507 lines
18 KiB
Python
507 lines
18 KiB
Python
"""
|
|
Template Helpers für MYP Platform
|
|
Jinja2 Helper-Funktionen für UI-Komponenten
|
|
"""
|
|
|
|
from flask import current_app, url_for, request
|
|
from markupsafe import Markup
|
|
import json
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional, List
|
|
import calendar
|
|
import random
|
|
|
|
|
|
class UIHelpers:
|
|
"""UI-Helper-Klasse für Template-Funktionen"""
|
|
|
|
@staticmethod
|
|
def component_button(text: str, type: str = "primary", size: str = "md",
|
|
classes: str = "", icon: str = "", onclick: str = "",
|
|
disabled: bool = False, **attrs) -> Markup:
|
|
"""
|
|
Erstellt einen Button mit Tailwind-Klassen
|
|
|
|
Args:
|
|
text: Button-Text
|
|
type: Button-Typ (primary, secondary, danger, success)
|
|
size: Button-Größe (sm, md, lg)
|
|
classes: Zusätzliche CSS-Klassen
|
|
icon: SVG-Icon-Code
|
|
onclick: JavaScript-Code für onclick
|
|
disabled: Button deaktiviert
|
|
**attrs: Zusätzliche HTML-Attribute
|
|
"""
|
|
base_classes = ["btn"]
|
|
|
|
# Typ-spezifische Klassen
|
|
type_classes = {
|
|
"primary": "btn-primary",
|
|
"secondary": "btn-secondary",
|
|
"danger": "btn-danger",
|
|
"success": "btn-success"
|
|
}
|
|
base_classes.append(type_classes.get(type, "btn-primary"))
|
|
|
|
# Größen-spezifische Klassen
|
|
size_classes = {
|
|
"sm": "btn-sm",
|
|
"md": "",
|
|
"lg": "btn-lg"
|
|
}
|
|
if size_classes.get(size):
|
|
base_classes.append(size_classes[size])
|
|
|
|
if disabled:
|
|
base_classes.append("opacity-50 cursor-not-allowed")
|
|
|
|
# Zusätzliche Klassen hinzufügen
|
|
if classes:
|
|
base_classes.append(classes)
|
|
|
|
# HTML-Attribute aufbauen
|
|
attrs_str = ""
|
|
for key, value in attrs.items():
|
|
attrs_str += f' {key.replace("_", "-")}="{value}"'
|
|
|
|
if onclick:
|
|
attrs_str += f' onclick="{onclick}"'
|
|
|
|
if disabled:
|
|
attrs_str += ' disabled'
|
|
|
|
# Icon und Text kombinieren
|
|
content = ""
|
|
if icon:
|
|
content += f'<span class="inline-block mr-2">{icon}</span>'
|
|
content += text
|
|
|
|
html = f'''<button class="{" ".join(base_classes)}"{attrs_str}>
|
|
{content}
|
|
</button>'''
|
|
|
|
return Markup(html)
|
|
|
|
@staticmethod
|
|
def component_badge(text: str, type: str = "blue", classes: str = "") -> Markup:
|
|
"""
|
|
Erstellt ein Badge/Tag-Element
|
|
|
|
Args:
|
|
text: Badge-Text
|
|
type: Badge-Typ (blue, green, red, yellow, purple)
|
|
classes: Zusätzliche CSS-Klassen
|
|
"""
|
|
base_classes = ["badge", f"badge-{type}"]
|
|
|
|
if classes:
|
|
base_classes.append(classes)
|
|
|
|
html = f'<span class="{" ".join(base_classes)}">{text}</span>'
|
|
return Markup(html)
|
|
|
|
@staticmethod
|
|
def component_status_badge(status: str, type: str = "job") -> Markup:
|
|
"""
|
|
Erstellt ein Status-Badge für Jobs oder Drucker
|
|
|
|
Args:
|
|
status: Status-Wert
|
|
type: Typ (job, printer)
|
|
"""
|
|
if type == "job":
|
|
class_name = f"job-status job-{status}"
|
|
else:
|
|
class_name = f"printer-status printer-{status}"
|
|
|
|
# Status-Text übersetzen
|
|
translations = {
|
|
"job": {
|
|
"queued": "In Warteschlange",
|
|
"printing": "Wird gedruckt",
|
|
"completed": "Abgeschlossen",
|
|
"failed": "Fehlgeschlagen",
|
|
"cancelled": "Abgebrochen",
|
|
"paused": "Pausiert"
|
|
},
|
|
"printer": {
|
|
"ready": "Bereit",
|
|
"busy": "Beschäftigt",
|
|
"error": "Fehler",
|
|
"offline": "Offline",
|
|
"maintenance": "Wartung"
|
|
}
|
|
}
|
|
|
|
display_text = translations.get(type, {}).get(status, status)
|
|
|
|
html = f'<span class="{class_name}">{display_text}</span>'
|
|
return Markup(html)
|
|
|
|
@staticmethod
|
|
def component_card(title: str = "", content: str = "", footer: str = "",
|
|
classes: str = "", hover: bool = False) -> Markup:
|
|
"""
|
|
Erstellt eine Karte
|
|
|
|
Args:
|
|
title: Karten-Titel
|
|
content: Karten-Inhalt
|
|
footer: Karten-Footer
|
|
classes: Zusätzliche CSS-Klassen
|
|
hover: Hover-Effekt aktivieren
|
|
"""
|
|
base_classes = ["card"]
|
|
|
|
if hover:
|
|
base_classes.append("card-hover")
|
|
|
|
if classes:
|
|
base_classes.append(classes)
|
|
|
|
html_parts = [f'<div class="{" ".join(base_classes)}">']
|
|
|
|
if title:
|
|
html_parts.append(f'<h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">{title}</h3>')
|
|
|
|
if content:
|
|
html_parts.append(f'<div class="text-slate-600 dark:text-slate-300">{content}</div>')
|
|
|
|
if footer:
|
|
html_parts.append(f'<div class="mt-4 pt-4 border-t border-light-border dark:border-dark-border">{footer}</div>')
|
|
|
|
html_parts.append('</div>')
|
|
|
|
return Markup("".join(html_parts))
|
|
|
|
@staticmethod
|
|
def component_alert(message: str, type: str = "info", dismissible: bool = False) -> Markup:
|
|
"""
|
|
Erstellt eine Alert-Benachrichtigung
|
|
|
|
Args:
|
|
message: Alert-Nachricht
|
|
type: Alert-Typ (info, success, warning, error)
|
|
dismissible: Schließbar machen
|
|
"""
|
|
base_classes = ["alert", f"alert-{type}"]
|
|
|
|
html_parts = [f'<div class="{" ".join(base_classes)}">']
|
|
|
|
if dismissible:
|
|
html_parts.append('''
|
|
<div class="flex justify-between">
|
|
<div>
|
|
''')
|
|
|
|
html_parts.append(f'<p>{message}</p>')
|
|
|
|
if dismissible:
|
|
html_parts.append('''
|
|
</div>
|
|
<button onclick="this.parentElement.parentElement.remove()"
|
|
class="text-current opacity-70 hover:opacity-100">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
''')
|
|
|
|
html_parts.append('</div>')
|
|
|
|
return Markup("".join(html_parts))
|
|
|
|
@staticmethod
|
|
def component_modal(modal_id: str, title: str, content: str,
|
|
footer: str = "", size: str = "md") -> Markup:
|
|
"""
|
|
Erstellt ein Modal-Dialog
|
|
|
|
Args:
|
|
modal_id: Eindeutige Modal-ID
|
|
title: Modal-Titel
|
|
content: Modal-Inhalt
|
|
footer: Modal-Footer
|
|
size: Modal-Größe (sm, md, lg, xl)
|
|
"""
|
|
size_classes = {
|
|
"sm": "max-w-md",
|
|
"md": "max-w-lg",
|
|
"lg": "max-w-2xl",
|
|
"xl": "max-w-4xl"
|
|
}
|
|
|
|
max_width = size_classes.get(size, "max-w-lg")
|
|
|
|
html = f'''
|
|
<div id="{modal_id}" class="fixed inset-0 z-50 hidden">
|
|
<div class="flex items-center justify-center min-h-screen px-4">
|
|
<div class="modal-content bg-white dark:bg-slate-800 rounded-lg shadow-xl transform scale-95 opacity-0 transition-all duration-150 w-full {max_width}">
|
|
<div class="px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">{title}</h3>
|
|
<button data-modal-close="{modal_id}" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="px-6 py-4">
|
|
{content}
|
|
</div>
|
|
{f'<div class="px-6 py-4 border-t border-slate-200 dark:border-slate-700">{footer}</div>' if footer else ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
'''
|
|
|
|
return Markup(html)
|
|
|
|
@staticmethod
|
|
def component_table(headers: List[str], rows: List[List[str]],
|
|
classes: str = "", striped: bool = True) -> Markup:
|
|
"""
|
|
Erstellt eine styled Tabelle
|
|
|
|
Args:
|
|
headers: Tabellen-Kopfzeilen
|
|
rows: Tabellen-Zeilen
|
|
classes: Zusätzliche CSS-Klassen
|
|
striped: Zebra-Streifen aktivieren
|
|
"""
|
|
html_parts = ['<div class="table-container">']
|
|
|
|
table_classes = ["table-styled"]
|
|
if classes:
|
|
table_classes.append(classes)
|
|
|
|
html_parts.append(f'<table class="{" ".join(table_classes)}">')
|
|
|
|
# Kopfzeilen
|
|
html_parts.append('<thead><tr>')
|
|
for header in headers:
|
|
html_parts.append(f'<th>{header}</th>')
|
|
html_parts.append('</tr></thead>')
|
|
|
|
# Zeilen
|
|
html_parts.append('<tbody>')
|
|
for i, row in enumerate(rows):
|
|
row_classes = ""
|
|
if striped and i % 2 == 1:
|
|
row_classes = 'class="bg-slate-50 dark:bg-slate-800/50"'
|
|
html_parts.append(f'<tr {row_classes}>')
|
|
for cell in row:
|
|
html_parts.append(f'<td>{cell}</td>')
|
|
html_parts.append('</tr>')
|
|
html_parts.append('</tbody>')
|
|
|
|
html_parts.append('</table></div>')
|
|
|
|
return Markup("".join(html_parts))
|
|
|
|
@staticmethod
|
|
def format_datetime_german(dt: datetime, format_str: str = "%d.%m.%Y %H:%M") -> str:
|
|
"""
|
|
Formatiert Datetime für deutsche Anzeige
|
|
|
|
Args:
|
|
dt: Datetime-Objekt
|
|
format_str: Format-String
|
|
"""
|
|
if not dt:
|
|
return ""
|
|
return dt.strftime(format_str)
|
|
|
|
@staticmethod
|
|
def format_duration(minutes: int) -> str:
|
|
"""
|
|
Formatiert Dauer in Minuten zu lesbarem Format
|
|
|
|
Args:
|
|
minutes: Dauer in Minuten
|
|
"""
|
|
if not minutes:
|
|
return "0 Min"
|
|
|
|
if minutes < 60:
|
|
return f"{minutes} Min"
|
|
|
|
hours = minutes // 60
|
|
remaining_minutes = minutes % 60
|
|
|
|
if remaining_minutes == 0:
|
|
return f"{hours} Std"
|
|
|
|
return f"{hours} Std {remaining_minutes} Min"
|
|
|
|
@staticmethod
|
|
def json_encode(data: Any) -> str:
|
|
"""
|
|
Enkodiert Python-Daten als JSON für JavaScript
|
|
|
|
Args:
|
|
data: Zu enkodierendes Objekt
|
|
"""
|
|
return json.dumps(data, default=str, ensure_ascii=False)
|
|
|
|
|
|
def register_template_helpers(app):
|
|
"""
|
|
Registriert alle Template-Helper bei der Flask-App
|
|
|
|
Args:
|
|
app: Flask-App-Instanz
|
|
"""
|
|
# Funktionen registrieren
|
|
app.jinja_env.globals['ui_button'] = UIHelpers.component_button
|
|
app.jinja_env.globals['ui_badge'] = UIHelpers.component_badge
|
|
app.jinja_env.globals['ui_status_badge'] = UIHelpers.component_status_badge
|
|
app.jinja_env.globals['ui_card'] = UIHelpers.component_card
|
|
app.jinja_env.globals['ui_alert'] = UIHelpers.component_alert
|
|
app.jinja_env.globals['ui_modal'] = UIHelpers.component_modal
|
|
app.jinja_env.globals['ui_table'] = UIHelpers.component_table
|
|
|
|
# Filter registrieren
|
|
app.jinja_env.filters['german_datetime'] = UIHelpers.format_datetime_german
|
|
app.jinja_env.filters['duration'] = UIHelpers.format_duration
|
|
app.jinja_env.filters['json'] = UIHelpers.json_encode
|
|
|
|
# Zusätzliche globale Variablen
|
|
app.jinja_env.globals['current_year'] = datetime.now().year
|
|
|
|
# Icons als globale Variablen
|
|
icons = {
|
|
'check': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>',
|
|
'x': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>',
|
|
'plus': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>',
|
|
'edit': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>',
|
|
'trash': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>',
|
|
'printer': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path></svg>',
|
|
'dashboard': '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path></svg>',
|
|
}
|
|
|
|
app.jinja_env.globals['icons'] = icons
|
|
|
|
@app.context_processor
|
|
def utility_processor():
|
|
"""Fügt nützliche Hilfsfunktionen zu Jinja hinzu."""
|
|
return dict(
|
|
active_page=active_page,
|
|
format_datetime=format_datetime,
|
|
format_date=format_date,
|
|
format_time=format_time,
|
|
random_avatar_color=random_avatar_color,
|
|
get_initials=get_initials,
|
|
render_progress_bar=render_progress_bar
|
|
)
|
|
|
|
def active_page(path):
|
|
"""
|
|
Überprüft, ob der aktuelle Pfad mit dem gegebenen Pfad übereinstimmt.
|
|
"""
|
|
if request.path == path:
|
|
return 'active'
|
|
return ''
|
|
|
|
def format_datetime(value, format='%d.%m.%Y %H:%M'):
|
|
"""
|
|
Formatiert ein Datum mit Uhrzeit nach deutschem Format.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
try:
|
|
value = datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return value
|
|
return value.strftime(format)
|
|
|
|
def format_date(value, format='%d.%m.%Y'):
|
|
"""
|
|
Formatiert ein Datum nach deutschem Format.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
try:
|
|
value = datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return value
|
|
return value.strftime(format)
|
|
|
|
def format_time(value, format='%H:%M'):
|
|
"""
|
|
Formatiert eine Uhrzeit nach deutschem Format.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
try:
|
|
value = datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return value
|
|
return value.strftime(format)
|
|
|
|
def random_avatar_color():
|
|
"""
|
|
Gibt eine zufällige Hintergrundfarbe für Avatare zurück.
|
|
"""
|
|
colors = [
|
|
'bg-blue-100 text-blue-800',
|
|
'bg-green-100 text-green-800',
|
|
'bg-yellow-100 text-yellow-800',
|
|
'bg-red-100 text-red-800',
|
|
'bg-indigo-100 text-indigo-800',
|
|
'bg-purple-100 text-purple-800',
|
|
'bg-pink-100 text-pink-800',
|
|
'bg-gray-100 text-gray-800',
|
|
]
|
|
return random.choice(colors)
|
|
|
|
def get_initials(name, max_length=2):
|
|
"""
|
|
Extrahiert die Initialen eines Namens.
|
|
"""
|
|
if not name:
|
|
return "?"
|
|
|
|
parts = name.split()
|
|
if len(parts) == 1:
|
|
return name[0:max_length].upper()
|
|
|
|
initials = ""
|
|
for part in parts:
|
|
if part and len(initials) < max_length:
|
|
initials += part[0].upper()
|
|
|
|
return initials
|
|
|
|
def render_progress_bar(value, color='blue'):
|
|
"""
|
|
Rendert einen Fortschrittsbalken ohne Inline-Styles.
|
|
|
|
Args:
|
|
value (int): Der Prozentwert für den Fortschrittsbalken (0-100)
|
|
color (str): Die Farbe des Balkens (blue, green, purple, red)
|
|
|
|
Returns:
|
|
str: HTML-Markup für den Fortschrittsbalken
|
|
"""
|
|
css_class = f"progress-bar-fill-{color}"
|
|
|
|
# Sicherstellen, dass der Wert im gültigen Bereich liegt
|
|
if value < 0:
|
|
value = 0
|
|
elif value > 100:
|
|
value = 100
|
|
|
|
# Erstellen des DOM-Struktur für den Fortschrittsbalken
|
|
html = f"""
|
|
<div class="progress-bar">
|
|
<div class="progress-bar-fill {css_class}" data-width="{value}"></div>
|
|
</div>
|
|
"""
|
|
|
|
return Markup(html) |