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:
253
src/Application/Admin/CacheMetricsController.php
Normal file
253
src/Application/Admin/CacheMetricsController.php
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
186
src/Application/Admin/HealthController.php
Normal file
186
src/Application/Admin/HealthController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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>";
|
||||
|
||||
211
src/Application/Admin/LogViewerController.php
Normal file
211
src/Application/Admin/LogViewerController.php
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>";
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
39
src/Application/Admin/ShowImageManager.php
Normal file
39
src/Application/Admin/ShowImageManager.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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>";
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
184
src/Application/Admin/StyleguideController.php
Normal file
184
src/Application/Admin/StyleguideController.php
Normal 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)',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
496
src/Application/Admin/templates/health-dashboard.view.php
Normal file
496
src/Application/Admin/templates/health-dashboard.view.php
Normal 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>
|
||||
747
src/Application/Admin/templates/log-viewer.view.php
Normal file
747
src/Application/Admin/templates/log-viewer.view.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ current_year }} Framework Admin</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
250
src/Application/Admin/views/admin/image-manager.view.php
Normal file
250
src/Application/Admin/views/admin/image-manager.view.php
Normal 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>
|
||||
@@ -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>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ current_year }} Framework Admin</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -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>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ current_year }} Framework Admin</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ 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>
|
||||
353
src/Application/Analytics/AnalyticsController.php
Normal file
353
src/Application/Analytics/AnalyticsController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
110
src/Application/Analytics/Service/AnalyticsDashboardService.php
Normal file
110
src/Application/Analytics/Service/AnalyticsDashboardService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
108
src/Application/Analytics/Service/AnalyticsRealTimeService.php
Normal file
108
src/Application/Analytics/Service/AnalyticsRealTimeService.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
237
src/Application/Analytics/Service/AnalyticsReportService.php
Normal file
237
src/Application/Analytics/Service/AnalyticsReportService.php
Normal 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'] ?? []))),
|
||||
];
|
||||
}
|
||||
}
|
||||
110
src/Application/Analytics/ValueObject/ActionBreakdown.php
Normal file
110
src/Application/Analytics/ValueObject/ActionBreakdown.php
Normal 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;
|
||||
}
|
||||
}
|
||||
95
src/Application/Analytics/ValueObject/BrowserBreakdown.php
Normal file
95
src/Application/Analytics/ValueObject/BrowserBreakdown.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
171
src/Application/Analytics/ValueObject/BusinessMetricsReport.php
Normal file
171
src/Application/Analytics/ValueObject/BusinessMetricsReport.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
107
src/Application/Analytics/ValueObject/CountryBreakdown.php
Normal file
107
src/Application/Analytics/ValueObject/CountryBreakdown.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
84
src/Application/Analytics/ValueObject/DeviceBreakdown.php
Normal file
84
src/Application/Analytics/ValueObject/DeviceBreakdown.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
118
src/Application/Analytics/ValueObject/UserBehaviorReport.php
Normal file
118
src/Application/Analytics/ValueObject/UserBehaviorReport.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
472
src/Application/Analytics/templates/analytics-dashboard.view.php
Normal file
472
src/Application/Analytics/templates/analytics-dashboard.view.php
Normal 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>
|
||||
89
src/Application/Api/ApiDocsController.php
Normal file
89
src/Application/Api/ApiDocsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
216
src/Application/Api/ExampleApiController.php
Normal file
216
src/Application/Api/ExampleApiController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
134
src/Application/Api/Images/ImageApiController.php
Normal file
134
src/Application/Api/Images/ImageApiController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
121
src/Application/Api/Images/ImageSlotController.php
Normal file
121
src/Application/Api/Images/ImageSlotController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
162
src/Application/Api/MarkdownDocsController.php
Normal file
162
src/Application/Api/MarkdownDocsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
217
src/Application/Api/SimpleMarkdownController.php
Normal file
217
src/Application/Api/SimpleMarkdownController.php
Normal 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'),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
116
src/Application/Api/V1/UsersController.php
Normal file
116
src/Application/Api/V1/UsersController.php
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
224
src/Application/Api/V2/UsersController.php
Normal file
224
src/Application/Api/V2/UsersController.php
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
219
src/Application/Api/VersionController.php
Normal file
219
src/Application/Api/VersionController.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Auth;
|
||||
|
||||
use App\Framework\Http\ControllerRequest;
|
||||
|
||||
@@ -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
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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!']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
7
src/Application/Contact/contact-success.view.php
Normal file
7
src/Application/Contact/contact-success.view.php
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
95
src/Application/Controller/CsrfController.php
Normal file
95
src/Application/Controller/CsrfController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/Application/Controller/DemoController.php
Normal file
73
src/Application/Controller/DemoController.php
Normal 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',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
379
src/Application/Controller/QrCodeTestController.php
Normal file
379
src/Application/Controller/QrCodeTestController.php
Normal 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>";
|
||||
}
|
||||
}
|
||||
1530
src/Application/Controller/api-manager.view.php
Normal file
1530
src/Application/Controller/api-manager.view.php
Normal file
File diff suppressed because it is too large
Load Diff
1040
src/Application/Controller/canvas.view.php
Normal file
1040
src/Application/Controller/canvas.view.php
Normal file
File diff suppressed because it is too large
Load Diff
658
src/Application/Controller/permissions.view.php
Normal file
658
src/Application/Controller/permissions.view.php
Normal 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>
|
||||
318
src/Application/Design/Controller/DesignSystemController.php
Normal file
318
src/Application/Design/Controller/DesignSystemController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
384
src/Application/Design/Views/colors.view.php
Normal file
384
src/Application/Design/Views/colors.view.php
Normal 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>
|
||||
561
src/Application/Design/Views/component-detail.view.php
Normal file
561
src/Application/Design/Views/component-detail.view.php
Normal 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>
|
||||
114
src/Application/Design/Views/component-not-found.view.php
Normal file
114
src/Application/Design/Views/component-not-found.view.php
Normal 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>
|
||||
467
src/Application/Design/Views/components-overview.view.php
Normal file
467
src/Application/Design/Views/components-overview.view.php
Normal 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>
|
||||
180
src/Application/Design/Views/components.view.php
Normal file
180
src/Application/Design/Views/components.view.php
Normal 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>
|
||||
251
src/Application/Design/Views/conventions.view.php
Normal file
251
src/Application/Design/Views/conventions.view.php
Normal 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>
|
||||
252
src/Application/Design/Views/design-dashboard.view.php
Normal file
252
src/Application/Design/Views/design-dashboard.view.php
Normal 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>
|
||||
406
src/Application/Design/Views/roadmap.view.php
Normal file
406
src/Application/Design/Views/roadmap.view.php
Normal 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>
|
||||
275
src/Application/Design/Views/tokens.view.php
Normal file
275
src/Application/Design/Views/tokens.view.php
Normal 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>
|
||||
@@ -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
|
||||
{
|
||||
|
||||
54
src/Application/Examples/SimpleParameterExample.php
Normal file
54
src/Application/Examples/SimpleParameterExample.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
156
src/Application/FeatureFlags/FeatureFlagController.php
Normal file
156
src/Application/FeatureFlags/FeatureFlagController.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
290
src/Application/GraphQL/GraphQLController.php
Normal file
290
src/Application/GraphQL/GraphQLController.php
Normal 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],
|
||||
],
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
203
src/Application/GraphQL/GraphQLSchemaBuilder.php
Normal file
203
src/Application/GraphQL/GraphQLSchemaBuilder.php
Normal 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'
|
||||
),
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
60
src/Application/GraphQL/UserResolvers.php
Normal file
60
src/Application/GraphQL/UserResolvers.php
Normal 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();
|
||||
}
|
||||
}
|
||||
148
src/Application/GraphQL/UserService.php
Normal file
148
src/Application/GraphQL/UserService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
261
src/Application/Health/HealthCheckController.php
Normal file
261
src/Application/Health/HealthCheckController.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
130
src/Application/Http/BatchController.php
Normal file
130
src/Application/Http/BatchController.php
Normal 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])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
131
src/Application/Http/Examples/BatchExampleController.php
Normal file
131
src/Application/Http/Examples/BatchExampleController.php
Normal 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'),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Media;
|
||||
|
||||
113
src/Application/Media/MediaCleanupService.php
Normal file
113
src/Application/Media/MediaCleanupService.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
107
src/Application/Metrics/MetricsController.php
Normal file
107
src/Application/Metrics/MetricsController.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Newsletter\SignUp;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Newsletter\SignUp;
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
156
src/Application/Search/CreateIndexRequest.php
Normal file
156
src/Application/Search/CreateIndexRequest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
515
src/Application/Search/SearchController.php
Normal file
515
src/Application/Search/SearchController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
224
src/Application/Search/SearchRequest.php
Normal file
224
src/Application/Search/SearchRequest.php
Normal 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
Reference in New Issue
Block a user