docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,22 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="subtitle">{{ subtitle }}</p>
</if>
</div>
<div class="form-container">
<div class="card">
<div class="card-body">
{{ form }}
</div>
</div>
</div>
<div class="form-actions">
<a href="/admin/{{ resource }}" class="btn btn-secondary">Cancel</a>
</div>
</div>

View File

@@ -0,0 +1,35 @@
<layout name="layouts/admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<div class="page-actions">
<if condition="{{ actions }}">
<for items="{{ actions }}" value="action">
<a href="{{ action.url }}" class="btn btn-primary">
<if condition="{{ action.icon }}">
<i class="icon-{{ action.icon }}"></i>
</if>
{{ action.label }}
</a>
</for>
</if>
</div>
</div>
<if condition="{{ searchable }}">
<div class="table-controls">
<input type="text"
class="form-control search-input"
data-table-search="{{ resource }}"
placeholder="Search...">
</div>
</if>
<div class="table-container">
{{ table }}
</div>
<div class="pagination-container" data-table-pagination="{{ resource }}"></div>
</div>

View File

@@ -8,6 +8,8 @@
<title>Admin</title>
<meta name="description" content="Admin">
<meta property="og:type" content="Admin">
<link rel='stylesheet' href='/css/admin.css'>
</head>
<body>
@@ -15,10 +17,15 @@
<h2>Admin</h2>
</header>-->
<menu>
<li><a href="/admin">Dashboard</a></li>
</menu>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<main></main>

View File

@@ -0,0 +1,82 @@
<layout name="layouts/admin" />
<div class="section">
<h2>Analytics Dashboard</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Page Views</h3>
<p><strong>Today:</strong> {{ today_page_views }}</p>
<p><strong>This Week:</strong> {{ week_page_views }}</p>
<p><strong>This Month:</strong> {{ month_page_views }}</p>
</div>
<div class="stat-card">
<h3>Unique Visitors</h3>
<p><strong>Today:</strong> {{ today_visitors }}</p>
<p><strong>This Week:</strong> {{ week_visitors }}</p>
<p><strong>This Month:</strong> {{ month_visitors }}</p>
</div>
<div class="stat-card">
<h3>Performance</h3>
<p><strong>Avg. Load Time:</strong> {{ avg_load_time }}</p>
<p><strong>Bounce Rate:</strong> {{ bounce_rate }}</p>
<p><strong>Session Duration:</strong> {{ avg_session_duration }}</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-card full-width">
<h3>Top Pages</h3>
<if condition="{{ top_pages }}">
<table>
<thead>
<tr>
<th>Page</th>
<th>Views</th>
<th>Unique Visitors</th>
<th>Avg. Time on Page</th>
</tr>
</thead>
<tbody>
<for items="{{ top_pages }}" value="page">
<tr>
<td>{{ page.path }}</td>
<td>{{ page.views }}</td>
<td>{{ page.unique_visitors }}</td>
<td>{{ page.avg_time }}</td>
</tr>
</for>
</tbody>
</table>
<else>
<p>No analytics data available yet.</p>
<p>The analytics system will start collecting data once visitors start using your website.</p>
</if>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Traffic Sources</h3>
<if condition="{{ traffic_sources }}">
<ul style="list-style: none; padding: 0;">
<for items="{{ traffic_sources }}" key="source" value="percentage">
<li style="margin: 8px 0;">
<strong>{{ source }}:</strong> {{ percentage }}%
</li>
</for>
</ul>
<else>
<p>No traffic source data available.</p>
</if>
</div>
<div class="stat-card">
<h3>System Status</h3>
<p><strong>Analytics Status:</strong> <span style="color: var(--success);">Active</span></p>
<p><strong>Data Collection:</strong> <span style="color: var(--success);">Running</span></p>
<p><strong>Last Update:</strong> {{ last_update }}</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,211 @@
<layout name="layouts/admin" />
<div class="section">
<h2>Cache Metrics</h2>
<p class="section-description">Cache Performance Monitoring und Statistiken</p>
<!-- Cache Overview Cards -->
<div class="admin-grid admin-grid--4col">
<div class="admin-card metric-card">
<div class="metric-card__value">{{ hit_rate }}%</div>
<div class="metric-card__label">Hit Rate</div>
<div class="metric-card__change metric-card__change--{{ hit_rate >= 80 ? 'positive' : 'negative' }}">
{{ hit_rate >= 80 ? 'Excellent' : (hit_rate >= 60 ? 'Good' : 'Needs Improvement') }}
</div>
</div>
<div class="admin-card metric-card">
<div class="metric-card__value">{{ total_operations }}</div>
<div class="metric-card__label">Total Operations</div>
<div class="metric-card__change">Since startup</div>
</div>
<div class="admin-card metric-card">
<div class="metric-card__value">{{ avg_latency_ms }}ms</div>
<div class="metric-card__label">Average Latency</div>
<div class="metric-card__change metric-card__change--{{ avg_latency_ms <= 5 ? 'positive' : 'negative' }}">
{{ avg_latency_ms <= 5 ? 'Fast' : 'Slow' }}
</div>
</div>
<div class="admin-card metric-card">
<div class="metric-card__value">{{ total_size_mb }}MB</div>
<div class="metric-card__label">Total Cache Size</div>
<div class="metric-card__change">{{ active_drivers }} active drivers</div>
</div>
</div>
<!-- Health Status -->
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Cache Health Status</h3>
<span class="admin-table__status admin-table__status--{{ health_status === 'healthy' ? 'success' : (health_status === 'warning' ? 'warning' : 'error') }}">
<span class="status-indicator status-indicator--{{ health_status === 'healthy' ? 'success' : (health_status === 'warning' ? 'warning' : 'error') }}"></span>
{{ health_status|upper }}
</span>
</div>
<div class="admin-card__content">
<p>Cache system efficiency rating: <strong>{{ efficiency_rating }}</strong></p>
<if condition="{{ recommendations_count > 0 }}">
<p class="text-warning">{{ recommendations_count }} recommendations available for optimization.</p>
</if>
</div>
</div>
<!-- Driver Statistics -->
<if condition="{{ active_drivers > 0 }}">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Driver Statistics</h3>
</div>
<div class="admin-card__content">
<div class="admin-table-wrapper">
<table class="admin-table">
<thead>
<tr>
<th>Driver</th>
<th>Hit Rate</th>
<th>Operations</th>
<th>Avg Latency</th>
<th>Size (MB)</th>
</tr>
</thead>
<tbody>
<for items="{{ driver_stats }}" key="driver" value="stats">
<tr>
<td class="font-mono">{{ driver }}</td>
<td>{{ stats.hit_rate }}%</td>
<td>{{ stats.operations }}</td>
<td>{{ stats.avg_latency }}ms</td>
<td>{{ stats.size }}MB</td>
</tr>
</for>
</tbody>
</table>
</div>
</div>
</div>
</if>
<!-- Real-time Metrics (JavaScript will update these) -->
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Real-time Metrics</h3>
<button id="refresh-metrics" class="admin-button admin-button--small">Refresh</button>
</div>
<div class="admin-card__content">
<div id="real-time-metrics">
<p>Loading real-time data...</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Cache Actions</h3>
</div>
<div class="admin-card__content">
<div class="admin-actions">
<button id="reset-metrics" class="admin-button admin-button--warning">Reset Metrics</button>
<button id="generate-test-data" class="admin-button admin-button--secondary">Generate Test Data</button>
<a href="/admin/infrastructure/cache/metrics" class="admin-button admin-button--secondary">View JSON API</a>
</div>
</div>
</div>
</div>
</div>
<script>
// Real-time metrics updating
function updateMetrics() {
fetch('/admin/infrastructure/cache/metrics')
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
const container = document.getElementById('real-time-metrics');
const realTime = data.data.real_time;
const performance = data.data.performance_summary;
container.innerHTML = `
<div class="metrics-grid">
<div class="metric">
<strong>Current Hit Rate:</strong> ${(realTime.current_hit_rate * 100).toFixed(2)}%
</div>
<div class="metric">
<strong>Operations/sec:</strong> ${performance.ops_per_second}
</div>
<div class="metric">
<strong>Framework Activity:</strong> ${realTime.framework_cache_activity} keys
</div>
<div class="metric">
<strong>Last Updated:</strong> ${new Date(realTime.timestamp * 1000).toLocaleTimeString()}
</div>
</div>
<p class="text-muted">${realTime.sampling_note}</p>
`;
}
})
.catch(err => {
console.error('Error fetching metrics:', err);
});
}
// Auto-refresh every 5 seconds
setInterval(updateMetrics, 5000);
updateMetrics(); // Initial load
// Manual refresh button
document.getElementById('refresh-metrics').addEventListener('click', updateMetrics);
// Reset metrics button
document.getElementById('reset-metrics').addEventListener('click', function() {
if (confirm('Are you sure you want to reset all cache metrics?')) {
fetch('/admin/cache/metrics/reset', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
alert('Cache metrics have been reset');
location.reload();
}
});
}
});
// Generate test data button
document.getElementById('generate-test-data').addEventListener('click', function() {
fetch('/admin/cache/debug')
.then(response => response.json())
.then(data => {
alert('Test cache operations generated');
updateMetrics();
});
});
</script>
<style>
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.metric {
padding: 0.5rem;
background: var(--bg-alt);
border-radius: var(--radius-md);
}
.admin-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.text-muted {
color: var(--muted);
font-size: 0.875rem;
font-style: italic;
}
</style>

View File

@@ -1,146 +1,52 @@
<layout src="admin-main"/>
<a href="/admin/routes">Routes</a>
<br>
<a href="/admin/imageslots">Image Slots</a>
<layout name="layouts/admin" />
<div class="section">
<h2>Basis-Cards (nur semantische Selektoren)</h2>
<div class="demo-grid">
<article class="card">
<header>
<div>
<h3>Projekt Alpha</h3>
<small>Erstellt am 10. Juli 2025</small>
</div>
<span role="status">Aktiv</span>
</header>
<main>
<p>Diese Card nutzt nur semantische HTML-Elemente. Das Styling erfolgt über Selektoren wie <code>.card > header</code> und <code>.card h3</code>.</p>
<p>Weniger Klassen, sauberer HTML-Code.</p>
</main>
<footer>
<small>Letztes Update: heute</small>
<div>
<button>Öffnen</button>
<button>Teilen</button>
</div>
</footer>
</article>
<h2>Admin Dashboard</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>System Status</h3>
<p><strong>Status:</strong> <span style="color: var(--success);">Online</span></p>
<p><strong>Uptime:</strong> {{ uptime_formatted }}</p>
<p><strong>Version:</strong> {{ framework_version }}</p>
</div>
<article class="card">
<header>
<h3>Einfache Card</h3>
</header>
<main>
<p>Minimaler HTML-Code, maximale Semantik.</p>
</main>
<footer>
<div>
<button>Action</button>
</div>
</footer>
</article>
</div>
</div>
<div class="section">
<h2>Status-Varianten (Klassen für Varianten)</h2>
<div class="demo-grid">
<article class="card card--success">
<header>
<h3>Erfolg</h3>
<span role="status">Abgeschlossen</span>
</header>
<main>
<p>Success-Variante durch eine einzige Modifier-Klasse.</p>
</main>
<footer>
<div>
<button>Details</button>
</div>
</footer>
</article>
<article class="card card--error">
<header>
<h3>Fehler</h3>
<span role="status">Problem</span>
</header>
<main>
<p>Error-Variante mit systematischen Farben.</p>
</main>
<footer>
<div>
<button>Beheben</button>
<button>Ignorieren</button>
</div>
</footer>
</article>
</div>
</div>
<div class="section">
<h2>Größen-Varianten</h2>
<div class="demo-grid">
<article class="card card--compact">
<header>
<h3>Kompakt</h3>
</header>
<main>
<p>Weniger Padding durch Modifier-Klasse.</p>
</main>
</article>
<article class="card card--spacious">
<header>
<h3>Großzügig</h3>
</header>
<main>
<p>Mehr Weißraum für wichtige Inhalte.</p>
</main>
<footer>
<div>
<button>Hauptaktion</button>
</div>
</footer>
</article>
</div>
</div>
<div class="section">
<h2>Layout-Varianten</h2>
<div class="demo-grid demo-grid--wide">
<article class="card card--horizontal">
<header>
<h3>Horizontal</h3>
</header>
<main>
<p>Horizontales Layout durch Modifier.</p>
</main>
<footer>
<div>
<button>Action</button>
</div>
</footer>
</article>
<article class="card card--media">
<img src="https://picsum.photos/400/200?random=2" alt="Demo">
<header>
<h3>Mit Media</h3>
</header>
<main>
<p>Bild wird durch Selector <code>.card--media img</code> gestylt.</p>
</main>
<footer>
<div>
<button>Ansehen</button>
</div>
</footer>
</article>
<div class="stat-card">
<h3>Performance</h3>
<p><strong>Memory Usage:</strong> {{ memory_usage_formatted }}</p>
<p><strong>Peak Memory:</strong> {{ peak_memory_formatted }}</p>
<p><strong>Load Average:</strong> {{ load_average }}</p>
</div>
<div class="stat-card">
<h3>Database</h3>
<p><strong>Connection:</strong> <span style="color: var(--success);">Connected</span></p>
<p><strong>Pool Size:</strong> {{ db_pool_size }}</p>
<p><strong>Active Connections:</strong> {{ db_active_connections }}</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Cache Performance</h3>
<p><strong>Hit Rate:</strong> {{ cache_hit_rate }}</p>
<p><strong>Total Operations:</strong> {{ cache_total_operations }}</p>
<p><strong>Status:</strong> <span style="color: var(--success);">Running</span></p>
</div>
<div class="stat-card">
<h3>Recent Activity</h3>
<p><strong>Requests Today:</strong> {{ requests_today }}</p>
<p><strong>Errors:</strong> {{ errors_today }}</p>
<p><strong>Last Deployment:</strong> {{ last_deployment }}</p>
</div>
<div class="stat-card">
<h3>Quick Actions</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<a href="{{ clear_cache_url }}" style="background: var(--primary); color: white; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px;">Clear Cache</a>
<a href="{{ logs_url }}" style="background: var(--secondary); color: white; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px;">View Logs</a>
<a href="{{ migrations_url }}" style="background: var(--accent); color: white; padding: 8px 16px; border-radius: 4px; text-decoration: none; font-size: 14px;">Migrations</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,54 @@
<layout name="layouts/admin" />
<div class="section">
<h2>Umgebungsvariablen</h2>
<div class="admin-tools">
<input type="text" id="envFilter" placeholder="Variablen filtern..." style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; width: 300px;">
<div style="margin-top: 10px;">
<button class="filter-tag" data-prefix="APP_" style="margin: 2px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; cursor: pointer;">APP_</button>
<button class="filter-tag" data-prefix="DB_" style="margin: 2px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; cursor: pointer;">DB_</button>
<button class="filter-tag" data-prefix="REDIS_" style="margin: 2px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; cursor: pointer;">REDIS_</button>
<button class="filter-tag" data-prefix="RATE_LIMIT_" style="margin: 2px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; cursor: pointer;">RATE_LIMIT_</button>
<button class="filter-tag" data-prefix="PHP_" style="margin: 2px; padding: 4px 8px; border: 1px solid var(--gray-300); border-radius: 4px; cursor: pointer;">PHP_</button>
<button class="filter-tag active" data-prefix="" style="margin: 2px; padding: 4px 8px; border: 1px solid var(--primary); background: var(--primary); color: white; border-radius: 4px; cursor: pointer;">Alle</button>
</div>
</div>
<table-data source="env" type="environment" container-class="admin-card" id="envTable" />
</div>
<script>
// Filterung der Umgebungsvariablen
document.getElementById('envFilter').addEventListener('input', filterTable);
// Tag-Filter
document.querySelectorAll('.filter-tag').forEach(tag => {
tag.addEventListener('click', function() {
document.querySelectorAll('.filter-tag').forEach(t => {
t.style.background = '';
t.style.color = '';
t.style.borderColor = 'var(--gray-300)';
t.classList.remove('active');
});
this.classList.add('active');
this.style.background = 'var(--primary)';
this.style.color = 'white';
this.style.borderColor = 'var(--primary)';
const prefix = this.getAttribute('data-prefix');
document.getElementById('envFilter').value = prefix;
filterTable();
});
});
function filterTable() {
const filterValue = document.getElementById('envFilter').value.toLowerCase();
const rows = document.querySelectorAll('#envTable tbody tr');
rows.forEach(row => {
const key = row.cells[0].textContent.toLowerCase();
row.style.display = key.includes(filterValue) ? '' : 'none';
});
}
</script>

View File

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

View File

@@ -1,496 +1,129 @@
<?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;
}
<layout name="layouts/admin" />
<!-- Cache invalidation: 2025-01-20 16:11 -->
<div class="section">
<h2>{{ title }}</h2>
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 class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">System Overview</h3>
</div>
<div>
<button class="action-button" id="refreshBtn">
🔄 Refresh All
<div class="admin-card__content">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-md); margin-bottom: var(--space-lg);">
<div class="metric-card">
<div class="metric-card__value" style="color: var(--success);">
{{ overall_status }}
</div>
<div class="metric-card__label">Overall Status</div>
</div>
<div class="metric-card">
<div class="metric-card__value">{{ total_checks }}</div>
<div class="metric-card__label">Total Checks</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--success);">{{ healthy_checks }}</div>
<div class="metric-card__label">Healthy</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--warning);">{{ warning_checks }}</div>
<div class="metric-card__label">Warnings</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--error);">{{ failed_checks }}</div>
<div class="metric-card__label">Failed</div>
</div>
</div>
</div>
</div>
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Health Check Details</h3>
<button class="admin-button admin-button--small" onclick="refreshHealthChecks()" style="margin-left: auto;">
🔄 Refresh
</button>
</div>
<table-data source="health_check_table" container-class="admin-card" />
</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>
<script>
function toggleDetails(id) {
const element = document.getElementById(id);
if (element) {
element.style.display = element.style.display === 'none' ? 'table-row' : 'none';
}
}
<!-- Health Checks Grid -->
<div class="health-grid" id="healthGrid">
<!-- Health cards will be populated here -->
</div>
function refreshHealthChecks() {
window.location.reload();
}
<!-- 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>
// Auto-refresh health status every 60 seconds
setInterval(function() {
window.location.reload();
}, 60000);
</script>
<script>
class HealthDashboard {
constructor() {
this.refreshButton = document.getElementById('refreshBtn');
this.runAllButton = document.getElementById('runAllBtn');
this.exportButton = document.getElementById('exportBtn');
this.setupEventListeners();
this.loadHealthStatus();
<style>
.metric-card {
text-align: center;
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-alt);
}
// Auto-refresh every 30 seconds
setInterval(() => this.loadHealthStatus(), 30000);
}
.metric-card__value {
font-size: 2rem;
font-weight: 700;
margin-bottom: var(--space-sm);
}
setupEventListeners() {
this.refreshButton.addEventListener('click', () => {
this.loadHealthStatus();
});
.metric-card__label {
color: var(--muted);
font-size: 0.875rem;
}
this.runAllButton.addEventListener('click', () => {
this.runAllChecks();
});
.admin-table__action {
background: var(--primary);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: opacity 0.2s;
}
this.exportButton.addEventListener('click', () => {
this.exportReport();
});
}
.admin-table__action:hover {
opacity: 0.8;
}
async loadHealthStatus() {
try {
this.setLoading(true);
.admin-table__status {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
}
const response = await fetch('/admin/health/api/status');
if (!response.ok) throw new Error('Network error');
.admin-table__status--success {
background-color: rgba(16, 185, 129, 0.1);
color: rgb(16, 185, 129);
}
const data = await response.json();
.admin-table__status--warning {
background-color: rgba(245, 158, 11, 0.1);
color: rgb(245, 158, 11);
}
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);
}
}
.admin-table__status--error {
background-color: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
}
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>
.admin-table__status--unknown {
background-color: rgba(107, 114, 128, 0.1);
color: rgb(107, 114, 128);
}
</style>

View File

@@ -0,0 +1,906 @@
<layout name="layouts/admin" />
<div class="section">
<div class="page-header-actions">
<div>
<h2>{{ title }}</h2>
<p>{{ subtitle }}</p>
</div>
</div>
<!-- Image Upload Section -->
<div class="stat-card full-width">
<h3>Upload Images</h3>
<div data-module="image-manager"
data-image-uploader
data-upload-url="/api/images"
data-max-file-size="10485760"
data-allowed-types="image/jpeg,image/png,image/gif,image/webp"
data-max-files="10">
<div class="upload-area">
<div class="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
<circle cx="12" cy="13" r="3"></circle>
</svg>
</div>
<h4>Drag & Drop Images Here</h4>
<p>Or click to select files from your computer</p>
<button type="button" class="upload-btn">Choose Files</button>
<input type="file" class="upload-input" multiple accept="image/*" hidden>
<div class="upload-constraints">
<small>Max file size: 10MB | Supported: JPEG, PNG, GIF, WebP | Max 10 files at once</small>
</div>
</div>
<!-- Upload Progress -->
<div class="upload-progress" style="display: none;">
<div class="progress-header">
<h4>Uploading Images...</h4>
<span class="progress-text">0 / 0</span>
</div>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<div class="upload-files"></div>
</div>
</div>
</div>
<!-- Image Gallery Section -->
<div class="stat-card full-width">
<h3>Image Gallery</h3>
<div data-module="image-manager"
data-image-gallery
data-list-endpoint="/api/images"
data-upload-endpoint="/api/images"
data-page-size="20"
data-columns="4"
data-pagination
data-search
data-sort
data-allow-delete
data-allow-edit
data-selectable>
<!-- Gallery will be rendered here by JavaScript -->
<div class="gallery-loading">
<div class="loading-spinner"></div>
<p>Loading images...</p>
</div>
</div>
</div>
<!-- Legacy Image Slots Section (for backward compatibility) -->
<div class="stat-card full-width">
<h3>Image Slots (Legacy)</h3>
<p style="color: var(--gray-600); margin-bottom: 20px;">
Legacy image slots system - images can be dragged from the gallery above
</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">
<for var="slot" in="slots">
<div class="stat-card slot-item" data-slot-id="{{ slot.id }}">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h4>{{ slot.slotName }}</h4>
<p style="color: var(--gray-600); margin: 0;">ID: {{ slot.id }}</p>
</div>
<div class="slot-image-container" style="width: 100px; height: 100px;">
<div style="border: 2px dashed var(--gray-300); display: flex; align-items: center; justify-content: center; height: 100%; border-radius: 4px; cursor: pointer;"
ondrop="handleDrop(event, '{{ slot.id }}')"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)">
<span style="color: var(--gray-500); font-size: 12px; text-align: center;">Drop image here</span>
</div>
</div>
</div>
</div>
</for>
</div>
</div>
</div>
<!-- Image Modal -->
<div data-image-modal style="display: none;">
<div class="modal-backdrop"></div>
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Image Details</h3>
<button type="button" class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="modal-image-container">
<img class="modal-image" src="" alt="">
</div>
<div class="modal-details">
<div class="detail-group">
<label>Filename:</label>
<span class="detail-filename"></span>
</div>
<div class="detail-group">
<label>Dimensions:</label>
<span class="detail-dimensions"></span>
</div>
<div class="detail-group">
<label>File Size:</label>
<span class="detail-file-size"></span>
</div>
<div class="detail-group">
<label>Type:</label>
<span class="detail-mime-type"></span>
</div>
<div class="detail-group">
<label>Upload Date:</label>
<span class="detail-created-at"></span>
</div>
<div class="detail-group">
<label>Alt Text:</label>
<input type="text" class="detail-alt-text" placeholder="Enter alt text for accessibility">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-close">Close</button>
<button type="button" class="btn btn-primary save-alt-text">Save Alt Text</button>
<button type="button" class="btn btn-danger delete-image">Delete Image</button>
</div>
</div>
</div>
<style>
/* Image Manager Specific Styles */
.page-header-actions {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 20px;
}
.page-header-actions h2 {
margin: 0;
}
.page-header-actions p {
margin: 4px 0 0 0;
color: var(--gray-600);
}
/* Upload Styles */
.upload-area {
border: 2px dashed var(--gray-300);
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--gray-50);
}
.upload-area:hover {
border-color: var(--primary);
background: rgba(30, 64, 175, 0.05);
}
.upload-area.drag-over {
border-color: var(--primary);
background: rgba(30, 64, 175, 0.1);
transform: scale(1.02);
}
.upload-icon {
color: var(--gray-400);
margin-bottom: 16px;
}
.upload-area h4 {
margin: 0 0 8px 0;
color: var(--gray-700);
font-size: 18px;
}
.upload-area p {
margin: 0 0 20px 0;
color: var(--gray-500);
}
.upload-btn {
background: var(--primary);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.upload-btn:hover {
background: #1d4ed8;
}
.upload-constraints {
margin-top: 16px;
}
.upload-constraints small {
color: var(--gray-500);
font-size: 12px;
}
/* Progress Styles */
.upload-progress {
margin-top: 20px;
padding: 20px;
background: var(--gray-50);
border-radius: 8px;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.progress-header h4 {
margin: 0;
font-size: 16px;
}
.progress-text {
font-size: 14px;
color: var(--gray-600);
}
.progress-bar {
height: 8px;
background: var(--gray-200);
border-radius: 4px;
overflow: hidden;
margin-bottom: 16px;
}
.progress-fill {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.3s ease;
}
.upload-files {
display: flex;
flex-direction: column;
gap: 8px;
}
.upload-file {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: white;
border-radius: 4px;
border: 1px solid var(--gray-200);
}
.upload-file.success {
border-color: var(--success);
background: rgba(5, 150, 105, 0.05);
}
.upload-file.error {
border-color: var(--danger);
background: rgba(220, 38, 38, 0.05);
}
/* Gallery Styles */
.image-gallery {
width: 100%;
}
.gallery__controls {
display: flex;
gap: 16px;
margin-bottom: 20px;
align-items: center;
flex-wrap: wrap;
}
.gallery__search-wrapper {
position: relative;
flex: 1;
min-width: 250px;
}
.gallery__search {
width: 100%;
padding: 8px 32px 8px 12px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 14px;
}
.gallery__search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--gray-400);
}
.gallery__sort-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.gallery__sort {
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 14px;
background: white;
}
.gallery__grid {
display: grid;
grid-template-columns: repeat(var(--columns, 4), 1fr);
gap: 16px;
margin-bottom: 20px;
}
@media (max-width: 1024px) {
.gallery__grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.gallery__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.gallery__grid {
grid-template-columns: 1fr;
}
}
.gallery__item {
background: white;
border: 1px solid var(--gray-200);
border-radius: 8px;
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
}
.gallery__item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.gallery__item--selected {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(30, 64, 175, 0.2);
}
.gallery__item-inner {
height: 100%;
display: flex;
flex-direction: column;
}
.gallery__item-image {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
}
.gallery__item-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
.gallery__item:hover .gallery__item-image img {
transform: scale(1.05);
}
.gallery__item-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.gallery__item:hover .gallery__item-overlay {
opacity: 1;
}
.gallery__item-actions {
display: flex;
gap: 8px;
}
.action-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.action-btn:hover {
background: white;
transform: scale(1.1);
}
.gallery__item-info {
padding: 12px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.gallery__item-name {
font-weight: 500;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.gallery__item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: var(--gray-500);
}
.meta-item {
background: var(--gray-100);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
}
/* Pagination */
.gallery__pagination {
text-align: center;
margin-top: 20px;
}
.gallery__load-more {
background: var(--primary);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.gallery__load-more:hover {
background: #1d4ed8;
}
.gallery__load-more:disabled {
background: var(--gray-400);
cursor: not-allowed;
}
/* Loading and Empty States */
.gallery-loading,
.gallery__loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--gray-500);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--gray-200);
border-top: 3px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.gallery__empty {
text-align: center;
padding: 60px 20px;
color: var(--gray-500);
}
.empty-state__icon {
margin-bottom: 16px;
color: var(--gray-300);
}
.gallery__empty h3 {
margin: 0 0 8px 0;
color: var(--gray-700);
}
.gallery__empty p {
margin: 0;
color: var(--gray-500);
}
/* Modal Styles */
[data-image-modal] {
position: fixed;
inset: 0;
z-index: 1000;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 12px;
max-width: 800px;
max-height: 90vh;
width: 90vw;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--gray-200);
}
.modal-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--gray-400);
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: var(--gray-600);
}
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
display: grid;
grid-template-columns: 1fr 250px;
gap: 24px;
}
.modal-image-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.modal-image {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.modal-details {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-group label {
font-weight: 500;
color: var(--gray-700);
font-size: 14px;
}
.detail-group span,
.detail-group input {
color: var(--gray-600);
font-size: 14px;
}
.detail-alt-text {
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: 6px;
font-size: 14px;
width: 100%;
}
.modal-footer {
padding: 20px 24px;
border-top: 1px solid var(--gray-200);
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-secondary {
background: var(--gray-200);
color: var(--gray-700);
}
.btn-secondary:hover {
background: var(--gray-300);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
/* Error States */
.gallery__error {
margin: 16px 0;
padding: 12px 16px;
background: rgba(220, 38, 38, 0.1);
border: 1px solid var(--danger);
border-radius: 6px;
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
color: var(--danger);
font-size: 14px;
}
.error-close {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
margin-left: auto;
font-size: 16px;
}
/* Legacy Slots Integration */
.slot-item.selecting {
border-color: var(--primary) !important;
background-color: rgba(30, 64, 175, 0.1) !important;
}
@media (max-width: 768px) {
.modal-body {
grid-template-columns: 1fr;
gap: 16px;
}
.modal-content {
width: 95vw;
max-height: 95vh;
}
.gallery__controls {
flex-direction: column;
align-items: stretch;
}
.gallery__search-wrapper {
min-width: unset;
}
}
</style>
<script type="module">
// Legacy slot functions for backward compatibility
let draggedImageUlid = null;
let selectedSlotId = null;
function handleDragStart(event, imageUlid) {
draggedImageUlid = imageUlid;
event.dataTransfer.effectAllowed = 'copy';
}
function handleDragOver(event) {
event.preventDefault();
event.currentTarget.style.backgroundColor = 'var(--gray-100)';
}
function handleDragLeave(event) {
event.currentTarget.style.backgroundColor = '';
}
async function handleDrop(event, slotId) {
event.preventDefault();
event.currentTarget.style.backgroundColor = '';
if (draggedImageUlid) {
await assignImageToSlot(slotId, draggedImageUlid);
}
}
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();
} else {
alert('Failed to assign image');
}
} catch (error) {
console.error('Error:', error);
alert('Error assigning image');
}
}
function selectImage(imageUlid) {
const clickedSlot = document.querySelector('.slot-item.selecting');
if (clickedSlot) {
const slotId = clickedSlot.dataset.slotId;
assignImageToSlot(slotId, imageUlid);
clickedSlot.classList.remove('selecting');
}
}
// Make functions global for template access
window.handleDragStart = handleDragStart;
window.handleDragOver = handleDragOver;
window.handleDragLeave = handleDragLeave;
window.handleDrop = handleDrop;
window.selectImage = selectImage;
// Legacy slot click handlers - initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.slot-item').forEach(slot => {
const container = slot.querySelector('.slot-image-container');
if (container && !container.querySelector('img')) {
container.addEventListener('click', function() {
document.querySelectorAll('.slot-item').forEach(s => s.classList.remove('selecting'));
slot.classList.add('selecting');
slot.style.borderColor = 'var(--primary)';
slot.style.backgroundColor = 'rgba(30, 64, 175, 0.1)';
});
}
});
});
// Debug: Enable JavaScript logging and check module loading
console.log('🚀 Debug: Admin Image Manager Template loaded');
// Enable JavaScript debugging
if (window.Logger) {
window.Logger.setEnabled(true);
window.Logger.setLevel('info');
console.log('🔧 Debug: Logger enabled');
}
// Check if the image-manager module is loaded
setTimeout(() => {
console.log('🔍 Debug: Checking module loading...');
if (window.activeModules) {
console.log('📦 Available modules:', Array.from(window.activeModules.keys()));
const imageManager = window.activeModules.get('image-manager');
if (imageManager) {
console.log('✅ Image Manager module found:', imageManager);
} else {
console.log('❌ Image Manager module NOT found');
// Check if there are any errors for the module
const modules = Array.from(window.activeModules.entries());
console.log('🔧 All modules with details:', modules);
}
} else {
console.log('❌ No activeModules found in window');
}
// Check for data-image-gallery elements
const galleryElements = document.querySelectorAll('[data-image-gallery]');
console.log('🖼️ Gallery elements found:', galleryElements.length, galleryElements);
// Check for data-module elements
const moduleElements = document.querySelectorAll('[data-module]');
console.log('🔧 Module elements found:', moduleElements.length, moduleElements);
// Try to check moduleHealth if available
if (window.moduleHealth) {
const health = window.moduleHealth();
console.log('🏥 Module health:', health);
console.log('🏥 Individual modules:', health.modules);
}
}, 2000);
</script>

View File

@@ -1,21 +1,62 @@
<layout src="admin-main"/>
<layout name="layouts/admin" />
<for var="slot" in="slots">
<div class="section">
<h2>{{ title }}</h2>
<form action='/admin/imageslots/{{ slot.slotName }}' method='post'>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Existing Image Slots</h3>
</div>
<div class="admin-card__content">
<if condition="slots">
<div class="admin-table-wrapper">
<table class="admin-table">
<thead>
<tr>
<th>Slot Name</th>
<th>Current Image</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<for var="slot" in="slots">
<tr>
<td>{{ slot.slotName }}</td>
<td>
<if condition="slot.image">
{{ slot.image.filename }}
<else/>
<span style="color: var(--muted);">No image assigned</span>
</if>
</td>
<td class="admin-table__actions">
<form action="/admin/content/image-slots/{{ slot.slotName }}" method="post" style="display: inline;">
<button type="submit" class="admin-table__action">Edit</button>
</form>
</td>
</tr>
</for>
</tbody>
</table>
</div>
<else/>
<p style="color: var(--muted); text-align: center; padding: var(--space-lg);">No image slots created yet.</p>
</if>
</div>
</div>
<h3>{{ slot.slotName }}</h3>
<input type='submit' value='Update'/>
</form>
</for>
<form action='/admin/imageslots/create' method='post'>
<label>Slot Name:
<input type='text' name='slotName' value=''/>
</label>
<input type='submit' value='Create'/>
</form>
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Create New Image Slot</h3>
</div>
<div class="admin-card__content">
<form action="/admin/imageslots/create" method="post">
<div style="margin-bottom: var(--space-md);">
<label for="slotName" style="display: block; margin-bottom: var(--space-sm); font-weight: 500;">Slot Name:</label>
<input type="text" id="slotName" name="slotName" class="admin-input" placeholder="Enter slot name..." required />
</div>
<button type="submit" class="admin-button">Create Image Slot</button>
</form>
</div>
</div>
</div>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
<layout name="layouts/admin" />
<div class="section">
<h2>{{ title }}</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Migration Status</h3>
<p><strong>Total Migrations:</strong> {{ total_migrations }}</p>
<p><strong>Applied:</strong> <span style="color: var(--success);">{{ applied_count }}</span></p>
<p><strong>Pending:</strong> <span style="color: var(--warning);">{{ pending_count }}</span></p>
</div>
<div class="stat-card">
<h3>Database State</h3>
<p><strong>Pending Count:</strong> {{ pending_count }}</p>
<p><strong>Status:</strong>
<if condition="{{ has_pending }}">
<span style="color: var(--warning);">⚠️ {{ pending_count }} Migration(s) pending</span>
<else/>
<span style="color: var(--success);"> Database is up to date</span>
</if>
</p>
<if condition="{{ has_pending }}">
<p><strong>Action Required:</strong> <span style="color: var(--error);">Run migrations to update database</span></p>
</if>
</div>
</div>
<div class="admin-tools" style="margin: 1rem 0;">
<input type="text" id="migrationFilter" placeholder="Migrationen filtern..." class="search-input">
</div>
<table-data source="migrations_table" id="migrationsTable" striped="true" hover="true" />
</div>
<style>
/* Status badge styles for migrations table */
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
display: inline-block;
}
.status-badge.success {
background-color: rgba(34, 197, 94, 0.1);
color: rgb(34, 197, 94);
}
.status-badge.warning {
background-color: rgba(245, 158, 11, 0.1);
color: rgb(245, 158, 11);
}
.status-badge.error {
background-color: rgba(239, 68, 68, 0.1);
color: rgb(239, 68, 68);
}
.status-badge.secondary {
background-color: rgba(107, 114, 128, 0.1);
color: rgb(107, 114, 128);
}
/* Boolean badges for applied column */
.badge-success {
background-color: rgba(34, 197, 94, 0.1);
color: rgb(34, 197, 94);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-secondary {
background-color: rgba(107, 114, 128, 0.1);
color: rgb(107, 114, 128);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
/* Column specific styles */
.version-col {
font-family: monospace;
font-size: 0.875rem;
width: 120px;
}
.description-col {
max-width: 300px;
word-wrap: break-word;
}
.status-col {
width: 120px;
}
.applied-col {
width: 80px;
text-align: center;
}
.search-input {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.875rem;
min-width: 300px;
}
.search-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
</style>
<script>
// Filter functionality - works with the new table structure
document.addEventListener('DOMContentLoaded', function() {
const filterInput = document.getElementById('migrationFilter');
if (filterInput) {
filterInput.addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const table = document.getElementById('migrationsTable');
if (table) {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(filterValue) ? '' : 'none';
});
}
});
}
// Sort functionality - enhanced for new table structure
const table = document.getElementById('migrationsTable');
if (table) {
const headers = table.querySelectorAll('th .header-content');
headers.forEach((header, index) => {
header.style.cursor = 'pointer';
header.addEventListener('click', () => {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
const isAscending = header.classList.contains('sort-asc');
// Remove sort classes from all headers
headers.forEach(h => {
h.classList.remove('sort-asc', 'sort-desc');
});
// Add appropriate sort class
header.classList.add(isAscending ? 'sort-desc' : 'sort-asc');
rows.sort((a, b) => {
const aVal = a.cells[index].textContent.trim();
const bVal = b.cells[index].textContent.trim();
const comparison = aVal.localeCompare(bVal);
return isAscending ? -comparison : comparison;
});
rows.forEach(row => tbody.appendChild(row));
});
});
}
});
</script>

View File

@@ -0,0 +1,198 @@
<layout name="layouts/admin" />
<div class="section">
<h2>Performance Übersicht</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>💾 Speicher <span id="realtime-indicator" class="indicator"></span></h3>
<p><strong>Aktuell:</strong> <span id="current-memory">{{ performance.currentMemoryUsage }}</span></p>
<p><strong>Peak:</strong> <span id="peak-memory">{{ performance.peakMemoryUsage }}</span></p>
<p><strong>Limit:</strong> {{ performance.memoryLimit }}</p>
<p><strong>Auslastung:</strong> <span id="memory-percentage">{{ performance.memoryUsagePercentage }}</span>%</p>
<div class="progress-bar">
<div class="progress-fill" id="memory-progress" style="width: {{ performance.memoryUsagePercentage }}%"></div>
</div>
</div>
<div class="stat-card">
<h3>System</h3>
<p><strong>Ausführungszeit:</strong> {{ performance.executionTime }}</p>
<p><strong>Geladene Dateien:</strong> {{ performance.includedFiles }}</p>
<p><strong>OPCache:</strong> {{ performance.opcacheEnabled }}</p>
</div>
<if condition="{{ performance.opcacheMemoryUsage }}">
<div class="stat-card">
<h3>OPCache</h3>
<p><strong>Speicher:</strong> {{ performance.opcacheMemoryUsage }}</p>
<p><strong>Cache Hits:</strong> {{ performance.opcacheCacheHits }}</p>
<p><strong>Miss Rate:</strong> {{ performance.opcacheMissRate }}</p>
</div>
</if>
</div>
<!-- Real-time Controls -->
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">⚙️ Real-time Monitoring</h3>
</div>
<div class="admin-card__content">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="realtime-toggle">
<span>Enable Real-time Updates</span>
<span id="realtime-status" class="status-indicator"></span>
</label>
<p style="margin-top: 10px; font-size: 0.9em; color: var(--text-muted);">
<span id="last-update">Last Updated: {{ timestamp }}</span>
</p>
</div>
</div>
</div>
<style>
.progress-bar {
height: 8px;
background: var(--bg-muted);
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--success), var(--success-dark));
transition: width 0.3s ease;
}
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
margin-left: 8px;
}
.indicator.active {
background: var(--success);
animation: pulse 1s infinite;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
display: inline-block;
}
.status-indicator.active {
background: var(--success);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-lg);
margin-bottom: var(--space-lg);
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-lg);
}
.stat-card h3 {
margin: 0 0 var(--space-md) 0;
color: var(--text-primary);
display: flex;
align-items: center;
}
.stat-card p {
margin: var(--space-sm) 0;
color: var(--text-secondary);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const toggle = document.getElementById('realtime-toggle');
const statusIndicator = document.getElementById('realtime-status');
const realtimeIndicator = document.getElementById('realtime-indicator');
const lastUpdate = document.getElementById('last-update');
let updateInterval = null;
toggle?.addEventListener('change', function() {
if (this.checked) {
startRealtimeUpdates();
} else {
stopRealtimeUpdates();
}
});
function startRealtimeUpdates() {
statusIndicator?.classList.add('active');
realtimeIndicator?.classList.add('active');
updateInterval = setInterval(async () => {
try {
const response = await fetch('/admin/system/performance/api/realtime');
const data = await response.json();
updateMetrics(data);
} catch (error) {
console.error('Failed to fetch realtime metrics:', error);
stopRealtimeUpdates();
toggle.checked = false;
}
}, 3000); // Update every 3 seconds
}
function stopRealtimeUpdates() {
statusIndicator?.classList.remove('active');
realtimeIndicator?.classList.remove('active');
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = null;
}
}
function updateMetrics(data) {
// Update memory metrics
const currentMemory = document.getElementById('current-memory');
if (currentMemory && data.memory?.current) {
currentMemory.textContent = data.memory.current;
}
const peakMemory = document.getElementById('peak-memory');
if (peakMemory && data.memory?.peak) {
peakMemory.textContent = data.memory.peak;
}
const memoryPercentage = document.getElementById('memory-percentage');
const memoryProgress = document.getElementById('memory-progress');
if (data.memory?.usage_percentage !== undefined) {
if (memoryPercentage) {
memoryPercentage.textContent = data.memory.usage_percentage.toFixed(1);
}
if (memoryProgress) {
memoryProgress.style.width = data.memory.usage_percentage + '%';
}
}
// Update timestamp
if (lastUpdate && data.timestamp) {
lastUpdate.textContent = 'Last Updated: ' + data.timestamp;
}
}
});
</script>

View File

@@ -0,0 +1,63 @@
<layout name="layouts/admin" />
<div class="section">
<h2>{{ title }}</h2>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">General Information</h3>
</div>
<table-data source="general_info" type="phpinfo" container-class="admin-card" id="generalInfoTable" />
</div>
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Configuration</h3>
</div>
<table-data source="config_info" type="phpinfo" container-class="admin-card" id="configInfoTable" />
</div>
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Extensions</h3>
</div>
<div class="admin-card__content">
<div style="margin-bottom: var(--space-md);">
<div class="metric-card" style="display: inline-block; margin-right: var(--space-md);">
<div class="metric-card__value">{{ extensions_count }}</div>
<div class="metric-card__label">Total Extensions</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-sm);">
<for var="extension" in="extensions_list">
<div style="padding: var(--space-sm); background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.875rem;">
{{ extension }}
</div>
</for>
</div>
</div>
</div>
</div>
<style>
.metric-card {
text-align: center;
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-alt);
}
.metric-card__value {
font-size: 2rem;
font-weight: 700;
margin-bottom: var(--space-sm);
color: var(--primary);
}
.metric-card__label {
color: var(--muted);
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Campaign - Admin</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<layout name="admin">
<div class="container">
<div class="page-header">
<h1>Create Pre-Save Campaign</h1>
<a href="/admin/presave/campaigns" class="btn btn-secondary">Back to Campaigns</a>
</div>
<form action="/admin/presave/campaigns" method="POST" class="campaign-form">
<csrf-token />
<div class="form-section">
<h2>Basic Information</h2>
<div class="form-group">
<label for="title">Campaign Title *</label>
<input type="text" id="title" name="title" required class="form-control">
</div>
<div class="form-group">
<label for="artist_name">Artist Name *</label>
<input type="text" id="artist_name" name="artist_name" required class="form-control">
</div>
<div class="form-group">
<label for="cover_image_url">Cover Image URL *</label>
<input type="url" id="cover_image_url" name="cover_image_url" required class="form-control">
<small>Direct URL to album/single cover image</small>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="4" class="form-control"></textarea>
</div>
</div>
<div class="form-section">
<h2>Release Information</h2>
<div class="form-group">
<label for="release_date">Release Date *</label>
<input type="datetime-local" id="release_date" name="release_date" required class="form-control">
</div>
<div class="form-group">
<label for="start_date">Campaign Start Date</label>
<input type="datetime-local" id="start_date" name="start_date" class="form-control">
<small>Leave empty to start immediately upon activation</small>
</div>
</div>
<div class="form-section">
<h2>Track URLs</h2>
<p class="text-muted">At least one platform URL is required</p>
<div class="form-group">
<label for="spotify_url">Spotify URL</label>
<input type="url" id="spotify_url" name="spotify_url" class="form-control" placeholder="https://open.spotify.com/album/...">
</div>
<div class="form-group">
<label for="apple_music_url">Apple Music URL</label>
<input type="url" id="apple_music_url" name="apple_music_url" class="form-control" placeholder="https://music.apple.com/album/...">
</div>
<div class="form-group">
<label for="tidal_url">Tidal URL</label>
<input type="url" id="tidal_url" name="tidal_url" class="form-control" placeholder="https://tidal.com/browse/album/...">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Create Campaign</button>
<a href="/admin/presave/campaigns" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</layout>
<script>
// Set default release date to tomorrow
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
document.getElementById('release_date').value = tomorrow.toISOString().slice(0, 16);
</script>
</body>
</html>

View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Campaign - {campaign.title}</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<layout name="admin">
<div class="container">
<div class="page-header">
<h1>Edit Campaign</h1>
<a href="/admin/presave/campaigns/{campaign.id}" class="btn btn-secondary">Back to Details</a>
</div>
<form action="/admin/presave/campaigns/{campaign.id}" method="POST" class="campaign-form">
<csrf-token />
<input type="hidden" name="_method" value="PUT">
<div class="form-section">
<h2>Basic Information</h2>
<div class="form-group">
<label for="title">Campaign Title *</label>
<input type="text" id="title" name="title" value="{campaign.title}" required class="form-control">
</div>
<div class="form-group">
<label for="artist_name">Artist Name *</label>
<input type="text" id="artist_name" name="artist_name" value="{campaign.artistName}" required class="form-control">
</div>
<div class="form-group">
<label for="cover_image_url">Cover Image URL *</label>
<input type="url" id="cover_image_url" name="cover_image_url" value="{campaign.coverImageUrl}" required class="form-control">
<small>Direct URL to album/single cover image</small>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="4" class="form-control">{campaign.description}</textarea>
</div>
</div>
<div class="form-section">
<h2>Release Information</h2>
<div class="form-group">
<label for="release_date">Release Date *</label>
<input type="datetime-local" id="release_date" name="release_date" value="{campaign.releaseDate|datetime_input}" required class="form-control">
</div>
</div>
<div class="form-section">
<h2>Track URLs</h2>
<p class="text-muted">At least one platform URL is required</p>
<div class="form-group">
<label for="spotify_url">Spotify URL</label>
<input type="url" id="spotify_url" name="spotify_url" value="{campaign.trackUrls.spotify}" class="form-control" placeholder="https://open.spotify.com/album/...">
</div>
<div class="form-group">
<label for="apple_music_url">Apple Music URL</label>
<input type="url" id="apple_music_url" name="apple_music_url" value="{campaign.trackUrls.apple_music}" class="form-control" placeholder="https://music.apple.com/album/...">
</div>
<div class="form-group">
<label for="tidal_url">Tidal URL</label>
<input type="url" id="tidal_url" name="tidal_url" value="{campaign.trackUrls.tidal}" class="form-control" placeholder="https://tidal.com/browse/album/...">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Update Campaign</button>
<a href="/admin/presave/campaigns/{campaign.id}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</layout>
<script>
// Convert timestamp to datetime-local format
const releaseDate = new Date({campaign.releaseDate} * 1000);
document.getElementById('release_date').value = releaseDate.toISOString().slice(0, 16);
</script>
</body>
</html>

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pre-Save Campaigns - Admin</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<layout name="admin">
<div class="container">
<div class="page-header">
<h1>Pre-Save Campaigns</h1>
<a href="/admin/presave/campaigns/create" class="btn btn-primary">New Campaign</a>
</div>
<if condition="{stats}">
<div class="stats-grid">
<div class="stat-card">
<h3>Total Campaigns</h3>
<p class="stat-value">{stats.total}</p>
</div>
<div class="stat-card">
<h3>Active</h3>
<p class="stat-value">{stats.active}</p>
</div>
<div class="stat-card">
<h3>Total Registrations</h3>
<p class="stat-value">{stats.total_registrations}</p>
</div>
<div class="stat-card">
<h3>Completed</h3>
<p class="stat-value">{stats.completed}</p>
</div>
</div>
</if>
<if condition="{campaigns}">
<div class="campaigns-list">
<for items="{campaigns}" as="campaign">
<div class="campaign-card">
<div class="campaign-header">
<img src="{campaign.coverImageUrl}" alt="{campaign.title}" class="campaign-cover">
<div class="campaign-info">
<h3>{campaign.title}</h3>
<p class="artist">{campaign.artistName}</p>
<p class="release-date">Release: {campaign.releaseDate|date}</p>
</div>
<div class="campaign-status">
<span class="badge badge-{campaign.status.value}">{campaign.status.value}</span>
</div>
</div>
<div class="campaign-actions">
<a href="/admin/presave/campaigns/{campaign.id}" class="btn btn-sm">View</a>
<a href="/admin/presave/campaigns/{campaign.id}/edit" class="btn btn-sm">Edit</a>
<if condition="{campaign.status.value === 'draft'}">
<form action="/admin/presave/campaigns/{campaign.id}/activate" method="POST" style="display: inline;">
<csrf-token />
<button type="submit" class="btn btn-sm btn-success">Activate</button>
</form>
</if>
<if condition="{campaign.status.value === 'active'}">
<form action="/admin/presave/campaigns/{campaign.id}/pause" method="POST" style="display: inline;">
<csrf-token />
<button type="submit" class="btn btn-sm btn-warning">Pause</button>
</form>
</if>
<button class="btn btn-sm btn-danger" onclick="deleteCampaign({campaign.id})">Delete</button>
</div>
</div>
</for>
</div>
</if>
<if condition="{!campaigns || campaigns.length === 0}">
<div class="empty-state">
<p>No campaigns yet.</p>
<a href="/admin/presave/campaigns/create" class="btn btn-primary">Create your first campaign</a>
</div>
</if>
</div>
</layout>
<script>
async function deleteCampaign(id) {
if (!confirm('Are you sure you want to delete this campaign? This will also delete all registrations.')) {
return;
}
try {
const response = await fetch(`/admin/presave/campaigns/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
window.location.reload();
} else {
alert(data.message || 'Failed to delete campaign');
}
} catch (error) {
alert('Error deleting campaign');
console.error(error);
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{campaign.title} - Campaign Details</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<layout name="admin">
<div class="container">
<div class="page-header">
<h1>{campaign.title}</h1>
<div class="header-actions">
<a href="/admin/presave/campaigns/{campaign.id}/edit" class="btn btn-primary">Edit</a>
<a href="/admin/presave/campaigns" class="btn btn-secondary">Back to List</a>
</div>
</div>
<div class="campaign-details">
<div class="campaign-cover-large">
<img src="{campaign.coverImageUrl}" alt="{campaign.title}">
</div>
<div class="campaign-info-section">
<h2>Campaign Information</h2>
<dl class="info-list">
<dt>Artist</dt>
<dd>{campaign.artistName}</dd>
<dt>Status</dt>
<dd><span class="badge badge-{campaign.status.value}">{campaign.status.value}</span></dd>
<dt>Release Date</dt>
<dd>{campaign.releaseDate|date}</dd>
<if condition="{campaign.description}">
<dt>Description</dt>
<dd>{campaign.description}</dd>
</if>
<dt>Available Platforms</dt>
<dd>
<div class="platform-links">
<for items="{campaign.trackUrls}" as="platform=>url">
<a href="{url}" target="_blank" class="platform-badge">{platform}</a>
</for>
</div>
</dd>
</dl>
</div>
<div class="campaign-stats-section">
<h2>Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Total Registrations</h3>
<p class="stat-value">{stats.total_registrations}</p>
</div>
<div class="stat-card">
<h3>Pending</h3>
<p class="stat-value">{stats.pending}</p>
</div>
<div class="stat-card">
<h3>Completed</h3>
<p class="stat-value">{stats.completed}</p>
</div>
<div class="stat-card">
<h3>Failed</h3>
<p class="stat-value">{stats.failed}</p>
</div>
</div>
<h3>By Platform</h3>
<div class="platform-stats">
<div class="platform-stat">
<span class="platform-name">Spotify</span>
<span class="platform-count">{stats.by_platform.spotify}</span>
</div>
<div class="platform-stat">
<span class="platform-name">Apple Music</span>
<span class="platform-count">{stats.by_platform.apple_music}</span>
</div>
<div class="platform-stat">
<span class="platform-name">Tidal</span>
<span class="platform-count">{stats.by_platform.tidal}</span>
</div>
</div>
</div>
<div class="campaign-actions-section">
<h2>Campaign Actions</h2>
<div class="action-buttons">
<if condition="{campaign.status.value === 'draft'}">
<form action="/admin/presave/campaigns/{campaign.id}/activate" method="POST">
<csrf-token />
<button type="submit" class="btn btn-success">Activate Campaign</button>
</form>
</if>
<if condition="{campaign.status.value === 'active'}">
<form action="/admin/presave/campaigns/{campaign.id}/pause" method="POST">
<csrf-token />
<button type="submit" class="btn btn-warning">Pause Campaign</button>
</form>
</if>
<if condition="{campaign.status.value === 'paused'}">
<form action="/admin/presave/campaigns/{campaign.id}/activate" method="POST">
<csrf-token />
<button type="submit" class="btn btn-success">Resume Campaign</button>
</form>
</if>
<if condition="{campaign.status.value !== 'completed'}">
<form action="/admin/presave/campaigns/{campaign.id}/complete" method="POST">
<csrf-token />
<button type="submit" class="btn btn-primary">Mark as Completed</button>
</form>
</if>
</div>
</div>
<div class="registrations-section">
<h2>Registrations</h2>
<if condition="{registrations && registrations.length > 0}">
<table class="data-table">
<thead>
<tr>
<th>User ID</th>
<th>Platform</th>
<th>Status</th>
<th>Registered</th>
<th>Processed</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<for items="{registrations}" as="registration">
<tr>
<td>{registration.userId}</td>
<td>{registration.platform.value}</td>
<td><span class="badge badge-{registration.status.value}">{registration.status.value}</span></td>
<td>{registration.registeredAt|datetime}</td>
<td>
<if condition="{registration.processedAt}">
{registration.processedAt|datetime}
</if>
<if condition="{!registration.processedAt}">
-
</if>
</td>
<td>
<if condition="{registration.errorMessage}">
<span class="error-message" title="{registration.errorMessage}">
{registration.errorMessage|truncate:50}
</span>
</if>
<if condition="{!registration.errorMessage}">
-
</if>
</td>
</tr>
</for>
</tbody>
</table>
</if>
<if condition="{!registrations || registrations.length === 0}">
<p class="text-muted">No registrations yet.</p>
</if>
</div>
</div>
</div>
</layout>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<layout name="layouts/admin" />
<div class="section">
<h2>{{ title }}</h2>
<!-- Connection Status -->
<div class="stats-grid">
<div class="stat-card">
<h3>Connection Status</h3>
<p><strong>Status:</strong> {{ redis.status }}</p>
<p><strong>Connected:</strong> {{ redis.is_connected }}</p>
</div>
</div>
<!-- TODO: Conditional sections will work once ForProcessor/IfProcessor are fixed -->
<div class="stat-card">
<h3>Debug Information</h3>
<p><strong>Has Basic Info:</strong> {{ redis.has_basic_info }}</p>
<p><strong>Has Error:</strong> {{ redis.has_error }}</p>
<p><strong>Has Databases:</strong> {{ redis.has_databases }}</p>
<p><strong>Has Cache Patterns:</strong> {{ redis.has_cache_patterns }}</p>
</div>
</div>
<style>
.redis-section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.redis-section h3 {
margin: 0 0 16px 0;
color: var(--gray-900);
border-bottom: 2px solid var(--primary);
padding-bottom: 8px;
}
.key-detail, .slow-log-entry, .cache-pattern, .database-item {
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: 4px;
padding: 12px;
margin-bottom: 12px;
}
.status-indicator {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
.status-connected { background-color: var(--success); }
.status-error { background-color: var(--danger); }
</style>
<script>
// Auto-refresh der Seite alle 30 Sekunden
setTimeout(() => {
window.location.reload();
}, 30000);
</script>

View File

@@ -0,0 +1,60 @@
<layout name="layouts/admin" />
<div class="section">
<h2>System Routes</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Overview</h3>
<p><strong>Total Routes:</strong> {{ total_routes }}</p>
<p><strong>Admin Routes:</strong> {{ admin_routes_count }}</p>
<p><strong>API Routes:</strong> {{ api_routes_count }}</p>
</div>
<div class="stat-card">
<h3>HTTP Methods</h3>
<p><strong>GET:</strong> {{ get_routes_count }}</p>
<p><strong>POST:</strong> {{ post_routes_count }}</p>
<p><strong>PUT/PATCH:</strong> {{ put_routes_count }}</p>
<p><strong>DELETE:</strong> {{ delete_routes_count }}</p>
</div>
<div class="stat-card">
<h3>Authentication</h3>
<p><strong>Protected Routes:</strong> {{ protected_routes_count }}</p>
<p><strong>Public Routes:</strong> {{ public_routes_count }}</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-card full-width">
<h3>Route List</h3>
<table-data source="routes" type="routes" container-class="admin-card" id="routesTable" />
</div>
</div>
<if condition="{{ middlewares }}">
<div class="stats-grid">
<div class="stat-card full-width">
<h3>Middleware Usage</h3>
<table>
<thead>
<tr>
<th>Middleware</th>
<th>Usage Count</th>
<th>Routes</th>
</tr>
</thead>
<tbody>
<for items="{{ middlewares }}" key="middleware" value="data">
<tr>
<td>{{ middleware }}</td>
<td>{{ data.count }}</td>
<td style="font-size: 12px;">{{ data.routes }}</td>
</tr>
</for>
</tbody>
</table>
</div>
</div>
</if>
</div>

View File

@@ -1,46 +0,0 @@
<layout src="admin-main"/>
<div class="admin-content">
<h1>{{ title }}</h1>
<div class="admin-tools">
<input type="text" id="routeFilter" placeholder="Routen filtern..." class="search-input">
</div>
<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>
<script>
document.getElementById('routeFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const rows = document.querySelectorAll('#routesTable tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>

View File

@@ -0,0 +1,41 @@
<layout name="layouts/admin" />
<div class="section">
<h2>Registrierte Dienste</h2>
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<input type="text" id="serviceFilter" placeholder="Dienste filtern..."
style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; width: 300px;">
<span class="services-count" style="color: var(--gray-600);">{{ servicesCount }} Dienste insgesamt</span>
</div>
<div class="stats-grid" id="serviceList">
<for var="service" in="services">
<div class="stat-card service-item">
<h3>{{ service.name }}</h3>
<p><strong>Kategorie:</strong> {{ service.category }}</p>
<if condition="{{ service.subCategory }}">
<p><strong>Unterkategorie:</strong> {{ service.subCategory }}</p>
</if>
</div>
</for>
</div>
</div>
<script>
document.getElementById('serviceFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const items = document.querySelectorAll('#serviceList .service-item');
let visibleCount = 0;
items.forEach(item => {
const text = item.textContent.toLowerCase();
const isVisible = text.includes(filterValue);
item.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
document.querySelector('.services-count').textContent =
visibleCount + ' von {{ servicesCount }} Diensten';
});
</script>

View File

@@ -0,0 +1,88 @@
<layout name="layouts/admin" />
<div class="section">
<h2>{{ title }}</h2>
<if condition="{{ success }}">
<div class="alert alert-success">
<strong>Erfolg!</strong> {{ description }}
</div>
<else>
<if condition="{{ error }}">
<div class="alert alert-danger">
<strong>Fehler:</strong> {{ description }}
</div>
<else>
<p>{{ description }}</p>
</if>
</if>
<div class="stat-card">
<h3>Bild hochladen</h3>
{{formHtml}}
</div>
</div>
<style>
.alert {
padding: 12px 16px;
border-radius: 6px;
margin-bottom: 20px;
border: 1px solid;
}
.alert-success {
background: rgba(5, 150, 105, 0.1);
border-color: var(--success);
color: var(--success);
}
.alert-danger {
background: rgba(220, 38, 38, 0.1);
border-color: var(--danger);
color: var(--danger);
}
.upload-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-form label {
font-weight: 500;
color: var(--gray-700);
margin-bottom: 4px;
}
.upload-form input[type="file"] {
padding: 12px;
border: 2px dashed var(--gray-300);
border-radius: 8px;
background: var(--gray-50);
transition: border-color 0.2s;
cursor: pointer;
}
.upload-form input[type="file"]:hover {
border-color: var(--primary);
background: rgba(30, 64, 175, 0.05);
}
.upload-form button {
background: var(--primary);
color: white;
padding: 12px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
align-self: flex-start;
}
.upload-form button:hover {
background: #1d4ed8;
}
</style>

View File

@@ -0,0 +1,213 @@
<layout name="layouts/admin" />
<div class="section">
<h2>{{ title }}</h2>
<p>{{ description }}</p>
<div class="stat-card">
<h3>JavaScript Upload Test</h3>
<div class="upload-test-form">
<div class="form-group">
<label for="test-upload">Select Image Files:</label>
<input type="file" id="test-upload" accept="image/*" multiple>
</div>
<button type="button" id="upload-button" class="upload-button">
Upload Files
</button>
<div class="progress-container">
<div class="progress-bar-wrapper">
<div id="progress-bar" class="progress-bar"></div>
</div>
<div id="progress-text" class="progress-text"></div>
</div>
</div>
</div>
<div class="stat-card">
<h3>Upload Results</h3>
<div id="results" class="results-container">
<p class="help-text">Upload results will appear here...</p>
</div>
</div>
<div class="stat-card">
<h3>Test Console Commands</h3>
<p>Open browser console and try:</p>
<pre><code>// Test CSRF token generation
await testCsrfTokens();
// Test file validation
FileValidator.validateImage(file);
// Access upload manager directly
uploadManager.getCsrfTokens('/api/images', 'post');</code></pre>
</div>
</div>
<style>
.upload-test-form {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-weight: 500;
color: var(--gray-700);
margin-bottom: 4px;
}
.form-group input[type="file"] {
padding: 12px;
border: 2px dashed var(--gray-300);
border-radius: 8px;
background: var(--gray-50);
width: 100%;
cursor: pointer;
transition: border-color 0.2s;
}
.form-group input[type="file"]:hover {
border-color: var(--primary);
background: rgba(30, 64, 175, 0.05);
}
.upload-button {
background: var(--primary);
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.upload-button:hover:not(:disabled) {
background: #1d4ed8;
}
.upload-button:disabled {
background: var(--gray-400);
cursor: not-allowed;
}
.progress-container {
margin-top: 16px;
display: none;
}
.progress-bar-wrapper {
width: 100%;
height: 8px;
background: var(--gray-200);
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.3s ease;
}
.progress-text {
font-size: 14px;
color: var(--gray-600);
}
.results-container {
max-height: 400px;
overflow-y: auto;
}
.help-text {
color: var(--gray-500);
font-style: italic;
}
.message {
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 8px;
font-size: 14px;
}
.message-info {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
color: #1e40af;
}
.message-success {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #15803d;
}
.message-error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #dc2626;
}
.upload-result {
border: 1px solid var(--gray-200);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
background: var(--gray-50);
}
.upload-result h4 {
margin: 0 0 8px 0;
font-size: 16px;
}
.result-details p {
margin: 4px 0;
font-size: 14px;
}
.result-details img {
border-radius: 4px;
border: 1px solid var(--gray-200);
}
.error {
color: var(--danger);
}
pre {
background: var(--gray-100);
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-size: 13px;
}
</style>
<script type="module">
// Show progress container when upload starts
document.addEventListener('DOMContentLoaded', () => {
const uploadButton = document.getElementById('upload-button');
const progressContainer = document.querySelector('.progress-container');
if (uploadButton && progressContainer) {
uploadButton.addEventListener('click', () => {
progressContainer.style.display = 'block';
});
}
});
</script>
<script type="module" src="/js/test-upload.js"></script>