style: Implement tutorial video upload functionality with preview and management features
This commit is contained in:
@@ -43,6 +43,7 @@ def set_security_headers(response):
|
|||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices")
|
INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices")
|
||||||
|
TUTORIAL_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "tutorials")
|
||||||
MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
|
MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
|
||||||
MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website")
|
MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website")
|
||||||
INSTANCE_REPO_URL = os.environ.get("INSTANCE_REPO_URL", "https://github.com/AIIrondev/legendary-octo-garbanzo")
|
INSTANCE_REPO_URL = os.environ.get("INSTANCE_REPO_URL", "https://github.com/AIIrondev/legendary-octo-garbanzo")
|
||||||
@@ -106,6 +107,13 @@ def _is_allowed_image_filename(filename: str) -> bool:
|
|||||||
return lowered.endswith(".jpg") or lowered.endswith(".jpeg") or lowered.endswith(".png") or lowered.endswith(".webp")
|
return lowered.endswith(".jpg") or lowered.endswith(".jpeg") or lowered.endswith(".png") or lowered.endswith(".webp")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed_tutorial_video_filename(filename: str) -> bool:
|
||||||
|
if not filename:
|
||||||
|
return False
|
||||||
|
lowered = filename.lower()
|
||||||
|
return lowered.endswith(".mp4") or lowered.endswith(".webm") or lowered.endswith(".ogv") or lowered.endswith(".ogg") or lowered.endswith(".m4v")
|
||||||
|
|
||||||
|
|
||||||
def _save_invoice_pdf(file_obj, invoice_number: str) -> str | None:
|
def _save_invoice_pdf(file_obj, invoice_number: str) -> str | None:
|
||||||
if not file_obj or not file_obj.filename:
|
if not file_obj or not file_obj.filename:
|
||||||
return None
|
return None
|
||||||
@@ -119,6 +127,20 @@ def _save_invoice_pdf(file_obj, invoice_number: str) -> str | None:
|
|||||||
file_obj.save(target)
|
file_obj.save(target)
|
||||||
return f"uploads/invoices/{unique_name}"
|
return f"uploads/invoices/{unique_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_tutorial_video_file(file_obj, title: str) -> str | None:
|
||||||
|
if not file_obj or not file_obj.filename:
|
||||||
|
return None
|
||||||
|
original_name = secure_filename(file_obj.filename)
|
||||||
|
if not _is_allowed_tutorial_video_filename(original_name):
|
||||||
|
return None
|
||||||
|
os.makedirs(TUTORIAL_UPLOAD_DIR, exist_ok=True)
|
||||||
|
safe_title = secure_filename(title or "tutorial")
|
||||||
|
unique_name = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}_{safe_title}_{original_name}"
|
||||||
|
target = os.path.join(TUTORIAL_UPLOAD_DIR, unique_name)
|
||||||
|
file_obj.save(target)
|
||||||
|
return f"/static/uploads/tutorials/{unique_name}"
|
||||||
|
|
||||||
def _get_mongo_client() -> MongoClient:
|
def _get_mongo_client() -> MongoClient:
|
||||||
global _MONGO_CLIENT
|
global _MONGO_CLIENT
|
||||||
if _MONGO_CLIENT is not None:
|
if _MONGO_CLIENT is not None:
|
||||||
@@ -2564,6 +2586,65 @@ def append_video():
|
|||||||
return redirect(url_for("admin_blog"))
|
return redirect(url_for("admin_blog"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/tutorials/upload', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def admin_tutorial_upload():
|
||||||
|
if request.method == 'POST':
|
||||||
|
title = _sanitize_text(request.form.get("title") or "", 200)
|
||||||
|
description = _sanitize_text(request.form.get("description") or "", 500)
|
||||||
|
duration = _sanitize_text(request.form.get("duration") or "", 20)
|
||||||
|
category = _sanitize_text(request.form.get("category") or "", 50)
|
||||||
|
thumbnail_url = _sanitize_text(request.form.get("thumbnail_url") or "", 300)
|
||||||
|
uploaded_file = request.files.get("video_file")
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
flash("Bitte einen Titel fuer das Tutorial eingeben.", "error")
|
||||||
|
return redirect(url_for("admin_tutorial_upload"))
|
||||||
|
|
||||||
|
saved_video_path = _save_tutorial_video_file(uploaded_file, title)
|
||||||
|
if not saved_video_path:
|
||||||
|
flash("Bitte eine gueltige Videodatei hochladen (.mp4, .webm, .ogv, .ogg, .m4v).", "error")
|
||||||
|
return redirect(url_for("admin_tutorial_upload"))
|
||||||
|
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client, col = _get_collection("tutorials")
|
||||||
|
col.insert_one(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"video_url": saved_video_path,
|
||||||
|
"thumbnail_url": thumbnail_url,
|
||||||
|
"duration": duration,
|
||||||
|
"category": category,
|
||||||
|
"published": True,
|
||||||
|
"created_at": datetime.utcnow().isoformat(timespec="seconds") + "Z",
|
||||||
|
"source": "upload",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
flash("Tutorial-Video wurde hochgeladen.", "success")
|
||||||
|
except PyMongoError:
|
||||||
|
flash("Tutorial-Video konnte nicht gespeichert werden.", "error")
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
return redirect(url_for("admin_tutorial_upload"))
|
||||||
|
|
||||||
|
tutorials = []
|
||||||
|
client = None
|
||||||
|
try:
|
||||||
|
client, col = _get_collection("tutorials")
|
||||||
|
tutorials = list(col.find({"source": "upload"}, {"_id": 1, "title": 1, "description": 1, "video_url": 1, "thumbnail_url": 1, "duration": 1, "category": 1, "created_at": 1}).sort("created_at", -1))
|
||||||
|
except PyMongoError:
|
||||||
|
flash("Upload-Uebersicht konnte nicht geladen werden.", "error")
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
return render_template("admin_tutorial_upload.html", tutorials=tutorials)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/chat', methods=['GET', 'POST'])
|
@app.route('/chat', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def user_chat():
|
def user_chat():
|
||||||
|
|||||||
@@ -216,7 +216,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 style="font-size: 2rem; color: #0f354d; margin-bottom: 1.2rem;">Blog verwalten</h1>
|
<div style="display:flex; gap:0.8rem; align-items:center; justify-content:space-between; flex-wrap:wrap; margin-bottom: 1.2rem;">
|
||||||
|
<h1 style="font-size: 2rem; color: #0f354d; margin: 0;">Blog verwalten</h1>
|
||||||
|
<a href="{{ url_for('admin_tutorial_upload') }}" style="display:inline-flex; align-items:center; gap:0.45rem; padding:0.68rem 1rem; border-radius:999px; border:1px solid #0a4c74; color:#0a4c74; font-weight:700; background:#fff; text-decoration:none;">Tutorial-Upload öffnen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="blog-admin-grid">
|
<section class="blog-admin-grid">
|
||||||
<aside class="new-post-form">
|
<aside class="new-post-form">
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Admin | Tutorial-Upload{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
<style>
|
||||||
|
.upload-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.9fr 1.1fr;
|
||||||
|
gap: 1.2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d7e1ea;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 14px 34px rgba(17, 43, 60, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form {
|
||||||
|
padding: 1.2rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form h1,
|
||||||
|
.list-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f354d;
|
||||||
|
font-family: "Merriweather", Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form p,
|
||||||
|
.list-panel p {
|
||||||
|
color: #587286;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f4b65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #c8d7e3;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
|
font: inherit;
|
||||||
|
color: #17384f;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #527085;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(120deg, #0a5f8f 0%, #08486c 100%);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-box {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d7e1ea;
|
||||||
|
background: linear-gradient(135deg, #f4f8fb 0%, #eaf1f6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-aspect {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 56.25%;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-aspect video,
|
||||||
|
.preview-aspect img,
|
||||||
|
.preview-aspect iframe {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #5c7485;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-list {
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-list .entry {
|
||||||
|
border: 1px solid #d7e1ea;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-list .entry h3 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #113d59;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: #637b8c;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
color: #0a4c74;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.24rem 0.55rem;
|
||||||
|
background: #e7f1f5;
|
||||||
|
color: #1f5c73;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.upload-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-form {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel upload-form">
|
||||||
|
<h1>Tutorial-Upload</h1>
|
||||||
|
<p>Videodatei hochladen und sofort als Tutorial im Nutzerbereich bereitstellen.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('admin_tutorial_upload') }}" enctype="multipart/form-data">
|
||||||
|
<div class="field">
|
||||||
|
<label for="title">Titel</label>
|
||||||
|
<input id="title" name="title" type="text" placeholder="z. B. Erste Schritte mit Invario" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="video_file">Videodatei</label>
|
||||||
|
<input id="video_file" name="video_file" type="file" accept="video/mp4,video/webm,video/ogg,.mp4,.webm,.ogv,.ogg,.m4v" required>
|
||||||
|
<div class="hint">Erlaubte Formate: .mp4, .webm, .ogv, .ogg, .m4v</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="description">Beschreibung</label>
|
||||||
|
<textarea id="description" name="description" placeholder="Kurze Beschreibung des Tutorials"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="thumbnail_url">Thumbnail-URL (optional)</label>
|
||||||
|
<input id="thumbnail_url" name="thumbnail_url" type="url" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="duration">Dauer (optional)</label>
|
||||||
|
<input id="duration" name="duration" type="text" placeholder="z. B. 12:34">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="category">Kategorie (optional)</label>
|
||||||
|
<input id="category" name="category" type="text" placeholder="z. B. Anfänger">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="submit-btn">Video hochladen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="preview-box" aria-live="polite">
|
||||||
|
<div id="preview-empty" class="preview-empty">Vorschau erscheint nach Auswahl einer Videodatei.</div>
|
||||||
|
<div class="preview-aspect" id="preview-wrap" hidden>
|
||||||
|
<video id="preview-video" controls playsinline></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel upload-list">
|
||||||
|
<h2>Aktuelle Uploads</h2>
|
||||||
|
<p>Neu hochgeladene Tutorials erscheinen hier und im Nutzerbereich.</p>
|
||||||
|
|
||||||
|
{% if tutorials %}
|
||||||
|
{% for tutorial in tutorials %}
|
||||||
|
<article class="entry">
|
||||||
|
<h3>{{ tutorial.title }} <span class="badge">Upload</span></h3>
|
||||||
|
<div class="meta">
|
||||||
|
{{ tutorial.category or 'Allgemein' }} | {{ tutorial.duration or 'ohne Dauer' }} | {{ tutorial.created_at[:10] }}
|
||||||
|
</div>
|
||||||
|
<p>{{ tutorial.description }}</p>
|
||||||
|
<a class="video-link" href="{{ tutorial.video_url }}" target="_blank" rel="noopener">Video öffnen</a>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<article class="entry"><p>Noch keine hochgeladenen Tutorials vorhanden.</p></article>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const input = document.getElementById('video_file');
|
||||||
|
const previewWrap = document.getElementById('preview-wrap');
|
||||||
|
const previewEmpty = document.getElementById('preview-empty');
|
||||||
|
const previewVideo = document.getElementById('preview-video');
|
||||||
|
let currentObjectUrl = null;
|
||||||
|
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if (currentObjectUrl) {
|
||||||
|
URL.revokeObjectURL(currentObjectUrl);
|
||||||
|
currentObjectUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
previewWrap.hidden = true;
|
||||||
|
previewEmpty.hidden = false;
|
||||||
|
previewVideo.removeAttribute('src');
|
||||||
|
previewVideo.load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentObjectUrl = URL.createObjectURL(file);
|
||||||
|
previewVideo.src = currentObjectUrl;
|
||||||
|
previewVideo.load();
|
||||||
|
previewEmpty.hidden = true;
|
||||||
|
previewWrap.hidden = false;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -523,11 +523,12 @@
|
|||||||
{% if session.get('is_admin') %}
|
{% if session.get('is_admin') %}
|
||||||
<a class="nav-user-link" href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a>
|
<a class="nav-user-link" href="{{ url_for('admin_invoices') }}">Rechnungsverwaltung</a>
|
||||||
<a class="nav-user-link" href="{{ url_for('admin_blog') }}">Blog verwalten</a>
|
<a class="nav-user-link" href="{{ url_for('admin_blog') }}">Blog verwalten</a>
|
||||||
|
<a class="nav-user-link" href="{{ url_for('admin_tutorial_upload') }}">Tutorial-Upload</a>
|
||||||
<a class="nav-user-link" href="{{ url_for('admin_chats') }}">Admin-Chat</a>
|
<a class="nav-user-link" href="{{ url_for('admin_chats') }}">Admin-Chat</a>
|
||||||
<a class="nav-user-link" href="{{ url_for('admin_tickets') }}">Support-Center</a>
|
<a class="nav-user-link" href="{{ url_for('admin_tickets') }}">Support-Center</a>
|
||||||
<a class="nav-user-link" href="{{ url_for('admin_system_tools') }}">System-Tools</a>
|
<a class="nav-user-link" href="{{ url_for('admin_system_tools') }}">System-Tools</a>
|
||||||
{% elif 'username' in session %}
|
{% elif 'username' in session %}
|
||||||
<a class="nav-user-link" href="{{ url_for('my_tutorials') }}">📚 Tutorials</a>
|
<a class="nav-user-link" href="{{ url_for('my_tutorials') }}">Tutorials</a>
|
||||||
<a class="nav-user-link" href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
|
<a class="nav-user-link" href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
|
||||||
<a class="nav-user-link" href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
|
<a class="nav-user-link" href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
|
||||||
<a class="nav-user-link" href="{{ url_for('user_chat') }}">Chat mit Admin</a>
|
<a class="nav-user-link" href="{{ url_for('user_chat') }}">Chat mit Admin</a>
|
||||||
@@ -550,6 +551,7 @@
|
|||||||
<a href="{{ url_for('admin_tickets') }}">Support-Center</a>
|
<a href="{{ url_for('admin_tickets') }}">Support-Center</a>
|
||||||
<a href="{{ url_for('admin_system_tools') }}">System-Tools</a>
|
<a href="{{ url_for('admin_system_tools') }}">System-Tools</a>
|
||||||
<a href="{{ url_for('admin_blog') }}">Blog verwalten</a>
|
<a href="{{ url_for('admin_blog') }}">Blog verwalten</a>
|
||||||
|
<a href="{{ url_for('admin_tutorial_upload') }}">Tutorial-Upload</a>
|
||||||
<a href="{{ url_for('admin_instances') }}">Schul-Instanzen</a>
|
<a href="{{ url_for('admin_instances') }}">Schul-Instanzen</a>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -558,7 +560,7 @@
|
|||||||
<details class="nav-dropdown">
|
<details class="nav-dropdown">
|
||||||
<summary class="nav-btn">Nutzerbereich</summary>
|
<summary class="nav-btn">Nutzerbereich</summary>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<a href="{{ url_for('my_tutorials') }}">📚 Tutorials</a>
|
<a href="{{ url_for('my_tutorials') }}">Tutorials</a>
|
||||||
<a href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
|
<a href="{{ url_for('my_invoices') }}">Meine Rechnungen</a>
|
||||||
<a href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
|
<a href="{{ url_for('my_instance_management') }}">Meine Instanz</a>
|
||||||
<a href="{{ url_for('user_chat') }}">Chat mit Admin</a>
|
<a href="{{ url_for('user_chat') }}">Chat mit Admin</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user