Add user authentication and registration features
- Implement login and registration HTML templates for user authentication. - Create user management functions in user.py for handling user data, including adding, deleting, and updating users. - Introduce password hashing and strength checking for secure user credentials. - Add terms of service and privacy policy pages to the website. - Enhance main page with dynamic content based on user session status.
This commit is contained in:
@@ -22,7 +22,7 @@ import json
|
||||
|
||||
root = Path.cwd()
|
||||
settings_path = root / "settings.json"
|
||||
version_file = root / "Code" / "version.txt"
|
||||
version_file = root / "version.txt"
|
||||
|
||||
version = "0.1.0"
|
||||
if settings_path.is_file():
|
||||
@@ -40,10 +40,10 @@ if settings_path.is_file():
|
||||
version_file.write_text(f"{version}\n", encoding="utf-8")
|
||||
print(f"Prepared {version_file} with version: {version}")
|
||||
PY
|
||||
python -m nuitka --standalone --follow-imports --include-data-dir=Code/templates=templates --include-data-dir=Code/static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=Inventarsystem_Lizenz_Verwaltung --remove-output Code/main.py
|
||||
if [[ -x "./build/main.dist/main.bin" ]]; then
|
||||
./build/main.dist/main.bin
|
||||
python -m nuitka --standalone --follow-imports --include-data-dir=templates=templates --include-data-dir=static=static --include-data-file=licenses.json=licenses.json --assume-yes-for-downloads --output-dir=Inventarsystem_Lizenz_Verwaltung --remove-output main.py
|
||||
if [[ -x "./Inventarsystem_Lizenz_Verwaltung/main.bin" ]]; then
|
||||
./Inventarsystem_Lizenz_Verwaltung/main.bin
|
||||
else
|
||||
echo "Build step finished but executable not found at ./build/main.dist/main.bin"
|
||||
echo "Build step finished but executable not found at ./Inventarsystem_Lizenz_Verwaltung/main.bin"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"id": "a-1774281965267",
|
||||
"username": "Aiirondev",
|
||||
"display_name": "Maximilian Gr\u00fcndinger",
|
||||
"date": "2026-03-24",
|
||||
"time": "10:00",
|
||||
"subject": "Test",
|
||||
"note": "",
|
||||
"status": "Abgelehnt",
|
||||
"created_at": "2026-03-23T16:06:05Z",
|
||||
"response": "Es geht leider nicht um die von ihnen angefragte Uhrzeit, bitte versuchen sie es zwei Tage sp\u00e4ter",
|
||||
"responded_at": "2026-03-23T16:06:52Z",
|
||||
"responded_by": "Aiirondev"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"username": "Aiirondev",
|
||||
"display_name": "Maximilian Gr\u00fcndinger",
|
||||
"password_hash": "scrypt:32768:8:1$nfN2TTigH6GNrZig$548ac55b59b2c10f2730a4a45b4140c84eba33e2719f95175c05ab1f3a157959ff7a5d11c3d38bd2ea172b0143fa3a0e0e410b8bfcc81b8d91cfbbfc12b30399",
|
||||
"is_admin": true,
|
||||
"created_at": "2026-03-23T16:05:42Z"
|
||||
}
|
||||
]
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
from flask_jwt_extended import JWTManager, create_access_token, jwt_required
|
||||
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session
|
||||
import os
|
||||
import json
|
||||
import calendar
|
||||
from datetime import timedelta, datetime, date
|
||||
from pathlib import Path
|
||||
from functools import wraps
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
import bleach
|
||||
from markupsafe import escape
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4s"
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get("JWT_SECRET_KEY", app.secret_key)
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=12)
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Strict"
|
||||
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
|
||||
app.config["PREFERRED_URL_SCHEME"] = "https" if os.environ.get("SESSION_COOKIE_SECURE") == "1" else "http"
|
||||
jwt = JWTManager(app)
|
||||
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com https://fonts.googleapis.com; img-src 'self' data:; connect-src 'self';"
|
||||
return response
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
USERS_FILE = DATA_DIR / "users.json"
|
||||
APPOINTMENTS_FILE = DATA_DIR / "appointments.json"
|
||||
POSTS_FILE = DATA_DIR / "posts.json"
|
||||
|
||||
|
||||
def _issue_access_token() -> str:
|
||||
return create_access_token(identity="license-validation-client")
|
||||
|
||||
|
||||
def _ensure_data_files() -> None:
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if not USERS_FILE.exists():
|
||||
USERS_FILE.write_text("[]", encoding="utf-8")
|
||||
if not APPOINTMENTS_FILE.exists():
|
||||
APPOINTMENTS_FILE.write_text("[]", encoding="utf-8")
|
||||
if not POSTS_FILE.exists():
|
||||
POSTS_FILE.write_text("[]", encoding="utf-8")
|
||||
|
||||
|
||||
def _read_json(file_path: Path) -> list:
|
||||
_ensure_data_files()
|
||||
try:
|
||||
return json.loads(file_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _write_json(file_path: Path, payload: list) -> None:
|
||||
_ensure_data_files()
|
||||
file_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8")
|
||||
|
||||
|
||||
def _sanitize_text(text: str, max_length: int = 255) -> str:
|
||||
"""Sanitize user text input: strip, limit length, and escape HTML."""
|
||||
text = (text or "").strip()
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length]
|
||||
return text
|
||||
|
||||
|
||||
def _sanitize_html(html_content: str, max_length: int = 50000) -> str:
|
||||
"""Sanitize HTML content: allow safe tags only."""
|
||||
if not html_content:
|
||||
return ""
|
||||
html_content = html_content[:max_length]
|
||||
allowed_tags = ["p", "br", "strong", "em", "u", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "a", "blockquote", "code", "pre"]
|
||||
allowed_attrs = {"a": ["href", "title"]}
|
||||
return bleach.clean(html_content, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
||||
|
||||
|
||||
def _validate_username(username: str) -> bool:
|
||||
"""Validate username format: alphanumeric, underscore, dash, 3-30 chars."""
|
||||
if not username or not isinstance(username, str):
|
||||
return False
|
||||
username = username.strip()
|
||||
if len(username) < 3 or len(username) > 30:
|
||||
return False
|
||||
return all(c.isalnum() or c in "_-" for c in username)
|
||||
|
||||
|
||||
def _find_user(username: str):
|
||||
username_key = (username or "").strip().lower()
|
||||
if not username_key:
|
||||
return None
|
||||
users = _read_json(USERS_FILE)
|
||||
for entry in users:
|
||||
if entry.get("username", "").lower() == username_key:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def login_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "username" not in session:
|
||||
flash("Bitte zuerst einloggen.", "error")
|
||||
return redirect(url_for("login"))
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def admin_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "username" not in session:
|
||||
flash("Bitte zuerst einloggen.", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
current_user = _find_user(session.get("username"))
|
||||
if not current_user or not current_user.get("is_admin", False):
|
||||
flash("Zugriff verweigert. Admin-Rechte erforderlich.", "error")
|
||||
return redirect(url_for("default"))
|
||||
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
"""-----------------------------------------------------------------------------------------------------------------"""
|
||||
|
||||
@app.route("/", methods=["GET", "POST"])
|
||||
def default():
|
||||
return render_template("main.html")
|
||||
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if 'username' in session:
|
||||
return redirect(url_for('default'))
|
||||
|
||||
if request.method == 'POST':
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("username") or request.form.get('username') or "").strip()
|
||||
password = data.get("password") or request.form.get('password') or ""
|
||||
|
||||
if not username or not password:
|
||||
if request.is_json:
|
||||
return jsonify({"error": "Missing login data"}), 400
|
||||
flash('Bitte Benutzername und Passwort eingeben.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
stored_user = _find_user(username)
|
||||
|
||||
if stored_user and check_password_hash(stored_user.get("password_hash", ""), password):
|
||||
session['username'] = stored_user.get("username")
|
||||
session['display_name'] = stored_user.get("display_name") or stored_user.get("username")
|
||||
session['is_admin'] = stored_user.get("is_admin", False)
|
||||
session['access_token'] = _issue_access_token()
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({"access_token": session['access_token'], "token_type": "Bearer"}), 200
|
||||
|
||||
return redirect(url_for('default'))
|
||||
|
||||
if request.is_json:
|
||||
return jsonify({"error": "Invalid credentials"}), 401
|
||||
flash('Login fehlgeschlagen. Bitte pruefen Sie Ihre Eingaben.', 'error')
|
||||
get_flashed_messages()
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@app.route("/register", methods=["GET", "POST"])
|
||||
def register():
|
||||
if request.method == "POST":
|
||||
username = (request.form.get("username") or "").strip()
|
||||
display_name = (request.form.get("display_name") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
password_repeat = request.form.get("password_repeat") or ""
|
||||
|
||||
if not username or not display_name or not password or not password_repeat:
|
||||
flash("Bitte alle Felder ausfuellen.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
if not _validate_username(username):
|
||||
flash("Benutzername muss 3-30 Zeichen lang sein und nur Buchstaben, Zahlen, - und _ enthalten.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
if len(password) < 8:
|
||||
flash("Das Passwort muss mindestens 8 Zeichen haben.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
if password != password_repeat:
|
||||
flash("Die Passwoerter stimmen nicht ueberein.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
if _find_user(username):
|
||||
flash("Benutzername bereits vergeben.", "error")
|
||||
return redirect(url_for("register"))
|
||||
|
||||
display_name = _sanitize_text(display_name, 100)
|
||||
|
||||
users = _read_json(USERS_FILE)
|
||||
is_admin = len(users) == 0
|
||||
users.append(
|
||||
{
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
"password_hash": generate_password_hash(password),
|
||||
"is_admin": is_admin,
|
||||
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||
}
|
||||
)
|
||||
_write_json(USERS_FILE, users)
|
||||
flash("Registrierung erfolgreich. Bitte jetzt einloggen.", "success")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return render_template("register.html")
|
||||
|
||||
|
||||
@app.route('/appointments', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def appointments():
|
||||
today = date.today()
|
||||
month = request.args.get("month", type=int) or today.month
|
||||
year = request.args.get("year", type=int) or today.year
|
||||
|
||||
month = 1 if month < 1 else 12 if month > 12 else month
|
||||
year = 1970 if year < 1970 else year
|
||||
|
||||
if request.method == 'POST':
|
||||
selected_date = (request.form.get("selected_date") or "").strip()
|
||||
appointment_time = (request.form.get("appointment_time") or "").strip()
|
||||
subject = (request.form.get("subject") or "").strip()
|
||||
note = (request.form.get("note") or "").strip()
|
||||
|
||||
try:
|
||||
date.fromisoformat(selected_date)
|
||||
except ValueError:
|
||||
flash("Bitte einen gueltigen Termin im Kalender auswaehlen.", "error")
|
||||
return redirect(url_for("appointments", month=month, year=year))
|
||||
|
||||
if not appointment_time or not subject:
|
||||
flash("Bitte Uhrzeit und Betreff ausfuellen.", "error")
|
||||
return redirect(url_for("appointments", month=month, year=year))
|
||||
|
||||
subject = _sanitize_text(subject, 200)
|
||||
note = _sanitize_text(note, 2000)
|
||||
|
||||
entries = _read_json(APPOINTMENTS_FILE)
|
||||
entries.append(
|
||||
{
|
||||
"id": f"a-{int(datetime.utcnow().timestamp() * 1000)}",
|
||||
"username": session.get("username"),
|
||||
"display_name": session.get("display_name"),
|
||||
"date": selected_date,
|
||||
"time": appointment_time,
|
||||
"subject": subject,
|
||||
"note": note,
|
||||
"status": "Angefragt",
|
||||
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||
}
|
||||
)
|
||||
_write_json(APPOINTMENTS_FILE, entries)
|
||||
|
||||
flash("Termin erfolgreich angefragt.", "success")
|
||||
selected = date.fromisoformat(selected_date)
|
||||
return redirect(url_for("appointments", month=selected.month, year=selected.year))
|
||||
|
||||
cal = calendar.Calendar(firstweekday=0)
|
||||
month_grid = cal.monthdayscalendar(year, month)
|
||||
month_name = calendar.month_name[month]
|
||||
|
||||
previous_month = month - 1
|
||||
previous_year = year
|
||||
if previous_month == 0:
|
||||
previous_month = 12
|
||||
previous_year -= 1
|
||||
|
||||
next_month = month + 1
|
||||
next_year = year
|
||||
if next_month == 13:
|
||||
next_month = 1
|
||||
next_year += 1
|
||||
|
||||
all_appointments = _read_json(APPOINTMENTS_FILE)
|
||||
user_appointments = [item for item in all_appointments if item.get("username") == session.get("username")]
|
||||
user_appointments.sort(key=lambda item: (item.get("date", ""), item.get("time", "")), reverse=False)
|
||||
|
||||
return render_template(
|
||||
"appointments.html",
|
||||
month=month,
|
||||
month_name=month_name,
|
||||
year=year,
|
||||
month_grid=month_grid,
|
||||
today_iso=today.isoformat(),
|
||||
previous_month=previous_month,
|
||||
previous_year=previous_year,
|
||||
next_month=next_month,
|
||||
next_year=next_year,
|
||||
user_appointments=user_appointments,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/admin/dashboard')
|
||||
@admin_required
|
||||
def admin_dashboard():
|
||||
all_appointments = _read_json(APPOINTMENTS_FILE)
|
||||
all_appointments.sort(key=lambda x: (x.get("date", ""), x.get("time", "")), reverse=True)
|
||||
|
||||
status_counts = {
|
||||
"Angefragt": len([a for a in all_appointments if a.get("status") == "Angefragt"]),
|
||||
"Bestaetigt": len([a for a in all_appointments if a.get("status") == "Bestaetigt"]),
|
||||
"Abgelehnt": len([a for a in all_appointments if a.get("status") == "Abgelehnt"]),
|
||||
}
|
||||
|
||||
posts = _read_json(POSTS_FILE)
|
||||
total_posts = len(posts)
|
||||
|
||||
return render_template(
|
||||
"admin_dashboard.html",
|
||||
appointments=all_appointments,
|
||||
status_counts=status_counts,
|
||||
total_posts=total_posts,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/admin/appointment/<appointment_id>', methods=['POST'])
|
||||
@admin_required
|
||||
def update_appointment(appointment_id):
|
||||
action = request.form.get("action", "").strip()
|
||||
response_text = _sanitize_text(request.form.get("response") or "", 5000)
|
||||
|
||||
if action not in ["confirm", "reject"]:
|
||||
flash("Ungueltige Aktion.", "error")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
all_appointments = _read_json(APPOINTMENTS_FILE)
|
||||
appointment = None
|
||||
for item in all_appointments:
|
||||
if item.get("id") == appointment_id:
|
||||
appointment = item
|
||||
break
|
||||
|
||||
if not appointment:
|
||||
flash("Termin nicht gefunden.", "error")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
if action == "confirm":
|
||||
appointment["status"] = "Bestaetigt"
|
||||
else:
|
||||
appointment["status"] = "Abgelehnt"
|
||||
|
||||
appointment["response"] = response_text
|
||||
appointment["responded_at"] = datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
||||
appointment["responded_by"] = session.get("username")
|
||||
|
||||
_write_json(APPOINTMENTS_FILE, all_appointments)
|
||||
flash(f"Termin wurde {('bestaetigt' if action == 'confirm' else 'abgelehnt')}.", "success")
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route('/admin/blog', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def admin_blog():
|
||||
if request.method == 'POST':
|
||||
action = request.form.get("action", "").strip()
|
||||
|
||||
if action == "create":
|
||||
title = _sanitize_text(request.form.get("title") or "", 200)
|
||||
content = _sanitize_html(request.form.get("content") or "", 50000)
|
||||
excerpt = _sanitize_text(request.form.get("excerpt") or "", 500)
|
||||
|
||||
if not title or not content:
|
||||
flash("Bitte Titel und inhalt ausfuellen.", "error")
|
||||
return redirect(url_for("admin_blog"))
|
||||
|
||||
posts = _read_json(POSTS_FILE)
|
||||
posts.append({
|
||||
"id": f"p-{int(datetime.utcnow().timestamp() * 1000)}",
|
||||
"title": title,
|
||||
"excerpt": excerpt or (content[:150] + "...") if len(content) > 150 else content,
|
||||
"content": content,
|
||||
"author": session.get("username"),
|
||||
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||
"published": True,
|
||||
})
|
||||
_write_json(POSTS_FILE, posts)
|
||||
flash("Beitrag veroeffentlicht.", "success")
|
||||
return redirect(url_for("admin_blog"))
|
||||
|
||||
elif action == "delete":
|
||||
post_id = (request.form.get("post_id") or "").strip()
|
||||
if not post_id or not post_id.startswith("p-"):
|
||||
flash("Ungueltige Beitrag-ID.", "error")
|
||||
return redirect(url_for("admin_blog"))
|
||||
posts = _read_json(POSTS_FILE)
|
||||
original_count = len(posts)
|
||||
posts = [p for p in posts if p.get("id") != post_id]
|
||||
if len(posts) == original_count:
|
||||
flash("Beitrag nicht gefunden.", "error")
|
||||
return redirect(url_for("admin_blog"))
|
||||
_write_json(POSTS_FILE, posts)
|
||||
flash("Beitrag geloescht.", "success")
|
||||
return redirect(url_for("admin_blog"))
|
||||
|
||||
posts = _read_json(POSTS_FILE)
|
||||
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
return render_template("admin_blog.html", posts=posts)
|
||||
|
||||
|
||||
@app.route('/blog')
|
||||
def blog():
|
||||
posts = _read_json(POSTS_FILE)
|
||||
posts.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
return render_template("blog.html", posts=posts)
|
||||
|
||||
|
||||
@app.route('/blog/<post_id>')
|
||||
def blog_post(post_id):
|
||||
if not post_id or not post_id.startswith("p-"):
|
||||
flash("Ungueltige Beitrag-ID.", "error")
|
||||
return redirect(url_for("blog"))
|
||||
|
||||
posts = _read_json(POSTS_FILE)
|
||||
post = None
|
||||
for p in posts:
|
||||
if p.get("id") == post_id:
|
||||
post = p
|
||||
break
|
||||
|
||||
if not post:
|
||||
flash("Beitrag nicht gefunden.", "error")
|
||||
return redirect(url_for("blog"))
|
||||
|
||||
return render_template("blog_post.html", post=post)
|
||||
|
||||
|
||||
@app.route('/datenschutz')
|
||||
def datenschutz():
|
||||
return render_template("datenschutz.html")
|
||||
|
||||
|
||||
@app.route('/impressum')
|
||||
def impressum():
|
||||
return render_template("impressum.html")
|
||||
|
||||
|
||||
@app.route('/nutzungsbedingungen')
|
||||
def nutzungsbedingungen():
|
||||
return render_template("nutzungsbedingungen.html")
|
||||
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('username', None)
|
||||
session.pop('display_name', None)
|
||||
session.pop('is_admin', None)
|
||||
session.pop('access_token', None)
|
||||
flash('Logged out successfully', 'info')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
|
||||
def main():
|
||||
app.run(host="0.0.0.0", port=4999, debug=False)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* iOS Specific Enhancements for Inventarsystem
|
||||
*
|
||||
* This file contains fixes specific to iOS devices for the Inventarsystem application,
|
||||
* focusing on upload and duplication functionality.
|
||||
*
|
||||
* Copyright 2025 Maximilian Gründinger
|
||||
* Licensed under the Apache License, Version 2.0
|
||||
*/
|
||||
|
||||
// Initialize when document is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (isIOSDevice()) {
|
||||
console.log('iOS device detected, applying iOS-specific enhancements');
|
||||
applyIOSFixes();
|
||||
}
|
||||
});
|
||||
|
||||
// Apply all iOS-specific fixes
|
||||
function applyIOSFixes() {
|
||||
// Fix file input issues
|
||||
fixIOSFileInputs();
|
||||
|
||||
// Fix form submission
|
||||
fixIOSFormSubmission();
|
||||
|
||||
// Fix image handling
|
||||
fixIOSImageHandling();
|
||||
|
||||
// Fix duplication
|
||||
fixIOSDuplication();
|
||||
|
||||
// Apply CSS fixes
|
||||
applyIOSCSSFixes();
|
||||
}
|
||||
|
||||
// Fix iOS file input issues
|
||||
function fixIOSFileInputs() {
|
||||
// Find all file inputs
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
|
||||
fileInputs.forEach(input => {
|
||||
// Create a touch-friendly wrapper
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'ios-file-input-wrapper';
|
||||
wrapper.style.position = 'relative';
|
||||
|
||||
// Style the original input to be more touch-friendly
|
||||
input.style.padding = '20px 0';
|
||||
|
||||
// Wrap the input
|
||||
if (input.parentNode) {
|
||||
input.parentNode.insertBefore(wrapper, input);
|
||||
wrapper.appendChild(input);
|
||||
|
||||
// Add a visible button for better touch target
|
||||
const fakeButton = document.createElement('div');
|
||||
fakeButton.className = 'ios-file-button';
|
||||
fakeButton.textContent = 'Wählen Sie Bilder/Videos aus';
|
||||
fakeButton.style.display = 'inline-block';
|
||||
fakeButton.style.padding = '10px 15px';
|
||||
fakeButton.style.backgroundColor = '#4CAF50';
|
||||
fakeButton.style.color = 'white';
|
||||
fakeButton.style.borderRadius = '4px';
|
||||
fakeButton.style.textAlign = 'center';
|
||||
fakeButton.style.margin = '10px 0';
|
||||
fakeButton.style.fontWeight = 'bold';
|
||||
wrapper.appendChild(fakeButton);
|
||||
|
||||
// Make sure the real input covers the fake button
|
||||
input.style.position = 'absolute';
|
||||
input.style.top = '0';
|
||||
input.style.left = '0';
|
||||
input.style.width = '100%';
|
||||
input.style.height = '100%';
|
||||
input.style.opacity = '0';
|
||||
input.style.zIndex = '10';
|
||||
}
|
||||
|
||||
// Add custom event handling
|
||||
input.addEventListener('change', function() {
|
||||
// Update visual feedback
|
||||
const fileCount = this.files ? this.files.length : 0;
|
||||
let fileText = fakeButton.textContent;
|
||||
|
||||
if (fileCount > 0) {
|
||||
fileText = `${fileCount} Datei${fileCount !== 1 ? 'en' : ''} ausgewählt`;
|
||||
fakeButton.style.backgroundColor = '#2E7D32';
|
||||
} else {
|
||||
fileText = 'Wählen Sie Bilder/Videos aus';
|
||||
fakeButton.style.backgroundColor = '#4CAF50';
|
||||
}
|
||||
|
||||
fakeButton.textContent = fileText;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fix iOS form submission issues
|
||||
function fixIOSFormSubmission() {
|
||||
// Find upload forms
|
||||
const uploadForms = document.querySelectorAll('form[action*="upload_item"]');
|
||||
|
||||
uploadForms.forEach(form => {
|
||||
// Add an iOS submission handler
|
||||
form.addEventListener('submit', function(e) {
|
||||
// Only intercept on iOS
|
||||
if (!isIOSDevice()) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Show that we're processing
|
||||
const loadingMessage = document.createElement('div');
|
||||
loadingMessage.className = 'ios-loading-message';
|
||||
loadingMessage.textContent = 'Wird verarbeitet...';
|
||||
loadingMessage.style.padding = '10px';
|
||||
loadingMessage.style.backgroundColor = '#f0f0f0';
|
||||
loadingMessage.style.borderRadius = '5px';
|
||||
loadingMessage.style.margin = '10px 0';
|
||||
loadingMessage.style.textAlign = 'center';
|
||||
form.appendChild(loadingMessage);
|
||||
|
||||
// Disable the submit button
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
}
|
||||
|
||||
// Use timeout to allow UI to update before heavy processing
|
||||
setTimeout(() => {
|
||||
// Gather form data with special handling for iOS
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add iOS flag
|
||||
formData.append('is_ios', 'true');
|
||||
|
||||
// Submit with fetch API which works better on iOS
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Remove loading message
|
||||
if (loadingMessage.parentNode) {
|
||||
loadingMessage.parentNode.removeChild(loadingMessage);
|
||||
}
|
||||
|
||||
// Re-enable submit button
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
// Show success and redirect
|
||||
alert(data.message || 'Element erfolgreich hinzugefügt');
|
||||
|
||||
// Redirect to the appropriate page
|
||||
if (data.itemId) {
|
||||
window.location.href = `/home_admin?highlight_item=${data.itemId}`;
|
||||
} else {
|
||||
window.location.href = '/home_admin';
|
||||
}
|
||||
} else {
|
||||
// Show error
|
||||
alert(data.message || 'Ein Fehler ist aufgetreten');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('iOS form submission error:', error);
|
||||
|
||||
// Remove loading message
|
||||
if (loadingMessage.parentNode) {
|
||||
loadingMessage.parentNode.removeChild(loadingMessage);
|
||||
}
|
||||
|
||||
// Re-enable submit button
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
|
||||
// Show error
|
||||
alert('Ein Netzwerkfehler ist aufgetreten. Bitte versuchen Sie es erneut.');
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fix iOS image handling issues
|
||||
function fixIOSImageHandling() {
|
||||
// Reduce image preview quality on iOS
|
||||
if (typeof ImagePreviewGenerator !== 'undefined') {
|
||||
// Override the compression quality for iOS
|
||||
const originalCompressImage = ImagePreviewGenerator.prototype._compressImage;
|
||||
|
||||
if (originalCompressImage) {
|
||||
ImagePreviewGenerator.prototype._compressImage = function(dataUrl) {
|
||||
// Lower quality for iOS
|
||||
this.quality = 0.5;
|
||||
this.maxWidth = 600;
|
||||
this.maxHeight = 600;
|
||||
|
||||
return originalCompressImage.call(this, dataUrl);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fix image preview display
|
||||
document.querySelectorAll('.image-preview-container').forEach(container => {
|
||||
// Add iOS-specific styling
|
||||
container.style.webkitOverflowScrolling = 'touch';
|
||||
container.style.maxHeight = '150px';
|
||||
container.style.overflow = 'auto';
|
||||
});
|
||||
}
|
||||
|
||||
// Fix iOS duplication issues
|
||||
function fixIOSDuplication() {
|
||||
// Override duplicateItem function
|
||||
if (typeof duplicateItem !== 'undefined') {
|
||||
const originalDuplicateItem = duplicateItem;
|
||||
|
||||
duplicateItem = function(itemId) {
|
||||
// Use localStorage on iOS instead of sessionStorage
|
||||
const useFunction = originalDuplicateItem;
|
||||
|
||||
useFunction(itemId);
|
||||
|
||||
// Additional iOS-specific handling
|
||||
const checkForSessionData = setInterval(() => {
|
||||
const sessionData = sessionStorage.getItem('duplicateItemData');
|
||||
if (sessionData) {
|
||||
// Copy from sessionStorage to localStorage
|
||||
try {
|
||||
localStorage.setItem('duplicateItemData', sessionData);
|
||||
localStorage.setItem('duplicateItemTimestamp', Date.now().toString());
|
||||
console.log('Copied duplicate data to localStorage for iOS reliability');
|
||||
} catch (e) {
|
||||
console.warn('Failed to copy to localStorage:', e);
|
||||
}
|
||||
|
||||
clearInterval(checkForSessionData);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Clear interval after 3 seconds regardless
|
||||
setTimeout(() => clearInterval(checkForSessionData), 3000);
|
||||
};
|
||||
}
|
||||
|
||||
// Fix prefill function
|
||||
if (typeof prefillFormWithDuplicateData !== 'undefined') {
|
||||
const originalPrefill = prefillFormWithDuplicateData;
|
||||
|
||||
prefillFormWithDuplicateData = function() {
|
||||
// Try to get data from localStorage first
|
||||
let data = null;
|
||||
try {
|
||||
const localData = localStorage.getItem('duplicateItemData');
|
||||
if (localData) {
|
||||
data = JSON.parse(localData);
|
||||
localStorage.removeItem('duplicateItemData');
|
||||
localStorage.removeItem('duplicateItemTimestamp');
|
||||
console.log('Using duplicate data from localStorage');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error accessing localStorage:', e);
|
||||
}
|
||||
|
||||
// If we got data from localStorage, manually populate the form
|
||||
if (data) {
|
||||
console.log('Manually populating form with iOS optimization');
|
||||
|
||||
// Basic fields
|
||||
document.getElementById('name').value = data.name || '';
|
||||
document.getElementById('beschreibung').value = data.description || '';
|
||||
|
||||
// Handle filters with a delay
|
||||
setTimeout(() => {
|
||||
// Filter 1
|
||||
if (data.filter1 && Array.isArray(data.filter1)) {
|
||||
data.filter1.forEach((val, idx) => {
|
||||
const field = document.getElementById(`filter1-${idx+1}`);
|
||||
if (field && val) field.value = val;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter 2
|
||||
if (data.filter2 && Array.isArray(data.filter2)) {
|
||||
data.filter2.forEach((val, idx) => {
|
||||
const field = document.getElementById(`filter2-${idx+1}`);
|
||||
if (field && val) field.value = val;
|
||||
});
|
||||
}
|
||||
|
||||
// Filter 3
|
||||
if (data.filter3 && Array.isArray(data.filter3)) {
|
||||
data.filter3.forEach((val, idx) => {
|
||||
const field = document.getElementById(`filter3-${idx+1}`);
|
||||
if (field && val) field.value = val;
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
|
||||
// Location
|
||||
setTimeout(() => {
|
||||
const location = document.getElementById('ort');
|
||||
if (location && data.location) location.value = data.location;
|
||||
}, 400);
|
||||
|
||||
// Year and cost
|
||||
const year = document.getElementById('anschaffungsjahr');
|
||||
if (year && data.year) year.value = data.year;
|
||||
|
||||
const cost = document.getElementById('anschaffungskosten');
|
||||
if (cost && data.cost) cost.value = data.cost;
|
||||
|
||||
// Handle images - limited for iOS
|
||||
const form = document.querySelector('form');
|
||||
if (form && data.images && Array.isArray(data.images)) {
|
||||
// Add hidden field to indicate duplication
|
||||
const isDuplicatingField = document.createElement('input');
|
||||
isDuplicatingField.type = 'hidden';
|
||||
isDuplicatingField.name = 'is_duplicating';
|
||||
isDuplicatingField.value = 'true';
|
||||
form.appendChild(isDuplicatingField);
|
||||
|
||||
// Only use first 3 images on iOS for performance
|
||||
const limitedImages = data.images.slice(0, 3);
|
||||
|
||||
limitedImages.forEach(imageName => {
|
||||
const imageField = document.createElement('input');
|
||||
imageField.type = 'hidden';
|
||||
imageField.name = 'duplicate_images';
|
||||
imageField.value = imageName;
|
||||
form.appendChild(imageField);
|
||||
});
|
||||
|
||||
// Add a message about limited images
|
||||
if (data.images.length > 3) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.style.margin = '10px 0';
|
||||
messageDiv.style.padding = '10px';
|
||||
messageDiv.style.backgroundColor = '#fff3cd';
|
||||
messageDiv.style.borderRadius = '5px';
|
||||
messageDiv.style.color = '#856404';
|
||||
messageDiv.textContent = `Aus Leistungsgründen werden nur ${limitedImages.length} von ${data.images.length} Bildern verwendet`;
|
||||
|
||||
const fileInput = document.getElementById('images');
|
||||
if (fileInput && fileInput.parentNode) {
|
||||
fileInput.parentNode.appendChild(messageDiv);
|
||||
} else {
|
||||
form.appendChild(messageDiv);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use original function as fallback
|
||||
originalPrefill();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Apply iOS-specific CSS fixes
|
||||
function applyIOSCSSFixes() {
|
||||
// Create a style element
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* Improve touch targets for iOS */
|
||||
button, select, input[type="submit"], .ausleihen, .edit-button,
|
||||
.delete-button, .duplicate-button, .details-button {
|
||||
min-height: 44px !important;
|
||||
padding: 10px 15px !important;
|
||||
}
|
||||
|
||||
/* Improve scrolling on iOS */
|
||||
.items-container, .filter-options, .modal-content {
|
||||
-webkit-overflow-scrolling: touch !important;
|
||||
}
|
||||
|
||||
/* Prevent zoom on inputs */
|
||||
input, select, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* Fix iOS image display */
|
||||
.item-image {
|
||||
-webkit-transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Improve form fields on iOS */
|
||||
.form-group {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Mobile Compatibility Utilities
|
||||
*
|
||||
* This module provides utility functions for better mobile device compatibility,
|
||||
* especially for iOS devices where certain browser APIs have limited support.
|
||||
*
|
||||
* Copyright 2025 Maximilian Gründinger
|
||||
* Licensed under the Apache License, Version 2.0
|
||||
*/
|
||||
|
||||
// Detect mobile devices including tablets
|
||||
function isMobileDevice() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
// Specifically detect iOS devices
|
||||
function isIOSDevice() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
}
|
||||
|
||||
// Mobile-compatible file tracker to replace DataTransfer API
|
||||
class MobileCompatFileTracker {
|
||||
constructor() {
|
||||
this.files = [];
|
||||
this.removedIndices = new Set();
|
||||
}
|
||||
|
||||
// Add files from a FileList
|
||||
addFiles(fileList) {
|
||||
// Convert FileList to Array for easier handling
|
||||
Array.from(fileList).forEach(file => {
|
||||
this.files.push(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove a file by index
|
||||
removeFile(index) {
|
||||
if (index >= 0 && index < this.files.length) {
|
||||
this.removedIndices.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all non-removed files
|
||||
getFiles() {
|
||||
return this.files.filter((_, index) => !this.removedIndices.has(index));
|
||||
}
|
||||
|
||||
// Convert to FormData for upload
|
||||
appendToFormData(formData, fieldName) {
|
||||
this.getFiles().forEach(file => {
|
||||
formData.append(fieldName, file);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear all files
|
||||
clear() {
|
||||
this.files = [];
|
||||
this.removedIndices.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile optimized image preview generator
|
||||
class ImagePreviewGenerator {
|
||||
constructor(options = {}) {
|
||||
this.maxWidth = options.maxWidth || 800;
|
||||
this.maxHeight = options.maxHeight || 600;
|
||||
this.quality = options.quality || 0.8;
|
||||
this.maxPreviewsOnMobile = options.maxPreviewsOnMobile || 3;
|
||||
}
|
||||
|
||||
// Create optimized image preview element
|
||||
createPreview(file, index) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
reject(new Error('No file provided'));
|
||||
return;
|
||||
}
|
||||
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
if (isVideo) {
|
||||
this._createVideoPreview(file, index).then(resolve).catch(reject);
|
||||
} else if (file.type.startsWith('image/')) {
|
||||
this._createImagePreview(file, index).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(new Error('Unsupported file type'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create image preview with optimization for mobile
|
||||
_createImagePreview(file, index) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
// For mobile, we'll compress the image before creating the preview
|
||||
if (isMobileDevice()) {
|
||||
this._compressImage(e.target.result)
|
||||
.then(compressedDataUrl => {
|
||||
const previewElement = this._createPreviewElement(compressedDataUrl, file.name, index, false);
|
||||
resolve(previewElement);
|
||||
})
|
||||
.catch(err => {
|
||||
// Fallback to original if compression fails
|
||||
console.warn('Image compression failed, using original:', err);
|
||||
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
|
||||
resolve(previewElement);
|
||||
});
|
||||
} else {
|
||||
// For desktop, use the original image
|
||||
const previewElement = this._createPreviewElement(e.target.result, file.name, index, false);
|
||||
resolve(previewElement);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Create video preview with thumbnail generation
|
||||
_createVideoPreview(file, index) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const videoUrl = URL.createObjectURL(file);
|
||||
const video = document.createElement('video');
|
||||
|
||||
video.src = videoUrl;
|
||||
video.onloadedmetadata = () => {
|
||||
// Create preview element
|
||||
const previewElement = this._createPreviewElement(videoUrl, file.name, index, true);
|
||||
resolve(previewElement);
|
||||
|
||||
// Revoke object URL after a delay to ensure it's loaded
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(new Error('Failed to load video'));
|
||||
};
|
||||
|
||||
// Set a timeout in case the video never loads
|
||||
setTimeout(() => {
|
||||
if (!video.videoWidth) {
|
||||
URL.revokeObjectURL(videoUrl);
|
||||
reject(new Error('Video load timeout'));
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Create HTML element for preview
|
||||
_createPreviewElement(src, fileName, index, isVideo) {
|
||||
const previewDiv = document.createElement('div');
|
||||
previewDiv.className = 'image-preview-item';
|
||||
|
||||
if (isVideo) {
|
||||
const video = document.createElement('video');
|
||||
video.src = src;
|
||||
video.controls = true;
|
||||
video.preload = 'metadata';
|
||||
video.style.maxWidth = '150px';
|
||||
video.style.maxHeight = '150px';
|
||||
video.style.objectFit = 'cover';
|
||||
previewDiv.appendChild(video);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = src;
|
||||
img.alt = fileName;
|
||||
img.style.maxWidth = '150px';
|
||||
img.style.maxHeight = '150px';
|
||||
img.style.objectFit = 'cover';
|
||||
previewDiv.appendChild(img);
|
||||
}
|
||||
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'image-controls';
|
||||
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.textContent = 'Entfernen';
|
||||
removeButton.style.background = '#dc3545';
|
||||
removeButton.style.color = 'white';
|
||||
removeButton.style.border = 'none';
|
||||
removeButton.style.padding = '5px 10px';
|
||||
removeButton.style.borderRadius = '3px';
|
||||
removeButton.style.cursor = 'pointer';
|
||||
removeButton.style.marginLeft = '10px';
|
||||
removeButton.dataset.index = index;
|
||||
removeButton.addEventListener('click', function() {
|
||||
const indexToRemove = parseInt(this.dataset.index, 10);
|
||||
// We'll define removeImagePreview elsewhere
|
||||
if (typeof window.removeImagePreview === 'function') {
|
||||
window.removeImagePreview(indexToRemove);
|
||||
}
|
||||
});
|
||||
|
||||
controlsDiv.appendChild(removeButton);
|
||||
previewDiv.appendChild(controlsDiv);
|
||||
|
||||
return previewDiv;
|
||||
}
|
||||
|
||||
// Compress image for mobile devices
|
||||
_compressImage(dataUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let width = img.width;
|
||||
let height = img.height;
|
||||
|
||||
// Scale down if needed
|
||||
if (width > this.maxWidth || height > this.maxHeight) {
|
||||
const ratio = Math.min(this.maxWidth / width, this.maxHeight / height);
|
||||
width *= ratio;
|
||||
height *= ratio;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Get compressed data URL
|
||||
const compressedDataUrl = canvas.toDataURL('image/jpeg', this.quality);
|
||||
|
||||
// Clean up
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
|
||||
resolve(compressedDataUrl);
|
||||
};
|
||||
|
||||
img.onerror = () => reject(new Error('Failed to load image for compression'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile-optimized debounce function for event handlers
|
||||
function debounce(func, wait = 300) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Mobile-safe promise-based setTimeout
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Log mobile-specific issues to console or server
|
||||
function logMobileIssue(action, error) {
|
||||
if (isMobileDevice()) {
|
||||
const diagnosticInfo = {
|
||||
action: action,
|
||||
error: error.message,
|
||||
browser: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
memoryInfo: performance?.memory ? {
|
||||
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
||||
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
||||
usedJSHeapSize: performance.memory.usedJSHeapSize
|
||||
} : 'Not available'
|
||||
};
|
||||
|
||||
console.error('Mobile Issue:', diagnosticInfo);
|
||||
|
||||
// Optionally send to server
|
||||
fetch('/log_mobile_issue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(diagnosticInfo)
|
||||
}).catch(() => {
|
||||
// Silently fail if logging fails
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blog verwalten{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.blog-admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 0.7fr 1.3fr;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.new-post-form {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 16px;
|
||||
padding: 1.4rem;
|
||||
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
}
|
||||
|
||||
.new-post-form h2 {
|
||||
font-size: 1.2rem;
|
||||
color: #0f354d;
|
||||
margin: 0 0 0.9rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 700;
|
||||
color: #1f4b65;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #c4d4e0;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.72rem;
|
||||
font: inherit;
|
||||
color: #143f5d;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: #2f79a7;
|
||||
box-shadow: 0 0 0 3px rgba(58, 133, 180, 0.2);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.72rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 14px;
|
||||
padding: 1.1rem;
|
||||
box-shadow: 0 10px 28px rgba(20, 40, 55, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.post-info h3 {
|
||||
font-size: 1.05rem;
|
||||
color: #113d59;
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.post-info p {
|
||||
margin: 0.2rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #5a7a8f;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
font-size: 0.8rem;
|
||||
color: #7a8fa2;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.delete-form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #a74444 0%, #8a3535 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.blog-admin-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.new-post-form {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="font-size: 2rem; color: #0f354d; margin-bottom: 1.2rem;">Blog verwalten</h1>
|
||||
|
||||
<section class="blog-admin-grid">
|
||||
<aside class="new-post-form">
|
||||
<h2>Neuer Beitrag</h2>
|
||||
<form method="POST" action="{{ url_for('admin_blog') }}">
|
||||
<div class="field">
|
||||
<label for="title">Titel</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Beitragstitel"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="excerpt">Zusammenfassung</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
name="excerpt"
|
||||
placeholder="Optionale Kurzbeschreibung"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="content">Inhalt</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
placeholder="Beitrag schreiben..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<input type="hidden" name="action" value="create" />
|
||||
<button type="submit" class="submit-btn">Veroeffentlichen</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<article>
|
||||
<h2 style="font-size: 1.2rem; color: #0f354d; margin: 0 0 0.8rem;">Veroeffentlichte Beitraege</h2>
|
||||
<section class="posts-list">
|
||||
{% if posts %}
|
||||
{% for post in posts %}
|
||||
<article class="post-card">
|
||||
<div class="post-info">
|
||||
<h3>{{ post.title }}</h3>
|
||||
<p>{{ post.excerpt }}</p>
|
||||
<div class="post-meta">
|
||||
<strong>Autor:</strong> {{ post.author }}<br />
|
||||
<strong>Veroeffentlicht:</strong> {{ post.created_at[:10] }}
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('admin_blog') }}" class="delete-form">
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<input type="hidden" name="post_id" value="{{ post.id }}" />
|
||||
<button type="submit" class="delete-btn" onclick="return confirm('Sicher?')">Loeschen</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>Noch keine Beitraege veroeffentlicht.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 14px;
|
||||
padding: 1.2rem;
|
||||
box-shadow: 0 10px 28px rgba(20, 40, 55, 0.05);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: #0a5c88;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: #5a7a8f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.appointments-section {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 16px;
|
||||
padding: 1.4rem;
|
||||
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
|
||||
}
|
||||
|
||||
.appointments-section h2 {
|
||||
font-size: 1.3rem;
|
||||
color: #0f354d;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.appointment-card {
|
||||
border: 1px solid #d8e3ec;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
background: #f9fbfd;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.appointment-info > strong {
|
||||
display: block;
|
||||
color: #113d59;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.appointment-info p {
|
||||
margin: 0.15rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.appointment-status {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
border: 1px solid #fba316;
|
||||
background: #fffbf0;
|
||||
color: #b8730f;
|
||||
}
|
||||
|
||||
.status-confirmed {
|
||||
border: 1px solid #0e9f6e;
|
||||
background: #f0fdf4;
|
||||
color: #0b5c47;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
border: 1px solid #d3413e;
|
||||
background: #fdf5f5;
|
||||
color: #8b2a27;
|
||||
}
|
||||
|
||||
.appointment-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.action-form {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.response-input {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
border: 1px solid #c4d4e0;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: linear-gradient(120deg, #0a7c4e 0%, #065f3f 100%);
|
||||
}
|
||||
|
||||
.reject-btn {
|
||||
background: linear-gradient(120deg, #a74444 0%, #8a3535 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.admin-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.appointment-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="font-size: 2rem; color: #0f354d; margin-bottom: 1rem;">Admin Dashboard</h1>
|
||||
|
||||
<section class="admin-grid">
|
||||
<article class="stat-card">
|
||||
<div class="stat-value">{{ status_counts.Angefragt }}</div>
|
||||
<div class="stat-label">Neue Anfragen</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-value">{{ status_counts.Bestaetigt }}</div>
|
||||
<div class="stat-label">Bestaetigt</div>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<div class="stat-value">{{ total_posts }}</div>
|
||||
<div class="stat-label">Blog-Beitraege</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="appointments-section">
|
||||
<h2>Terminanfragen verwalten</h2>
|
||||
{% if appointments %}
|
||||
{% for appointment in appointments %}
|
||||
<article class="appointment-card">
|
||||
<div class="appointment-info">
|
||||
<strong>{{ appointment.display_name }} - {{ appointment.subject }}</strong>
|
||||
<p><strong>Datum:</strong> {{ appointment.date }} um {{ appointment.time }}</p>
|
||||
<p><strong>Angefragt:</strong> {{ appointment.created_at[:10] }}</p>
|
||||
{% if appointment.note %}
|
||||
<p><strong>Notiz:</strong> {{ appointment.note }}</p>
|
||||
{% endif %}
|
||||
{% if appointment.response %}
|
||||
<p><strong>Antwort:</strong> {{ appointment.response }}</p>
|
||||
{% endif %}
|
||||
<span class="appointment-status status-{{ appointment.status|lower|replace('Ä', 'ae')|replace('ö', 'oe')|replace('ü', 'ue') if appointment.status == 'Angefragt' else 'confirmed' if appointment.status == 'Bestaetigt' else 'rejected' }}">{{ appointment.status }}</span>
|
||||
</div>
|
||||
{% if appointment.status == 'Angefragt' %}
|
||||
<div class="appointment-actions">
|
||||
<form method="POST" action="{{ url_for('update_appointment', appointment_id=appointment.id) }}" class="action-form" style="min-width: 280px;">
|
||||
<textarea class="response-input" name="response" placeholder="Antwort (optional)"></textarea>
|
||||
<div style="display: flex; gap: 0.4rem;">
|
||||
<button type="submit" name="action" value="confirm" class="confirm-btn action-btn">Bestaetigen</button>
|
||||
<button type="submit" name="action" value="reject" class="reject-btn action-btn">Ablehnen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>Keine Terminanfragen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section style="margin-top: 1.5rem;">
|
||||
<a href="{{ url_for('admin_blog') }}" style="color: #ffffff; background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%); padding: 0.68rem 1.15rem; border-radius: 999px; font-weight: 700; display: inline-block; text-decoration: none;">Blog verwalten</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,302 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Termin anfragen{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.appointments-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 16px;
|
||||
padding: 1.2rem;
|
||||
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.calendar-header h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #133f5d;
|
||||
}
|
||||
|
||||
.month-nav {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.month-nav a {
|
||||
border-radius: 999px;
|
||||
border: 1px solid #bfd1de;
|
||||
padding: 0.38rem 0.7rem;
|
||||
color: #185170;
|
||||
font-weight: 700;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.weekday-row,
|
||||
.day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.weekday {
|
||||
text-align: center;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #587689;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.day {
|
||||
min-height: 74px;
|
||||
border: 1px solid #d8e3ec;
|
||||
border-radius: 10px;
|
||||
background: #f9fcff;
|
||||
color: #0f3f5d;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.day:hover {
|
||||
border-color: #9ec0d6;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.day.empty {
|
||||
background: #f2f6f9;
|
||||
border-style: dashed;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.day.today {
|
||||
border-color: #2d7fab;
|
||||
}
|
||||
|
||||
.day.selected {
|
||||
background: linear-gradient(140deg, #0d5c88 0%, #0a4464 100%);
|
||||
border-color: #0d5c88;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.booking-form h2,
|
||||
.appointment-list h2 {
|
||||
font-size: 1.2rem;
|
||||
color: #143f5e;
|
||||
margin: 0 0 0.8rem;
|
||||
}
|
||||
|
||||
.selected-date-badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.8rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.6rem;
|
||||
border: 1px solid #bfd2df;
|
||||
color: #23526c;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 700;
|
||||
color: #1f4b65;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #c4d4e0;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem 0.72rem;
|
||||
font: inherit;
|
||||
color: #143f5d;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 86px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.74rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entry {
|
||||
border: 1px solid #d7e2ea;
|
||||
border-radius: 12px;
|
||||
padding: 0.72rem;
|
||||
margin-bottom: 0.55rem;
|
||||
background: #f9fbfd;
|
||||
}
|
||||
|
||||
.entry strong {
|
||||
color: #113d59;
|
||||
}
|
||||
|
||||
.entry p {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
margin-top: 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #c9d7e2;
|
||||
padding: 0.15rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
color: #2f5f79;
|
||||
font-weight: 700;
|
||||
background: #eef4f8;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.appointments-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="appointments-grid">
|
||||
<article class="panel">
|
||||
<div class="calendar-header">
|
||||
<h1>{{ month_name }} {{ year }}</h1>
|
||||
<div class="month-nav">
|
||||
<a href="{{ url_for('appointments', month=previous_month, year=previous_year) }}">Zurueck</a>
|
||||
<a href="{{ url_for('appointments', month=next_month, year=next_year) }}">Weiter</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weekday-row" aria-hidden="true">
|
||||
<div class="weekday">Mo</div>
|
||||
<div class="weekday">Di</div>
|
||||
<div class="weekday">Mi</div>
|
||||
<div class="weekday">Do</div>
|
||||
<div class="weekday">Fr</div>
|
||||
<div class="weekday">Sa</div>
|
||||
<div class="weekday">So</div>
|
||||
</div>
|
||||
|
||||
<div class="day-grid" id="dayGrid">
|
||||
{% for week in month_grid %}
|
||||
{% for day in week %}
|
||||
{% if day == 0 %}
|
||||
<button type="button" class="day empty" disabled></button>
|
||||
{% else %}
|
||||
{% set day_iso = "%04d-%02d-%02d"|format(year, month, day) %}
|
||||
<button
|
||||
type="button"
|
||||
class="day {% if day_iso == today_iso %}today{% endif %}"
|
||||
data-date="{{ day_iso }}"
|
||||
>
|
||||
{{ day }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="panel booking-form">
|
||||
<h2>Termin anfragen</h2>
|
||||
<span class="selected-date-badge" id="selectedDateBadge">Kein Datum gewaehlt</span>
|
||||
<form method="POST" action="{{ url_for('appointments', month=month, year=year) }}">
|
||||
<input type="hidden" id="selectedDateInput" name="selected_date" required>
|
||||
<div class="field">
|
||||
<label for="appointment_time">Uhrzeit</label>
|
||||
<input id="appointment_time" name="appointment_time" type="time" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subject">Betreff</label>
|
||||
<input id="subject" name="subject" type="text" placeholder="z. B. Projekt Kickoff" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="note">Notiz (optional)</label>
|
||||
<textarea id="note" name="note" placeholder="Kurzbeschreibung der Anfrage"></textarea>
|
||||
</div>
|
||||
<button class="submit-btn" type="submit">Termin anfragen</button>
|
||||
</form>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="panel appointment-list" style="margin-top: 1rem;">
|
||||
<h2>Meine Terminanfragen</h2>
|
||||
{% if user_appointments %}
|
||||
{% for item in user_appointments %}
|
||||
<article class="entry">
|
||||
<strong>{{ item.date }} um {{ item.time }} - {{ item.subject }}</strong>
|
||||
{% if item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
{% if item.response %}
|
||||
<p><strong>Admin-Antwort:</strong> {{ item.response }}</p>
|
||||
{% endif %}
|
||||
<span class="status">{{ item.status }}</span>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p>Noch keine Anfragen vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const dayGrid = document.getElementById("dayGrid");
|
||||
const selectedDateInput = document.getElementById("selectedDateInput");
|
||||
const selectedDateBadge = document.getElementById("selectedDateBadge");
|
||||
|
||||
function setSelection(dateValue, button) {
|
||||
selectedDateInput.value = dateValue;
|
||||
selectedDateBadge.textContent = "Ausgewaehlt: " + dateValue;
|
||||
|
||||
dayGrid.querySelectorAll(".day").forEach(function (node) {
|
||||
node.classList.remove("selected");
|
||||
});
|
||||
|
||||
if (button) {
|
||||
button.classList.add("selected");
|
||||
}
|
||||
}
|
||||
|
||||
dayGrid.addEventListener("click", function (event) {
|
||||
const target = event.target.closest(".day[data-date]");
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
setSelection(target.dataset.date, target);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,325 @@
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>{% block title %}IT Loesungen{% endblock %}</title>
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-main: #f5f8fa;
|
||||
--bg-soft: #e8eef2;
|
||||
--surface: #ffffff;
|
||||
--text-main: #112332;
|
||||
--text-soft: #466176;
|
||||
--line: #d8e1e8;
|
||||
--brand: #0a4c74;
|
||||
--brand-strong: #073754;
|
||||
--accent: #f28c28;
|
||||
--radius-md: 14px;
|
||||
--shadow-sm: 0 10px 30px rgba(17, 35, 50, 0.08);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
font-family: "Source Sans 3", system-ui, sans-serif;
|
||||
color: var(--text-main);
|
||||
background: radial-gradient(circle at 85% -10%, #c5d8e5 0%, transparent 35%),
|
||||
radial-gradient(circle at -10% 50%, #dce8ef 0%, transparent 42%),
|
||||
var(--bg-main);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
margin: 0;
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
width: min(1240px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(245, 248, 250, 0.86);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.site-nav-inner {
|
||||
min-height: 78px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #bcd0de;
|
||||
padding: 0.48rem 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #0e425f;
|
||||
background: #ffffff;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-btn.primary {
|
||||
color: #ffffff;
|
||||
border-color: #0a4c74;
|
||||
background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%);
|
||||
}
|
||||
|
||||
.main-wrap {
|
||||
padding: 2rem 0 3.4rem;
|
||||
}
|
||||
|
||||
.flashes {
|
||||
margin-bottom: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flash.success { border-left: 5px solid #0e9f6e; }
|
||||
.flash.error { border-left: 5px solid #d3413e; }
|
||||
.flash.info { border-left: 5px solid var(--brand); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.site-nav-inner {
|
||||
min-height: 64px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.42rem 0.72rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="site-nav">
|
||||
<div class="site-shell site-nav-inner">
|
||||
<a class="brand" href="{{ url_for('default') }}">Invario</a>
|
||||
<div class="nav-links" aria-label="Main Navigation">
|
||||
<a href="{{ url_for('default') }}#leistungen">Dienstleistungen</a>
|
||||
<a href="{{ url_for('default') }}#projekte">Projekte</a>
|
||||
<a href="{{ url_for('default') }}#team">Team</a>
|
||||
<a href="{{ url_for('default') }}#kontakt">Kontakt</a>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
{% if 'username' in session %}
|
||||
<a class="nav-btn" href="{{ url_for('blog') }}">Blog</a>
|
||||
<a class="nav-btn" href="{{ url_for('appointments') }}">Termin anfragen</a>
|
||||
{% if session.get('is_admin') %}
|
||||
<a class="nav-btn" href="{{ url_for('admin_dashboard') }}">Admin</a>
|
||||
{% endif %}
|
||||
<a class="nav-btn" href="{{ url_for('logout') }}">Logout</a>
|
||||
{% else %}
|
||||
<a class="nav-btn" href="{{ url_for('blog') }}">Blog</a>
|
||||
<a class="nav-btn" href="{{ url_for('login') }}">Login</a>
|
||||
<a class="nav-btn primary" href="{{ url_for('register') }}">Registrieren</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="site-shell main-wrap">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flashes">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<div class="site-shell footer-inner">
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h4>Über uns</h4>
|
||||
<p>Wir bieten professionelle IT-Lösungen und Dienstleistungen für Ihr Unternehmen.</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>Schnelllinks</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="{{ url_for('default') }}">Startseite</a></li>
|
||||
<li><a href="{{ url_for('blog') }}">Blog</a></li>
|
||||
{% if 'username' in session %}
|
||||
<li><a href="{{ url_for('appointments') }}">Termine</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>Rechtliches</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="{{ url_for('datenschutz') }}">Datenschutz</a></li>
|
||||
<li><a href="{{ url_for('impressum') }}">Impressum</a></li>
|
||||
<li><a href="{{ url_for('nutzungsbedingungen') }}">Nutzungsbedingungen</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2026 Invario. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.site-footer {
|
||||
background: linear-gradient(135deg, var(--brand-strong) 0%, #0a4c74 100%);
|
||||
color: #ffffff;
|
||||
margin-top: 4rem;
|
||||
padding: 3rem 0 1.5rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.95rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.footer-inner {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</footer>
|
||||
<!-- Mobile compatibility scripts -->
|
||||
<script src="{{ url_for('static', filename='js/mobile_compatibility.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/ios_fixes.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blog{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.blog-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.blog-header h1 {
|
||||
font-size: 2.4rem;
|
||||
color: #0f354d;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-header p {
|
||||
font-size: 1.05rem;
|
||||
color: #5a7a8f;
|
||||
}
|
||||
|
||||
.posts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.post-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 14px;
|
||||
padding: 1.2rem;
|
||||
box-shadow: 0 10px 28px rgba(20, 40, 55, 0.05);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.post-link:hover .post-item {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 16px 40px rgba(20, 40, 55, 0.12);
|
||||
}
|
||||
|
||||
.post-item h2 {
|
||||
font-size: 1.2rem;
|
||||
color: #113d59;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.post-item p {
|
||||
margin: 0;
|
||||
color: #5a7a8f;
|
||||
font-size: 0.95rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.8rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid #e8f0f6;
|
||||
font-size: 0.8rem;
|
||||
color: #7a8fa2;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
color: #0a5c88;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.posts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.blog-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="blog-header">
|
||||
<h1>Blog</h1>
|
||||
<p>Neuigkeiten und Insights rund um IT-Loesungen und Projekte</p>
|
||||
</section>
|
||||
|
||||
{% if posts %}
|
||||
<section class="posts-grid">
|
||||
{% for post in posts %}
|
||||
<a href="{{ url_for('blog_post', post_id=post.id) }}" class="post-link">
|
||||
<article class="post-item">
|
||||
<h2>{{ post.title }}</h2>
|
||||
<p>{{ post.excerpt }}</p>
|
||||
<div class="post-meta">
|
||||
<span>{{ post.created_at[:10] }}</span>
|
||||
<span class="read-more">Zum Beitrag →</span>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<p style="text-align: center; color: #5a7a8f; padding: 2rem 0;">Noch keine Beitraege veroeffentlicht.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.post-container {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d4e0e9;
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.2rem;
|
||||
border-bottom: 1px solid #e8f0f6;
|
||||
}
|
||||
|
||||
.post-header h1 {
|
||||
font-size: 2.2rem;
|
||||
color: #0f354d;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.post-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #7a8fa2;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.author-badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: #eef4f8;
|
||||
border: 1px solid #bcd0de;
|
||||
color: #185170;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
line-height: 1.8;
|
||||
color: #2f4556;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.post-content h2 {
|
||||
font-size: 1.5rem;
|
||||
color: #113d59;
|
||||
margin: 1.5rem 0 0.8rem;
|
||||
}
|
||||
|
||||
.post-content h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #113d59;
|
||||
margin: 1.2rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.post-content p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.post-content ul,
|
||||
.post-content ol {
|
||||
margin: 0 0 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.post-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #bcd0de;
|
||||
color: #0a5c88;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-container {
|
||||
padding: 1.4rem;
|
||||
}
|
||||
|
||||
.post-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="post-container">
|
||||
<header class="post-header">
|
||||
<h1>{{ post.title }}</h1>
|
||||
<div class="post-header-meta">
|
||||
<span class="author-badge">{{ post.author }}</span>
|
||||
<span>{{ post.created_at[:10] }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="post-content">
|
||||
{{ post.content }}
|
||||
</section>
|
||||
|
||||
<a href="{{ url_for('blog') }}" class="back-link">← Zurueck zum Blog</a>
|
||||
</article>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,238 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Datenschutz | Invario{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="legal-page">
|
||||
<div class="legal-header">
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
<p>Gültig ab: 1. Januar 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-content">
|
||||
<section>
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<p>Verantwortlich für die Datenverarbeitung ist:</p>
|
||||
<p>
|
||||
Invario GmbH<br>
|
||||
Musterstraße 123<br>
|
||||
12345 Musterstadt<br>
|
||||
Deutschland<br>
|
||||
E-Mail: info@invario.de
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Datenschutzbeauftragter</h2>
|
||||
<p>Unseren Datenschutzbeauftragten erreichen Sie unter:</p>
|
||||
<p>
|
||||
datenschutz@invario.de
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Erhobene Daten</h2>
|
||||
<p>Wir erheben und verarbeiten folgende Kategorien von personenbezogenen Daten:</p>
|
||||
<ul>
|
||||
<li><strong>Registrierungsdaten:</strong> Benutzername, Display-Name, E-Mail (falls vorhanden), Passwort-Hash</li>
|
||||
<li><strong>Termin- und Buchungsdaten:</strong> Datum, Uhrzeit, Betreff, Notizen, Status der Termine</li>
|
||||
<li><strong>Blog-Daten:</strong> Veröffentlichte Artikel, Autoreninformationen</li>
|
||||
<li><strong>Technische Daten:</strong> IP-Adresse, Browser-Informationen, Cookie-Daten, Zeitstempel</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Zweck der Datenverarbeitung</h2>
|
||||
<p>Wir verarbeiten Ihre Daten zu folgenden Zwecken:</p>
|
||||
<ul>
|
||||
<li>Bereitstellung und Verwaltung Ihres Benutzerkontos</li>
|
||||
<li>Abwicklung von Terminbuchungen und Anfragen</li>
|
||||
<li>Kommunikation mit Ihnen zu Ihren Anfragen</li>
|
||||
<li>Bereitstellung personalisierter Inhalte</li>
|
||||
<li>Sicherheit und Schutz vor Missbrauch</li>
|
||||
<li>Verbesserung unserer Dienstleistungen</li>
|
||||
<li>Erfüllung gesetzlicher Verpflichtungen</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Rechtsgrundlagen</h2>
|
||||
<p>Die Verarbeitung personenbezogener Daten erfolgt auf Grundlage von:</p>
|
||||
<ul>
|
||||
<li><strong>Artikel 6 Abs. 1 lit. b) DSGVO:</strong> Abwicklung von Verträgen und Anfragen</li>
|
||||
<li><strong>Artikel 6 Abs. 1 lit. a) DSGVO:</strong> Ihre ausdrückliche Einwilligung</li>
|
||||
<li><strong>Artikel 6 Abs. 1 lit. c) DSGVO:</strong> Erfüllung rechtlicher Verpflichtungen</li>
|
||||
<li><strong>Artikel 6 Abs. 1 lit. f) DSGVO:</strong> Berechtigte Interessen zur Sicherheit und Verbesserung</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Speicherdauer</h2>
|
||||
<p>Wir speichern Ihre Daten so lange, wie dies erforderlich ist:</p>
|
||||
<ul>
|
||||
<li>Kontodaten: Solange Ihr Konto aktiv ist, danach 12 Monate</li>
|
||||
<li>Terminbuchungen: Bis 3 Jahre nach Abwicklung</li>
|
||||
<li>Blog-Kommentare und Inhalte: Bis zur Löschung des Inhalts</li>
|
||||
<li>Technische Logs: Maximal 90 Tage</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Empfänger von Daten</h2>
|
||||
<p>Wir geben Ihre Daten nicht an Dritte weiter, außer:</p>
|
||||
<ul>
|
||||
<li>Unsere technischen Dienstanbieter (Webhoting, Cloud-Services)</li>
|
||||
<li>Behörden und Gerichte bei gesetzlicher Verpflichtung</li>
|
||||
<li>Mit Ihrer ausdrücklichen Einwilligung</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Ihre Rechte</h2>
|
||||
<p>Sie haben folgende Rechte bezügliche Ihrer personenbezogenen Daten:</p>
|
||||
<ul>
|
||||
<li><strong>Auskunftsrecht:</strong> Recht auf Auskunft über Ihre Daten (Artikel 15 DSGVO)</li>
|
||||
<li><strong>Berichtigungsrecht:</strong> Recht auf Korrektur unrichtiger Daten (Artikel 16 DSGVO)</li>
|
||||
<li><strong>Löschungsrecht:</strong> Recht auf Löschung Ihrer Daten (Artikel 17 DSGVO)</li>
|
||||
<li><strong>Einschränkungsrecht:</strong> Recht auf Einschränkung der Verarbeitung (Artikel 18 DSGVO)</li>
|
||||
<li><strong>Recht auf Datenportabilität:</strong> Recht auf Übertragung Ihrer Daten (Artikel 20 DSGVO)</li>
|
||||
<li><strong>Widerspruchsrecht:</strong> Recht auf Widerspruch gegen Verarbeitung (Artikel 21 DSGVO)</li>
|
||||
<li><strong>Recht auf Beschwerde:</strong> Beschwerde bei einer Aufsichtsbehörde</li>
|
||||
</ul>
|
||||
<p>Um Ihre Rechte auszuüben, kontaktieren Sie uns unter: datenschutz@invario.de</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Cookies</h2>
|
||||
<p>Wir verwenden Cookies zur Verwaltung von Sitzungen und Authentifizierung:</p>
|
||||
<ul>
|
||||
<li><strong>Session-Cookies:</strong> Erforderlich für die Anmeldung und Funktionalität</li>
|
||||
<li><strong>Sicherheits-Cookies:</strong> Schutz vor CSRF und XSS-Angriffen</li>
|
||||
<li><strong>Analytik-Cookies:</strong> Optional, zur Verbesserung unserer Dienste</li>
|
||||
</ul>
|
||||
<p>Sie können Cookies in den Einstellungen Ihres Browsers deaktivieren. Dies kann jedoch die Funktionalität beeinträchtigen.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Sicherheit</h2>
|
||||
<p>Wir schützen Ihre Daten durch:</p>
|
||||
<ul>
|
||||
<li>Verschlüsselte Übertragung (SSL/TLS)</li>
|
||||
<li>Sichere Passwortspeicherung (Hashing)</li>
|
||||
<li>Firewalls und Zugriffskontrollen</li>
|
||||
<li>Regelmäßige Sicherheitsupdates</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Externe Links</h2>
|
||||
<p>Diese Website enthält möglicherweise Links zu externen Websites. Wir sind nicht verantwortlich für deren Datenschutzpraktiken. Bitte lesen Sie deren Datenschutzerklärungen.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>12. Änderungen dieser Datenschutzerklärung</h2>
|
||||
<p>Wir können diese Datenschutzerklärung jederzeit aktualisieren. Die aktuelle Version ist immer auf unserer Website verfügbar. Bei wesentlichen Änderungen werden Sie benachrichtigt.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>13. Kontakt</h2>
|
||||
<p>Bei Fragen zur Datenschutzerklärung kontaktieren Sie uns unter:</p>
|
||||
<p>
|
||||
E-Mail: datenschutz@invario.de<br>
|
||||
Telefon: +49 (0) 123 456789<br>
|
||||
Adresse: Musterstraße 123, 12345 Musterstadt
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legal-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.legal-header {
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--line);
|
||||
}
|
||||
|
||||
.legal-header h1 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.legal-header p {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--brand);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section p {
|
||||
line-height: 1.7;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
section ul li {
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
section ul li:before {
|
||||
content: "→";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
section strong {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.legal-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
gap: 1.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,279 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Impressum | Invario{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="legal-page">
|
||||
<div class="legal-header">
|
||||
<h1>Impressum</h1>
|
||||
<p>Angaben gemäß TMG (Telemediengesetz) und MDStV (Mediendienstestaatsvertrag)</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-content">
|
||||
<section>
|
||||
<h2>1. Betreiber der Website</h2>
|
||||
<p>
|
||||
<strong>Invario GmbH</strong><br>
|
||||
Musterstraße 123<br>
|
||||
12345 Musterstadt<br>
|
||||
Deutschland
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Registergericht und Registernummer</h2>
|
||||
<p>
|
||||
<strong>Registergericht:</strong> Amtsgericht Musterstadt<br>
|
||||
<strong>Registernummer:</strong> HRB 123456<br>
|
||||
<strong>USt-ID:</strong> DE 123 456 789
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Geschäftsführung</h2>
|
||||
<p>
|
||||
<strong>Geschäftsführer:</strong> Max Mustermann und Erika Musterfrau
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Kontaktinformationen</h2>
|
||||
<p>
|
||||
<strong>Telefon:</strong> +49 (0) 123 / 456789<br>
|
||||
<strong>E-Mail:</strong> info@invario.de<br>
|
||||
<strong>Website:</strong> www.invario.de
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Verantwortliche Redaktion</h2>
|
||||
<p>
|
||||
<strong>Verantwortlich nach § 55 Abs. 2 RStV:</strong><br>
|
||||
Max Mustermann<br>
|
||||
Invario GmbH<br>
|
||||
Musterstraße 123<br>
|
||||
12345 Musterstadt
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Haftungsausschluss</h2>
|
||||
<p>
|
||||
<strong>Haftung für Inhalte:</strong><br>
|
||||
Trotz sorgfältiger Überprüfung übernehmen wir keine Haftung für die Inhalte externer Links.
|
||||
Die auf dieser Website verlinkten Seiten sind ausschließlich Verantwortung des jeweiligen Betreibers.
|
||||
Zum Zeitpunkt der Link-Erstellung waren keine illegalen Inhalte erkennbar.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Haftung für Links:</strong><br>
|
||||
Sollte eine Seite, auf die wir verlinken, in Zukunft illegale oder sittenwidrige Inhalte enthalten,
|
||||
werden wir den Link entfernen, sobald wir davon Kenntnis erlangen. Die Funktionsfähigkeit der Website
|
||||
wird trotz gründlicher Prüfung nicht garantiert.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Haftung für Dienste und Angebote:</strong><br>
|
||||
Alle unterbreiteten Informationen sind freibleibend und unverbindlich. Wir behalten uns ausdrücklich vor,
|
||||
Teile der Website oder das gesamte Angebot ohne separate Ankündigung zu verändern, zu ergänzen, zu löschen oder
|
||||
die Veröffentlichung zeitweise oder endgültig einzustellen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Urheberrecht und Nutzungsrechte</h2>
|
||||
<p>
|
||||
Die auf dieser Website veröffentlichten Inhalte und Werke unterliegen dem deutschen Urheberrecht.
|
||||
Eine Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts
|
||||
bedürfen der schriftlichen Zustimmung des Urhebers oder Erstellers. Downloads und Kopien dieser
|
||||
Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.
|
||||
</p>
|
||||
<p>
|
||||
<strong>© 2026 Invario GmbH. Alle Rechte vorbehalten.</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Besonderheiten in Bezug auf Inhalte</h2>
|
||||
<p>
|
||||
<strong>Blog und Benutzergenerierte Inhalte:</strong><br>
|
||||
Für Inhalte, die von Benutzern oder Administratoren veröffentlicht werden, trägt der jeweilige Autor/Verfasser
|
||||
die Verantwortung. Wir überprüfen regelmäßig alle Inhalte auf der Website. Sollten Sie der Meinung sein,
|
||||
dass gegen Ihr Urheberrecht verstoßen wird oder illegale Inhalte vorliegen, teilen Sie uns dies bitte
|
||||
umgehend mit.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Zahlungsarten und Geschäftsbedingungen</h2>
|
||||
<p>
|
||||
Die Nutzung unserer Dienstleistungen unterliegt den auf dieser Website veröffentlichten Nutzungsbedingungen
|
||||
und unseren allgemeinen Geschäftsbedingungen. Diese finden Sie unter
|
||||
<a href="{{ url_for('nutzungsbedingungen') }}">Nutzungsbedingungen</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Datenschutz</h2>
|
||||
<p>
|
||||
Weitere Informationen zur Verarbeitung Ihrer personenbezogenen Daten finden Sie in unserer
|
||||
<a href="{{ url_for('datenschutz') }}">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Außergerichtliche Streitbeilegung</h2>
|
||||
<p>
|
||||
Der Europäischen Kommission steht ein Online-Portal zur Beilegung von Streitigkeiten
|
||||
(Plattform zur Online-Streitbeilegung - OS) zur Verfügung:
|
||||
<a href="https://ec.europa.eu/consumers/odr/" target="_blank">https://ec.europa.eu/consumers/odr/</a>
|
||||
</p>
|
||||
<p>
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>12. Geltendes Recht</h2>
|
||||
<p>
|
||||
Für alle Rechtsbeziehungen zwischen dem Nutzer und uns gilt ausschließlich deutsches Recht.
|
||||
Es gilt das TMG und das MDStV in ihrer jeweils aktuellen Fassung. Erfüllungsort ist Musterstadt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>13. Kontakt zum Datenschutzbeauftragten</h2>
|
||||
<p>
|
||||
<strong>Datenschutzbeauftragter:</strong><br>
|
||||
E-Mail: datenschutz@invario.de<br>
|
||||
Telefon: +49 (0) 123 / 456789
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>14. Zu beachtende Gebote in Bezug auf Konten</h2>
|
||||
<ul>
|
||||
<li>Die Anmeldung erfordert einen gültigen Benutzernamen und ein Passwort</li>
|
||||
<li>Sie sind verantwortlich für die Geheimhaltung des Passworts</li>
|
||||
<li>Sie haften für alle Aktivitäten in Ihrem Konto</li>
|
||||
<li>Missbrauch und Sicherheitslücken müssen uns sofort mitgeteilt werden</li>
|
||||
<li>Eine Konto-Weitergabe ist nicht gestattet</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="last-updated">
|
||||
<p><strong>Zuletzt aktualisiert:</strong> 1. Januar 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legal-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.legal-header {
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--line);
|
||||
}
|
||||
|
||||
.legal-header h1 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.legal-header p {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--brand);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section p {
|
||||
line-height: 1.7;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
section p a {
|
||||
color: var(--brand);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
section p a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
section ul li {
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
section ul li:before {
|
||||
content: "→";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
section strong {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
background: var(--bg-soft);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.last-updated p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.legal-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
gap: 1.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
Copyright 2025 Maximilian Gründinger
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.auth-wrap {
|
||||
max-width: 520px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #d4e0e9;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 44px rgba(14, 32, 48, 0.08);
|
||||
}
|
||||
|
||||
.auth-wrap h1 {
|
||||
font-size: 2rem;
|
||||
color: #0f344d;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.auth-wrap p {
|
||||
margin-bottom: 1.3rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 700;
|
||||
color: #19445f;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.68rem 0.78rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #c4d5e2;
|
||||
font-size: 1rem;
|
||||
color: #12384f;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: #2f79a7;
|
||||
box-shadow: 0 0 0 3px rgba(58, 133, 180, 0.2);
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.72rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-meta {
|
||||
margin-top: 1rem;
|
||||
color: #547184;
|
||||
}
|
||||
|
||||
.auth-meta a {
|
||||
color: #0a5f8f;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="auth-wrap">
|
||||
<h1>Login</h1>
|
||||
<p>Bitte melden Sie sich mit Ihrem Konto an, um Termine anzufragen.</p>
|
||||
<form method="POST" action="{{ url_for('login') }}">
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button class="auth-btn" type="submit">Login</button>
|
||||
</form>
|
||||
<p class="auth-meta">Noch kein Konto? <a href="{{ url_for('register') }}">Jetzt registrieren</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,450 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}IT Loesungen | Modern{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.hero {
|
||||
margin-top: 1.2rem;
|
||||
padding: 3.8rem;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, #f8fcff 0%, #e1ecf3 70%, #d4e3ed 100%);
|
||||
border: 1px solid #cfdbe4;
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 55px rgba(17, 35, 50, 0.1);
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
bottom: -90px;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at center, #0b5b89 0%, #0b5b89 40%, transparent 70%);
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.85rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #0c4f78;
|
||||
background: #d9ebf6;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 5.1vw, 3.4rem);
|
||||
color: #06293e;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
max-width: 56ch;
|
||||
margin-top: 1.1rem;
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
margin-top: 1.8rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.68rem 1.15rem;
|
||||
font-weight: 700;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #0c5a86 0%, #08486c 100%);
|
||||
box-shadow: 0 8px 20px rgba(11, 87, 128, 0.26);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: #0d4567;
|
||||
background: #ffffff;
|
||||
border-color: #b8cddc;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
align-self: end;
|
||||
background: #0e2f44;
|
||||
color: #dcf0ff;
|
||||
border-radius: 18px;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid #194b68;
|
||||
}
|
||||
|
||||
.hero-card h3 {
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.hero-card p {
|
||||
color: #b6d6ea;
|
||||
font-size: 0.96rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.stats div {
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 12px;
|
||||
background: #153a52;
|
||||
border: 1px solid #2a5673;
|
||||
}
|
||||
|
||||
.stats strong {
|
||||
display: block;
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
color: #ffffff;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.stats span {
|
||||
color: #9ec5dc;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 2.2rem;
|
||||
padding: 2rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #d4dde5;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 15px 35px rgba(20, 40, 55, 0.05);
|
||||
animation: rise 700ms ease both;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: clamp(1.4rem, 2.5vw, 2rem);
|
||||
margin-bottom: 0.5rem;
|
||||
color: #0f334b;
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
margin-top: 1.1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #d9e2ea;
|
||||
background: linear-gradient(160deg, #fbfdff 0%, #f4f8fb 100%);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.07rem;
|
||||
color: #113c58;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
margin-top: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.project {
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #cfdae4;
|
||||
background: #f8fbfd;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project b {
|
||||
display: block;
|
||||
color: #0b3753;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.project span {
|
||||
font-size: 0.88rem;
|
||||
color: #4a6579;
|
||||
border: 1px solid #bfd0dd;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.process {
|
||||
margin-top: 1.2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding: 0.8rem 0.65rem;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ccdae5;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.step strong {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
font-size: 0.98rem;
|
||||
color: #0f3650;
|
||||
}
|
||||
|
||||
.step small {
|
||||
color: #5f7b8f;
|
||||
}
|
||||
|
||||
.team-note {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0c18b;
|
||||
background: linear-gradient(120deg, #fffaf3 0%, #ffeed8 100%);
|
||||
color: #7d4f14;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact {
|
||||
margin-top: 2rem;
|
||||
border-radius: 20px;
|
||||
padding: 2.3rem;
|
||||
border: 1px solid #0f496f;
|
||||
background: linear-gradient(120deg, #114e75 0%, #0a3d5c 100%);
|
||||
color: #d2ebfa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.contact h2 {
|
||||
color: #ffffff;
|
||||
font-size: clamp(1.25rem, 2.7vw, 2rem);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.contact p {
|
||||
color: #b9d8ec;
|
||||
}
|
||||
|
||||
.contact .btn-secondary {
|
||||
border-color: rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem 0 0.3rem;
|
||||
color: #6a8192;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.process {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.section-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.process {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.contact {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 1.55rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
flex-direction: column;
|
||||
padding-bottom: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero" id="top">
|
||||
<div>
|
||||
<span class="eyebrow">IT Loesungen mit Haltung</span>
|
||||
<h1>Zuhören. Verstehen. Planen. Realisieren.</h1>
|
||||
<p>
|
||||
Wir unterstuetzen Unternehmen ganzheitlich bei Enterprise-Architektur,
|
||||
Projektmanagement und IT-Betrieb. Die Anforderung bleibt im Mittelpunkt,
|
||||
die Umsetzung wird klar, messbar und belastbar.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
{% if 'username' in session %}
|
||||
<a class="btn btn-primary" href="{{ url_for('appointments') }}">Termin anfragen</a>
|
||||
{% else %}
|
||||
<a class="btn btn-primary" href="{{ url_for('login') }}">Termin anfragen</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary" href="#kontakt">Kontakt aufnehmen</a>
|
||||
<a class="btn btn-secondary" href="#leistungen">Dienstleistungen ansehen</a>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-card" aria-label="Kurzfakten">
|
||||
<h3>IT Projekte mit Substanz</h3>
|
||||
<p>
|
||||
Von Infrastruktur bis Governance: Wir verbinden konzeptionelle Tiefe mit
|
||||
pragmatischer Umsetzung im Tagesgeschaeft.
|
||||
</p>
|
||||
<div class="stats">
|
||||
<div><strong>25+</strong><span>aktive Mandate</span></div>
|
||||
<div><strong>98%</strong><span>Zieltermine erreicht</span></div>
|
||||
<div><strong>24/7</strong><span>Servicebereit</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="section" id="leistungen" style="animation-delay: 100ms;">
|
||||
<h2>Dienstleistungen</h2>
|
||||
<p>Modulare Leistungen, individuell kombiniert fuer Ihre IT-Roadmap.</p>
|
||||
<div class="section-grid">
|
||||
<article class="card">
|
||||
<h3>Support und Service</h3>
|
||||
<p>Betriebskritische Systeme stabil halten, proaktiv ueberwachen und schnell reagieren.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Optimierung und Tuning</h3>
|
||||
<p>Strukturen, Kosten und Performance datenbasiert verbessern statt nur Symptome behandeln.</p>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h3>Health Check und Verify</h3>
|
||||
<p>Technische Due-Diligence, klare Handlungsplaene und belastbare Priorisierung.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="projekte" style="animation-delay: 180ms;">
|
||||
<h2>Projektfelder</h2>
|
||||
<p>Ausgewählte Schwerpunkte aus laufenden und abgeschlossenen Kundenprojekten.</p>
|
||||
<div class="project-list">
|
||||
<div class="project">
|
||||
<div>
|
||||
<b>Virtualisierung und Hybrid-Cloud</b>
|
||||
Migration in wartbare Plattformen mit planbaren Betriebskosten.
|
||||
</div>
|
||||
<span>Cloud Computing</span>
|
||||
</div>
|
||||
<div class="project">
|
||||
<div>
|
||||
<b>Netzwerk und Sicherheit</b>
|
||||
Segmentierung, Monitoring und Security-Standards auf Enterprise-Niveau.
|
||||
</div>
|
||||
<span>Security</span>
|
||||
</div>
|
||||
<div class="project">
|
||||
<div>
|
||||
<b>Projektmanagement und EAM</b>
|
||||
Governance, Architekturprinzipien und Umsetzung in einer Linie.
|
||||
</div>
|
||||
<span>Transformation</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="team" style="animation-delay: 260ms;">
|
||||
<h2>Unsere Arbeitsweise</h2>
|
||||
<p>Klassische Beratungsdisziplin trifft auf moderne Delivery-Kultur.</p>
|
||||
<div class="process" aria-label="Prozessschritte">
|
||||
<div class="step"><strong>Zuhoeren</strong><small>Anforderungen erfassen</small></div>
|
||||
<div class="step"><strong>Verstehen</strong><small>Kontext analysieren</small></div>
|
||||
<div class="step"><strong>Besprechen</strong><small>Optionen priorisieren</small></div>
|
||||
<div class="step"><strong>Planen</strong><small>Roadmap festlegen</small></div>
|
||||
<div class="step"><strong>Realisieren</strong><small>Loesung liefern</small></div>
|
||||
<div class="step"><strong>Unterstuetzen</strong><small>Betrieb absichern</small></div>
|
||||
</div>
|
||||
<p class="team-note">
|
||||
Aktuell verfuegbar: Ein IT-Infrastruktur-Architekt und ein IT-Governance-Experte
|
||||
mit technischem Hintergrund fuer neue Aufgaben.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="contact" id="kontakt">
|
||||
<div>
|
||||
<h2>Bereit fuer das naechste IT-Projekt?</h2>
|
||||
<p>In einem kurzen Erstgespraech klaeren wir Zielbild, Risiken und realistische Umsetzungsschritte.</p>
|
||||
</div>
|
||||
{% if 'username' in session %}
|
||||
<a class="btn btn-secondary" href="{{ url_for('appointments') }}">Termin anfragen</a>
|
||||
{% else %}
|
||||
<a class="btn btn-secondary" href="{{ url_for('login') }}">Termin anfragen</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,354 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Nutzungsbedingungen | Invario{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="legal-page">
|
||||
<div class="legal-header">
|
||||
<h1>Nutzungsbedingungen</h1>
|
||||
<p>Allgemeine Geschäftsbedingungen (AGB) für die Nutzung von Invario</p>
|
||||
</div>
|
||||
|
||||
<div class="legal-content">
|
||||
<section>
|
||||
<h2>1. Geltungsbereich und Vertragsparteien</h2>
|
||||
<p>
|
||||
Diese Nutzungsbedingungen regeln die Beziehung zwischen Ihnen als Nutzer und der Invario GmbH
|
||||
bei der Nutzung unserer Website und Dienstleistungen. Mit der Nutzung unserer Website und Dienste
|
||||
akzeptieren Sie diese Bedingungen vollständig und verbindlich.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Vertragsparteien sind:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Invario GmbH (Anbieter)</li>
|
||||
<li>Der Nutzer (Sie)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Registrierung und Konto</h2>
|
||||
<p>
|
||||
<strong>Anforderungen zur Registrierung:</strong><br>
|
||||
Für die Nutzung bestimmter Funktionen müssen Sie sich registrieren. Sie verpflichten sich,
|
||||
wahrheitsgemäß und vollständig folgende Informationen bereitzustellen:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Einen eindeutigen Benutzernamen</li>
|
||||
<li>Einen Anzeigenamen (Display Name)</li>
|
||||
<li>Ein sicheres Passwort (mindestens 8 Zeichen)</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Ihre Verantwortung:</strong><br>
|
||||
Sie sind verantwortlich für den Schutz Ihres Passworts und für alle Aktivitäten in Ihrem Konto.
|
||||
Sie müssen uns sofort benachrichtigen, wenn Sie unbefugten Zugriff vermuten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Zulässige Nutzung</h2>
|
||||
<p>
|
||||
<strong>Ihre Nutzung unserer Dienste ist auf folgende Weise begrenzt:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Sie werden die Website nicht für illegale oder schädliche Zwecke nutzen</li>
|
||||
<li>Sie werden keine Malware, Viren oder zerstörerischen Code hochladen</li>
|
||||
<li>Sie werden nicht versuchen, unbefugten Zugang zu erhalten</li>
|
||||
<li>Sie werden die Website oder deren Dienste nicht missbrauchen</li>
|
||||
<li>Sie werden keine personenbezogenen Daten anderer Nutzer sammeln</li>
|
||||
<li>Sie werden die Rechte anderer Nutzer respektieren</li>
|
||||
<li>Sie werden keine rassistischen, rassistischen oder fremdenfeindlichen Inhalte teilen</li>
|
||||
<li>Sie werden keine Belästigung oder Bedrohung durchführen</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Terminbuchungen und Anfragen</h2>
|
||||
<p>
|
||||
<strong>Buchungsprozess:</strong><br>
|
||||
Sie können über unsere Plattform Termine anfordern. Ein Termin ist nicht automatisch bestätigt,
|
||||
sondern muss von unserem Team überprüft und genehmigt werden.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Termine inkl. Folgende Rechte und Pflichten:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Sie können einen Termin anfordern, wir sind jedoch nicht verpflichtet, diesen zu bestätigen</li>
|
||||
<li>Ein Termin wird erst vermittelt durch eine Bestätigung durch unser Team</li>
|
||||
<li>Bei Ablehnung erhalten Sie eine Begründung</li>
|
||||
<li>Sie sind verantwortlich für die Richtigkeit Ihrer Angaben</li>
|
||||
<li>Im Falle von Stornierungen beachten Sie bitte unsere Stornierungsrichtlinien</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Blog und benutzergenerierte Inhalte</h2>
|
||||
<p>
|
||||
<strong>Veröffentlichung von Inhalten:</strong><br>
|
||||
Admins können Blog-Beiträge veröffentlichen. Diese Beiträge müssen folgende Anforderungen erfüllen:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Keine illegalen oder extremistischen Inhalte</li>
|
||||
<li>Keine rassistischen oder rassistischen Aussagen</li>
|
||||
<li>Keine Verletzung von Urheberrechten dritter Parteien</li>
|
||||
<li>Keine persönlichen Daten von anderen Personen ohne ihre Zustimmung</li>
|
||||
<li>Keine Spam oder kommerzielle Werbung (außer für autorisierte Partner)</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Recht zur Modernis und Lösch:</strong><br>
|
||||
Wir behalten uns das Recht vor, Inhalte zu moderieren, zu bearbeiten oder zu löschen,
|
||||
die gegen diese Bedingungen verstoßen. Dies geschieht ohne Ankündigung und ohne Haftung.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Geistige Eigentum</h2>
|
||||
<p>
|
||||
<strong>Alle Inhalte und Materialien auf dieser Website, einschließlich, aber nicht begrenzt auf:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Texte, Grafiken, Logos, Bilder, Audio- und Video-Dateien</li>
|
||||
<li>Software, Quellcode und Datenbanken</li>
|
||||
<li>Die Auswahl, Koordination und Anordnung solcher Materialien</li>
|
||||
</ul>
|
||||
<p>
|
||||
...sind Eigentum der Invario GmbH oder der lizenzierten Parteien und durch Urheberrechts-
|
||||
und sonstige intellektuelle Eigentumsgesetze geschützt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Haftungsbeschränkung</h2>
|
||||
<p>
|
||||
<strong>Haftungsausschluss:</strong><br>
|
||||
Soweit von Gesetzes wegen zulässig, erklären wir uns nicht verantwortlich für:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Indirekte, zufällige oder Folgeschäden</li>
|
||||
<li>Entgangene Gewinne oder Geschäftsmöglichkeiten</li>
|
||||
<li>Verlust von Daten oder Informationen</li>
|
||||
<li>Unterbrechungen oder Ausfallzeiten</li>
|
||||
<li>Schäden durch unbefugten Zugriff</li>
|
||||
<li>Schäden durch Dritte oder externe Faktoren</li>
|
||||
</ul>
|
||||
<p>
|
||||
<strong>Haftungsbegrenzung:</strong><br>
|
||||
Falls wir haftbar sind, ist unsere Haftung auf den Betrag begrenzt, den Sie an uns bezahlt haben
|
||||
oder den geringeren Betrag von 100 EUR.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Sicherheit und Datenschutz</h2>
|
||||
<p>
|
||||
Wir nehmen die Sicherheit Ihrer Daten ernst. Weitere Details finden Sie in unserer
|
||||
<a href="{{ url_for('datenschutz') }}">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Sie verpflichten sich:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Ihr Passwort geheim zu halten</li>
|
||||
<li>Sich abzumelden, wenn Sie andere das System nutzen</li>
|
||||
<li>Uns über Sicherheitsverletzungen zu benachrichtigen</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Änderungen der Bedingungen und Dienste</h2>
|
||||
<p>
|
||||
<strong>Recht zur Änderung:</strong><br>
|
||||
Wir behalten uns das Recht vor, diese Nutzungsbedingungen jederzeit zu ändern.
|
||||
Sie werden benachrichtigt, wenn wesentliche Änderungen vorgenommen werden.
|
||||
Die weitere Nutzung der Website bedeutet Ihre Annahme der neuen Bedingungen.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Änderungen an Diensten:</strong><br>
|
||||
Wir behalten uns das Recht vor, Dienste jederzeit zu ändern, zu erweitern oder zu entfernen,
|
||||
ohne davor verpflichtet zu sein, Sie zu informieren.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Beendigung des Kontos</h2>
|
||||
<p>
|
||||
<strong>Beendigung durch den Nutzer:</strong><br>
|
||||
Sie können Ihr Konto jederzeit beenden, indem Sie uns eine E-Mail senden.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Beendigung durch uns:</strong><br>
|
||||
Wir können Ihr Konto sofort beenden, wenn Sie diese Nutzungsbedingungen verletzen oder
|
||||
verdächtige Aktivitäten aufweisen. Dies geschieht ohne Vorankündigung.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Webhooks und automatische Integration</h2>
|
||||
<p>
|
||||
<strong>Funktionalität:</strong><br>
|
||||
Unsere Plattform ermöglicht die Verwaltung von Terminen und Inhalten.
|
||||
Diese Daten sind nur für Sie und bei Berechtigungen für unseren Admin-Team zugänglich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>12. Benachrichtigungen und Kommunikation</h2>
|
||||
<p>
|
||||
<strong>Kommunikationsmittel:</strong><br>
|
||||
Wir können Sie per E-Mail, Website-Benachrichtigungen oder anderen Mitteln benachrichtigen.
|
||||
Sie erklären sich damit einverstanden, solche Benachrichtigungen zu erhalten.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Häufigkeit:</strong><br>
|
||||
Wir verpflichten uns, Sie nicht übermäßig zu bombardieren, aber wir können Ihnen relevante Aktualisierungen zusenden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>13. Verfügbarkeit und Support</h2>
|
||||
<p>
|
||||
Wir bieten Support während normaler Geschäftszeiten. Während Wartungsarbeiten oder ungeplanten Ausfallzeiten
|
||||
ist die Website möglicherweise nicht verfügbar. Wir entschuldigen uns für jegliche Unannehmlichkeiten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>14. Anwendbares Recht und Gerichtsstand</h2>
|
||||
<p>
|
||||
Diese Nutzungsbedingungen unterliegen deutschem Recht. Die Gerichtsstände für alle Streitigkeiten
|
||||
sind in Musterstadt. Sie erklären sich damit einverstanden, dass alle Ansprüche in den deutschen Gerichten
|
||||
eingereicht werden müssen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>15. Kontakt</h2>
|
||||
<p>
|
||||
Bei Fragen zu diesen Nutzungsbedingungen kontaktieren Sie uns unter:
|
||||
</p>
|
||||
<p>
|
||||
Invario GmbH<br>
|
||||
E-Mail: info@invario.de<br>
|
||||
Telefon: +49 (0) 123 / 456789<br>
|
||||
Adresse: Musterstraße 123, 12345 Musterstadt
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="last-updated">
|
||||
<p><strong>Zuletzt aktualisiert:</strong> 1. Januar 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.legal-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.legal-header {
|
||||
margin-bottom: 3rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 2px solid var(--line);
|
||||
}
|
||||
|
||||
.legal-header h1 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.legal-header p {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--brand);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
section p {
|
||||
line-height: 1.7;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
section p a {
|
||||
color: var(--brand);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
section p a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
section ul li {
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
section ul li:before {
|
||||
content: "→";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
section strong {
|
||||
color: var(--text-main);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.last-updated {
|
||||
background: var(--bg-soft);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.last-updated p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.legal-header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.legal-content {
|
||||
gap: 1.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Registrieren{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
.auth-wrap {
|
||||
max-width: 560px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid #d4e0e9;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 20px 44px rgba(14, 32, 48, 0.08);
|
||||
}
|
||||
|
||||
.auth-wrap h1 {
|
||||
font-size: 2rem;
|
||||
color: #0f344d;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.auth-wrap p {
|
||||
margin-bottom: 1.3rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 700;
|
||||
color: #19445f;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.68rem 0.78rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #c4d5e2;
|
||||
font-size: 1rem;
|
||||
color: #12384f;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: #2f79a7;
|
||||
box-shadow: 0 0 0 3px rgba(58, 133, 180, 0.2);
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.72rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(120deg, #0a5f8f 0%, #0b4567 100%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-meta {
|
||||
margin-top: 1rem;
|
||||
color: #547184;
|
||||
}
|
||||
|
||||
.auth-meta a {
|
||||
color: #0a5f8f;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="auth-wrap">
|
||||
<h1>Registrierung</h1>
|
||||
<p>Legen Sie ein Konto an, um den Kalender fuer Terminanfragen zu nutzen.</p>
|
||||
<form method="POST" action="{{ url_for('register') }}">
|
||||
<div class="field">
|
||||
<label for="display_name">Name</label>
|
||||
<input id="display_name" name="display_name" type="text" autocomplete="name" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort (mind. 8 Zeichen)</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password_repeat">Passwort wiederholen</label>
|
||||
<input id="password_repeat" name="password_repeat" type="password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<button class="auth-btn" type="submit">Konto erstellen</button>
|
||||
</form>
|
||||
<p class="auth-meta">Bereits registriert? <a href="{{ url_for('login') }}">Zum Login</a></p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
from pymongo import MongoClient
|
||||
import hashlib
|
||||
from bson.objectid import ObjectId
|
||||
|
||||
|
||||
def check_password_strength(password):
|
||||
"""
|
||||
Check if a password meets minimum security requirements.
|
||||
|
||||
Args:
|
||||
password (str): Password to check
|
||||
|
||||
Returns:
|
||||
bool: True if password is strong enough, False otherwise
|
||||
"""
|
||||
if len(password) < 6:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def hashing(password):
|
||||
"""
|
||||
Hash a password using SHA-512.
|
||||
|
||||
Args:
|
||||
password (str): Password to hash
|
||||
|
||||
Returns:
|
||||
str: Hexadecimal digest of the hashed password
|
||||
"""
|
||||
return hashlib.sha512(password.encode()).hexdigest()
|
||||
|
||||
|
||||
def check_nm_pwd(username, password):
|
||||
"""
|
||||
Verify username and password combination.
|
||||
|
||||
Args:
|
||||
username (str): Username to check
|
||||
password (str): Password to verify
|
||||
|
||||
Returns:
|
||||
dict: User document if credentials are valid, None otherwise
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
hashed_password = hashlib.sha512(password.encode()).hexdigest()
|
||||
user = users.find_one({'Username': username, 'Password': hashed_password})
|
||||
client.close()
|
||||
return user
|
||||
|
||||
|
||||
def add_user(username, password, name, last_name):
|
||||
"""
|
||||
Add a new user to the database.
|
||||
|
||||
Args:
|
||||
username (str): Username for the new user
|
||||
password (str): Password for the new user
|
||||
|
||||
Returns:
|
||||
bool: True if user was added successfully, False if password was too weak
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
if not check_password_strength(password):
|
||||
return False
|
||||
users.insert_one({'Username': username, 'Password': hashing(password), 'Admin': False, 'active_ausleihung': None, 'name': name, 'last_name': last_name})
|
||||
client.close()
|
||||
return True
|
||||
|
||||
|
||||
def make_admin(username):
|
||||
"""
|
||||
Grant administrator privileges to a user.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user to promote
|
||||
|
||||
Returns:
|
||||
bool: True if user was promoted successfully
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
users.update_one({'Username': username}, {'$set': {'Admin': True}})
|
||||
client.close()
|
||||
return True
|
||||
|
||||
def remove_admin(username):
|
||||
"""
|
||||
Remove administrator privileges from a user.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user to demote
|
||||
|
||||
Returns:
|
||||
bool: True if user was demoted successfully
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
users.update_one({'Username': username}, {'$set': {'Admin': False}})
|
||||
client.close()
|
||||
return True
|
||||
|
||||
def get_user(username):
|
||||
"""
|
||||
Retrieve a specific user by username.
|
||||
|
||||
Args:
|
||||
username (str): Username to search for
|
||||
|
||||
Returns:
|
||||
dict: User document or None if not found
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
users_return = users.find_one({'Username': username})
|
||||
client.close()
|
||||
return users_return
|
||||
|
||||
|
||||
def check_admin(username):
|
||||
"""
|
||||
Check if a user has administrator privileges.
|
||||
|
||||
Args:
|
||||
username (str): Username to check
|
||||
|
||||
Returns:
|
||||
bool: True if user is an administrator, False otherwise
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
user = users.find_one({'Username': username})
|
||||
client.close()
|
||||
return user and user.get('Admin', False)
|
||||
|
||||
def delete_user(username):
|
||||
"""
|
||||
Delete a user from the database.
|
||||
Administrative function for removing user accounts.
|
||||
|
||||
Args:
|
||||
username (str): Username of the account to delete
|
||||
|
||||
Returns:
|
||||
bool: True if user was deleted successfully, False otherwise
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
result = users.delete_one({'username': username})
|
||||
client.close()
|
||||
if result.deleted_count == 0:
|
||||
# Try with different field name
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
result = users.delete_one({'Username': username})
|
||||
client.close()
|
||||
|
||||
return result.deleted_count > 0
|
||||
|
||||
def get_name(username):
|
||||
"""
|
||||
Retrieve the name that is assosiated with the username.
|
||||
|
||||
Returns:
|
||||
str: String of name
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
user = users.find_one({'Username': username})
|
||||
name = user.get("name")
|
||||
return name
|
||||
|
||||
def get_last_name(username):
|
||||
"""
|
||||
Retrieve the last_name that is assosiated with the username.
|
||||
|
||||
Returns:
|
||||
str: String of last_name
|
||||
"""
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
user = users.find_one({'Username': username})
|
||||
name = user.get("last_name")
|
||||
return name
|
||||
|
||||
|
||||
def get_all_users():
|
||||
"""
|
||||
Retrieve all users from the database.
|
||||
Administrative function for user management.
|
||||
|
||||
Returns:
|
||||
list: List of all user documents
|
||||
"""
|
||||
try:
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
all_users = list(users.find())
|
||||
client.close()
|
||||
return all_users
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def update_password(username, new_password):
|
||||
"""
|
||||
Update a user's password with a new one.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
new_password (str): New password to set
|
||||
|
||||
Returns:
|
||||
bool: True if password was updated successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not check_password_strength(new_password):
|
||||
return False
|
||||
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
|
||||
# Hash the new password
|
||||
hashed_password = hashing(new_password)
|
||||
|
||||
# Update the user's password
|
||||
result = users.update_one(
|
||||
{'Username': username},
|
||||
{'$set': {'Password': hashed_password}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return result.modified_count > 0
|
||||
except Exception as e:
|
||||
print(f"Error updating password: {e}")
|
||||
return False
|
||||
|
||||
def update_user_name(username, name, last_name):
|
||||
"""
|
||||
Update a user's name and last name.
|
||||
|
||||
Args:
|
||||
username (str): Username of the user
|
||||
name (str): New first name
|
||||
last_name (str): New last name
|
||||
|
||||
Returns:
|
||||
bool: True if updated successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = MongoClient('localhost', 27017)
|
||||
db = client['Inventarsystem']
|
||||
users = db['users']
|
||||
|
||||
result = users.update_one(
|
||||
{'Username': username},
|
||||
{'$set': {'name': name, 'last_name': last_name}}
|
||||
)
|
||||
|
||||
client.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error updating user name: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user