🎉 Improved form testing infrastructure with new files: 'simple_form_tester.py', 'ui_components.html' templates, and updated test suite in 'form_test_automator.py'. 📚

This commit is contained in:
2025-06-18 08:49:23 +02:00
parent 9a03e52209
commit f1e3a2cfea
5 changed files with 919 additions and 490 deletions

View File

@@ -336,11 +336,13 @@
System-Info
</a>
<div class="border-t border-white/10 my-2"></div>
<button onclick="handleLogout()"
class="w-full flex items-center px-3 py-2 rounded-lg hover:bg-red-500/20 text-red-600 dark:text-red-400">
<i class="fas fa-sign-out-alt w-4 mr-3"></i>
Abmelden
</button>
<form method="POST" action="{{ url_for('auth.logout') }}" class="w-full">
{{ csrf_token() }}
<button type="submit" class="w-full flex items-center px-3 py-2 rounded-lg hover:bg-red-500/20 text-red-600 dark:text-red-400">
<i class="fas fa-sign-out-alt w-4 mr-3"></i>
Abmelden
</button>
</form>
</div>
</div>
</div>
@@ -438,16 +440,11 @@
<div class="flex-1">
<p class="text-sm font-medium">{{ message }}</p>
</div>
<button onclick="this.parentElement.parentElement.remove()" class="ml-3 hover:opacity-70">
<button type="button" class="ml-3 hover:opacity-70 close-flash-btn">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
<script>
setTimeout(() => {
document.getElementById('flash-{{ loop.index }}')?.remove();
}, 5000);
</script>
{% endfor %}
</div>
{% endif %}
@@ -727,12 +724,11 @@
const payload = JSON.parse(notification.payload || '{}');
return `
<div class="mt-3 flex space-x-2">
<button onclick="window.notificationManager.viewGuestRequest(${payload.request_id})"
class="px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600">
<a href="/admin/guest-requests?highlight=${payload.request_id}"
class="inline-block px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600">
Anzeigen
</button>
<button onclick="window.notificationManager.markAsRead(${notification.id})"
class="px-3 py-1 bg-gray-500 text-white text-xs rounded hover:bg-gray-600">
</a>
<button data-notification-id="${notification.id}" class="mark-read-btn px-3 py-1 bg-gray-500 text-white text-xs rounded hover:bg-gray-600">
Als gelesen markieren
</button>
</div>
@@ -748,10 +744,7 @@
// Event Listeners werden über onclick direkt gesetzt
}
async viewGuestRequest(requestId) {
// Weiterleitung zur Admin-Gastanfragen-Seite
window.location.href = `/admin/guest-requests?highlight=${requestId}`;
}
// Replaced by direct links in templates
async markAsRead(notificationId) {
try {
@@ -862,26 +855,19 @@
e.stopPropagation();
});
// Logout Handler
function handleLogout() {
if (confirm('Möchten Sie sich wirklich abmelden?')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("auth.logout") }}';
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'csrf_token';
input.value = csrfToken.getAttribute('content');
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
}
// Flash-Message close buttons
document.querySelectorAll('.close-flash-btn').forEach(btn => {
btn.addEventListener('click', function() {
this.parentElement.parentElement.remove();
});
});
// Auto-hide flash messages after 5 seconds
document.querySelectorAll('[id^="flash-"]').forEach((flash, index) => {
setTimeout(() => {
flash?.remove();
}, 5000 + (index * 500)); // Staggered removal
});
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {

View File

@@ -269,13 +269,13 @@
</div>
</div>
<div class="flex flex-wrap gap-3">
<button id="refreshDashboard"
class="btn-secondary flex items-center gap-2">
<a href="{{ url_for('dashboard') }}"
class="btn-secondary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Aktualisieren</span>
</button>
</a>
<a href="{{ url_for('jobs_page') }}"
class="btn-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -401,7 +401,7 @@
<div class="text-xs text-right mt-1 text-slate-500 dark:text-slate-400">{{ job.progress }}%</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<a href="{{ url_for('api_get_job', job_id=job.id) }}" class="text-slate-900 dark:text-white hover:text-slate-700 dark:hover:text-slate-300 font-medium">Details</a>
<a href="{{ url_for('jobs_page') }}#job-{{ job.id }}" class="text-slate-900 dark:text-white hover:text-slate-700 dark:hover:text-slate-300 font-medium">Details</a>
</td>
</tr>
{% endfor %}
@@ -499,454 +499,51 @@
{% block extra_js %}
<script>
class DashboardManager {
constructor() {
this.updateInterval = 30000; // 30 Sekunden
this.autoUpdateTimer = null;
this.isUpdating = false;
this.wsConnection = null;
this.init();
}
init() {
this.setupEventListeners();
this.setupAutoUpdate();
this.setupWebSocket();
this.animateCounters();
console.log('🚀 Dashboard Manager initialisiert');
}
setupEventListeners() {
// Refresh Button
const refreshBtn = document.getElementById('refreshDashboard');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.refreshDashboard();
});
}
// Job-Zeilen klickbar machen für Details
this.setupJobRowClicks();
// Drucker-Karten klickbar machen
this.setupPrinterClicks();
// Dark Mode Updates
window.addEventListener('darkModeChanged', (e) => {
this.updateThemeElements(e.detail.isDark);
});
// Visibility Change Detection für intelligente Updates
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.pauseAutoUpdate();
} else {
this.resumeAutoUpdate();
this.refreshDashboard(); // Einmalige Aktualisierung bei Rückkehr
}
});
}
setupJobRowClicks() {
const jobRows = document.querySelectorAll('tbody tr[data-job-id]');
jobRows.forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', () => {
const jobId = row.dataset.jobId;
if (jobId) {
window.location.href = `/jobs/${jobId}`;
}
});
// Hover-Effekt verstärken
row.addEventListener('mouseenter', () => {
row.style.transform = 'scale(1.01)';
row.style.transition = 'transform 0.2s ease';
});
row.addEventListener('mouseleave', () => {
row.style.transform = 'scale(1)';
});
});
}
setupPrinterClicks() {
const printerCards = document.querySelectorAll('[data-printer-id]');
printerCards.forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
const printerId = card.dataset.printerId;
if (printerId) {
window.location.href = `/printers/${printerId}`;
}
});
});
}
setupAutoUpdate() {
this.autoUpdateTimer = setInterval(() => {
if (!document.hidden && !this.isUpdating) {
this.updateDashboardData();
}
}, this.updateInterval);
}
pauseAutoUpdate() {
if (this.autoUpdateTimer) {
clearInterval(this.autoUpdateTimer);
this.autoUpdateTimer = null;
}
}
resumeAutoUpdate() {
if (!this.autoUpdateTimer) {
this.setupAutoUpdate();
}
}
setupWebSocket() {
// WebSocket für Real-time Updates
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
this.wsConnection = new WebSocket(wsUrl);
this.wsConnection.onopen = () => {
console.log('📡 WebSocket Verbindung zu Dashboard hergestellt');
};
this.wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleWebSocketUpdate(data);
} catch (error) {
console.error('Fehler beim Verarbeiten der WebSocket-Nachricht:', error);
}
};
this.wsConnection.onclose = () => {
console.log('📡 WebSocket Verbindung geschlossen - versuche Wiederverbindung in 5s');
setTimeout(() => {
this.setupWebSocket();
}, 5000);
};
this.wsConnection.onerror = (error) => {
console.error('WebSocket Fehler:', error);
};
} catch (error) {
console.log('WebSocket nicht verfügbar, verwende Polling-Updates');
}
}
handleWebSocketUpdate(data) {
console.log('📡 Erhaltenes WebSocket-Update:', data);
switch (data.type) {
case 'job_status_update':
this.updateJobStatus(data.job_id, data.status, data.progress);
break;
case 'printer_status_update':
this.updatePrinterStatus(data.printer_id, data.status);
break;
case 'new_activity':
this.addNewActivity(data.activity);
break;
case 'stats_update':
this.updateStats(data.stats);
break;
default:
console.log('Unbekannter WebSocket-Update-Typ:', data.type);
}
}
async refreshDashboard() {
if (this.isUpdating) return;
this.isUpdating = true;
const refreshBtn = document.getElementById('refreshDashboard');
try {
// Button-Status aktualisieren
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.innerHTML = `
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Aktualisiert...</span>
`;
}
// Vollständige Seitenaktualisierung
window.location.reload();
} catch (error) {
console.error('Fehler beim Aktualisieren:', error);
this.showToast('Fehler beim Aktualisieren des Dashboards', 'error');
} finally {
this.isUpdating = false;
// Button zurücksetzen
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.innerHTML = `
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Aktualisieren</span>
`;
}
}
}
async updateDashboardData() {
if (this.isUpdating) return;
this.isUpdating = true;
try {
// Stille Hintergrund-Updates ohne vollständige Neuladen
const responses = await Promise.all([
fetch('/api/dashboard/stats'),
fetch('/api/dashboard/active-jobs'),
fetch('/api/dashboard/printers'),
fetch('/api/dashboard/activities')
]);
const [statsData, jobsData, printersData, activitiesData] = await Promise.all(
responses.map(r => r.json())
);
// UI-Updates
this.updateStats(statsData);
this.updateActiveJobs(jobsData);
this.updatePrinters(printersData);
this.updateActivities(activitiesData);
console.log('🔄 Dashboard-Daten erfolgreich aktualisiert');
} catch (error) {
console.error('Fehler beim Laden der Dashboard-Daten:', error);
// Fehlschlag stumm - verwende WebSocket oder warte auf nächste Aktualisierung
} finally {
this.isUpdating = false;
}
}
updateJobStatus(jobId, status, progress) {
const jobRow = document.querySelector(`tr[data-job-id="${jobId}"]`);
if (jobRow) {
// Status-Indikator aktualisieren
const statusIndicator = jobRow.querySelector('.mb-status-indicator');
const statusText = jobRow.querySelector('.text-sm.font-medium');
const progressBar = jobRow.querySelector('.mb-progress-bar');
const progressText = jobRow.querySelector('.text-xs.text-right');
if (statusIndicator) {
statusIndicator.className = `mb-status-indicator ${this.getStatusClass(status)}`;
}
if (statusText) {
statusText.textContent = this.getStatusText(status);
}
if (progressBar && progress !== undefined) {
progressBar.style.width = `${progress}%`;
}
if (progressText && progress !== undefined) {
progressText.textContent = `${progress}%`;
}
// Animation für Updates
jobRow.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
setTimeout(() => {
jobRow.style.backgroundColor = '';
}, 1000);
}
}
updatePrinterStatus(printerId, status) {
const printerCard = document.querySelector(`[data-printer-id="${printerId}"]`);
if (printerCard) {
const statusIndicator = printerCard.querySelector('.mb-status-indicator');
const statusText = printerCard.querySelector('.text-sm.font-medium:last-child');
if (statusIndicator) {
statusIndicator.className = `mb-status-indicator ${this.getStatusClass(status)}`;
}
if (statusText) {
statusText.textContent = this.getStatusText(status);
}
// Animation für Updates
printerCard.style.transform = 'scale(1.02)';
setTimeout(() => {
printerCard.style.transform = 'scale(1)';
}, 300);
}
}
addNewActivity(activity) {
const activitiesContainer = document.querySelector('.space-y-3');
if (activitiesContainer) {
const newActivity = document.createElement('div');
newActivity.className = 'mb-activity-item pl-4 py-3 opacity-0';
newActivity.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm text-slate-700 dark:text-slate-300">${activity.description}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">${activity.time}</p>
</div>
</div>
`;
// Am Anfang einfügen
activitiesContainer.insertBefore(newActivity, activitiesContainer.firstChild);
// Animation
setTimeout(() => {
newActivity.style.opacity = '1';
newActivity.style.transition = 'opacity 0.5s ease';
}, 100);
// Alte Aktivitäten entfernen (max 10)
const activities = activitiesContainer.querySelectorAll('.mb-activity-item');
if (activities.length > 10) {
activities[activities.length - 1].remove();
}
}
}
updateStats(stats) {
// Statistik-Karten aktualisieren mit Animation
const statValues = document.querySelectorAll('.stat-value');
const statsMapping = [
{ element: statValues[0], value: stats.active_jobs_count },
{ element: statValues[1], value: stats.available_printers_count },
{ element: statValues[2], value: stats.total_jobs_count },
{ element: statValues[3], value: `${stats.success_rate}%` }
];
statsMapping.forEach(({ element, value }) => {
if (element && element.textContent !== value.toString()) {
this.animateValueChange(element, value);
}
});
}
animateValueChange(element, newValue) {
element.style.transform = 'scale(1.1)';
element.style.transition = 'transform 0.3s ease';
setTimeout(() => {
element.textContent = newValue;
element.style.transform = 'scale(1)';
}, 150);
}
animateCounters() {
// Initialanimation für Counter
const counters = document.querySelectorAll('.stat-value');
counters.forEach((counter, index) => {
const finalValue = parseInt(counter.textContent) || 0;
if (finalValue > 0) {
let currentValue = 0;
const increment = finalValue / 30; // 30 Schritte
const timer = setInterval(() => {
currentValue += increment;
if (currentValue >= finalValue) {
counter.textContent = finalValue + (counter.textContent.includes('%') ? '%' : '');
clearInterval(timer);
} else {
counter.textContent = Math.floor(currentValue) + (counter.textContent.includes('%') ? '%' : '');
}
}, 50 + (index * 100)); // Verzögerung zwischen Countern
}
});
}
getStatusClass(status) {
const statusClasses = {
'running': 'mb-status-busy',
'completed': 'mb-status-online',
'failed': 'mb-status-offline',
'paused': 'mb-status-idle',
'queued': 'mb-status-idle',
'online': 'mb-status-online',
'offline': 'mb-status-offline',
'busy': 'mb-status-busy',
'idle': 'mb-status-idle'
};
return statusClasses[status] || 'mb-status-idle';
}
getStatusText(status) {
const statusTexts = {
'running': 'Druckt',
'completed': 'Abgeschlossen',
'failed': 'Fehlgeschlagen',
'paused': 'Pausiert',
'queued': 'Warteschlange',
'online': 'Online',
'offline': 'Offline',
'busy': 'Beschäftigt',
'idle': 'Bereit'
};
return statusTexts[status] || 'Unbekannt';
}
updateThemeElements(isDark) {
// Theme-spezifische Updates
console.log(`🎨 Dashboard Theme aktualisiert: ${isDark ? 'Dark' : 'Light'} Mode`);
// Spezielle Animationen für Theme-Wechsel
const cards = document.querySelectorAll('.dashboard-card');
cards.forEach(card => {
card.style.transition = 'all 0.3s ease';
});
}
showToast(message, type = 'info') {
// Toast-Benachrichtigung anzeigen
if (window.MYP && window.MYP.UI && window.MYP.UI.ToastManager) {
const toast = new window.MYP.UI.ToastManager();
toast.show(message, type, 5000);
} else {
console.log(`Toast: ${message}`);
}
}
// Cleanup beim Verlassen der Seite
cleanup() {
if (this.autoUpdateTimer) {
clearInterval(this.autoUpdateTimer);
}
if (this.wsConnection) {
this.wsConnection.close();
}
}
}
// Dashboard Manager initialisieren
// Vereinfachtes Dashboard mit minimaler JavaScript-Abhängigkeit
document.addEventListener('DOMContentLoaded', function() {
window.dashboardManager = new DashboardManager();
// Cleanup beim Verlassen
window.addEventListener('beforeunload', () => {
if (window.dashboardManager) {
window.dashboardManager.cleanup();
}
// Klickbare Drucker-Karten
const printerCards = document.querySelectorAll('[data-printer-id]');
printerCards.forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
const printerId = card.dataset.printerId;
if (printerId) {
window.location.href = `/printers/${printerId}`;
}
});
});
// Job-Zeilen klickbar machen
const jobRows = document.querySelectorAll('tbody tr[data-job-id]');
jobRows.forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', () => {
const jobId = row.dataset.jobId;
if (jobId) {
window.location.href = `/jobs#job-${jobId}`;
}
});
});
// Einfache Hover-Effekte
const cards = document.querySelectorAll('.dashboard-card');
cards.forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = 'translateY(0)';
});
});
console.log('✅ Vereinfachtes Dashboard geladen');
});
// Auto-Refresh alle 60 Sekunden (optional)
{% if config.get('AUTO_REFRESH_DASHBOARD', False) %}
setTimeout(() => {
window.location.reload();
}, 60000);
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,183 @@
{# Jinja-Makros für UI-Komponenten zur JavaScript-Ersetzung #}
{# Status-Indikator mit CSS-Animation #}
{% macro status_indicator(status, text="") %}
{% set status_classes = {
'online': 'mb-status-online',
'offline': 'mb-status-offline',
'busy': 'mb-status-busy',
'idle': 'mb-status-idle',
'running': 'mb-status-busy',
'completed': 'mb-status-online',
'failed': 'mb-status-offline',
'paused': 'mb-status-idle',
'queued': 'mb-status-idle'
} %}
<div class="flex items-center">
<div class="mb-status-indicator {{ status_classes.get(status, 'mb-status-idle') }}"></div>
{% if text %}
<span class="ml-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ text }}</span>
{% endif %}
</div>
{% endmacro %}
{# Fortschrittsbalken mit CSS-Animation #}
{% macro progress_bar(progress, show_text=True) %}
<div class="mb-progress-container">
<div class="mb-progress-bar" style="width: {{ progress }}%"></div>
</div>
{% if show_text %}
<div class="text-xs text-right mt-1 text-slate-500 dark:text-slate-400">{{ progress }}%</div>
{% endif %}
{% endmacro %}
{# Klickbare Karte #}
{% macro clickable_card(url, class="dashboard-card p-6") %}
<a href="{{ url }}" class="{{ class }} block hover:transform hover:-translate-y-1 transition-transform">
{{ caller() }}
</a>
{% endmacro %}
{# Tab-Navigation mit serverseitiger Logik #}
{% macro tab_navigation(tabs, active_tab) %}
<div class="border-b border-gray-200 dark:border-slate-700">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
{% for tab in tabs %}
<a href="{{ tab.url }}"
class="{% if active_tab == tab.id %}border-blue-500 text-blue-600 dark:text-blue-400{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-slate-400 dark:hover:text-slate-300{% endif %}
whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
{% if tab.icon %}
<i class="{{ tab.icon }} mr-2"></i>
{% endif %}
{{ tab.name }}
</a>
{% endfor %}
</nav>
</div>
{% endmacro %}
{# Drucker-Status-Karte ohne JavaScript #}
{% macro printer_card(printer, show_link=True) %}
{% if show_link %}
<a href="{{ url_for('printers_page') }}#printer-{{ printer.id }}" class="block">
{% endif %}
<div class="flex items-center justify-between p-4 rounded-xl bg-gray-50 dark:bg-slate-700/30 {% if show_link %}hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-colors{% endif %}">
<div class="flex items-center">
{{ status_indicator(printer.status, printer.status_text) }}
<div class="ml-3">
<div class="text-sm font-medium text-slate-900 dark:text-white">{{ printer.name }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ printer.model }}</div>
</div>
</div>
<div class="text-right">
<div class="text-sm font-medium text-slate-700 dark:text-slate-300">{{ printer.status_text }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ printer.location }}</div>
</div>
</div>
{% if show_link %}
</a>
{% endif %}
{% endmacro %}
{# Job-Zeile in Tabelle ohne JavaScript #}
{% macro job_row(job, show_link=True) %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 {% if show_link %}cursor-pointer{% endif %}">
{% if show_link %}
<td colspan="6" class="p-0">
<a href="{{ url_for('jobs_page') }}#job-{{ job.id }}" class="block px-6 py-4">
{{ job_row_content(job) }}
</a>
</td>
{% else %}
{{ job_row_content(job) }}
{% endif %}
</tr>
{% endmacro %}
{# Job-Zeilen-Inhalt (für Wiederverwendung) #}
{% macro job_row_content(job) %}
<td class="px-6 py-4 whitespace-nowrap">
{{ status_indicator(job.status, job.status_text) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-slate-900 dark:text-white">{{ job.name }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ job.file_name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-slate-700 dark:text-slate-300">{{ job.printer }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-slate-700 dark:text-slate-300">{{ job.start_time }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{{ progress_bar(job.progress) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-slate-900 dark:text-white hover:text-slate-700 dark:hover:text-slate-300 font-medium">Details</span>
</td>
{% endmacro %}
{# Benachrichtigungs-Toast mit Auto-Close via CSS #}
{% macro notification_toast(message, type="info", auto_close=True) %}
{% set type_classes = {
'success': 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300',
'error': 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300',
'warning': 'border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300',
'info': 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300'
} %}
<div class="glass rounded-lg p-4 border {{ type_classes.get(type, type_classes['info']) }} {% if auto_close %}animate-toast{% endif %}">
<div class="flex items-start">
<i class="fas {{ 'fa-check-circle' if type == 'success' else 'fa-exclamation-circle' if type == 'error' else 'fa-exclamation-triangle' if type == 'warning' else 'fa-info-circle' }} mt-0.5 mr-3"></i>
<div class="flex-1">
<p class="text-sm font-medium">{{ message }}</p>
</div>
<form method="POST" class="ml-3">
{{ csrf_token() }}
<button type="submit" name="dismiss_notification" class="hover:opacity-70">
<i class="fas fa-times text-sm"></i>
</button>
</form>
</div>
</div>
{% endmacro %}
{# Formular-Submit-Button mit Loading-State #}
{% macro submit_button(text, loading_text="Wird verarbeitet...", class="btn-primary") %}
<button type="submit" class="{{ class }} relative" id="submit-btn">
<span class="submit-text">{{ text }}</span>
<span class="loading-text hidden">{{ loading_text }}</span>
</button>
<script>
document.getElementById('submit-btn').form.addEventListener('submit', function() {
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.querySelector('.submit-text').classList.add('hidden');
btn.querySelector('.loading-text').classList.remove('hidden');
});
</script>
{% endmacro %}
{# Auto-Refresh Meta-Tag für periodische Seitenaktualisierung #}
{% macro auto_refresh(seconds) %}
<meta http-equiv="refresh" content="{{ seconds }}">
{% endmacro %}
{# CSS-only Dropdown-Menu #}
{% macro css_dropdown(button_text, items, button_class="btn-secondary") %}
<div class="relative group">
<button class="{{ button_class }}">
{{ button_text }}
<i class="fas fa-chevron-down ml-2"></i>
</button>
<div class="absolute right-0 mt-2 w-48 glass rounded-xl overflow-hidden opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
{% for item in items %}
<a href="{{ item.url }}" class="block px-4 py-2 text-sm hover:bg-white/10 dark:hover:bg-black/10">
{% if item.icon %}
<i class="{{ item.icon }} w-4 mr-3"></i>
{% endif %}
{{ item.text }}
</a>
{% endfor %}
</div>
</div>
{% endmacro %}