add calendar
This commit is contained in:
parent
f0e7e9c165
commit
1f38e3acd2
3
.gitignore
vendored
3
.gitignore
vendored
@ -18,4 +18,5 @@
|
||||
/static/theme.css
|
||||
/transcription_config.yml
|
||||
|
||||
/messages.json
|
||||
/messages.json
|
||||
/calendar.db
|
||||
282
app.py
282
app.py
@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
# Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader
|
||||
FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
|
||||
@ -25,6 +26,7 @@ import re
|
||||
import qrcode
|
||||
import base64
|
||||
import json
|
||||
import io
|
||||
import search
|
||||
import auth
|
||||
import analytics as a
|
||||
@ -32,6 +34,7 @@ import folder_secret_config_editor as fsce
|
||||
import helperfunctions as hf
|
||||
import search_db_analyzer as sdb
|
||||
import fnmatch
|
||||
import openpyxl
|
||||
|
||||
app_config = auth.return_app_config()
|
||||
BASE_DIR = os.path.realpath(app_config['BASE_DIR'])
|
||||
@ -86,6 +89,7 @@ app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_adm
|
||||
|
||||
# Messages/News routes
|
||||
MESSAGES_FILENAME = 'messages.json'
|
||||
CALENDAR_DB = 'calendar.db'
|
||||
|
||||
def load_messages():
|
||||
"""Load messages from JSON file."""
|
||||
@ -100,6 +104,45 @@ def save_messages(messages):
|
||||
with open(MESSAGES_FILENAME, 'w', encoding='utf-8') as f:
|
||||
json.dump(messages, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def init_calendar_db():
|
||||
"""Ensure calendar table exists."""
|
||||
conn = sqlite3.connect(CALENDAR_DB)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS calendar_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
time TEXT,
|
||||
title TEXT NOT NULL,
|
||||
location TEXT,
|
||||
details TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Ensure details column exists for legacy DBs
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(calendar_entries)").fetchall()]
|
||||
if 'details' not in cols:
|
||||
conn.execute("ALTER TABLE calendar_entries ADD COLUMN details TEXT")
|
||||
conn.commit()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def serialize_calendar_row(row):
|
||||
return {
|
||||
'id': row['id'],
|
||||
'date': row['date'],
|
||||
'time': row['time'] or '',
|
||||
'title': row['title'],
|
||||
'location': row['location'] or '',
|
||||
'details': row['details'] or ''
|
||||
}
|
||||
|
||||
|
||||
init_calendar_db()
|
||||
|
||||
@app.route('/api/messages', methods=['GET'])
|
||||
@auth.require_secret
|
||||
def get_messages():
|
||||
@ -158,6 +201,245 @@ def delete_message(message_id):
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/calendar/<int:entry_id>', methods=['PUT'])
|
||||
@auth.require_admin
|
||||
def update_calendar_entry(entry_id):
|
||||
"""Update a calendar entry."""
|
||||
data = request.get_json() or {}
|
||||
entry_date = data.get('date')
|
||||
title = data.get('title')
|
||||
if not entry_date or not title:
|
||||
return jsonify({'error': 'date and title are required'}), 400
|
||||
|
||||
time_value = data.get('time') or ''
|
||||
location = data.get('location') or ''
|
||||
details = data.get('details') or ''
|
||||
|
||||
conn = sqlite3.connect(CALENDAR_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE calendar_entries
|
||||
SET date = ?, time = ?, title = ?, location = ?, details = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(entry_date, time_value, title, location, details, entry_id)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
conn.close()
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT id, date, time, title, location, details FROM calendar_entries WHERE id = ?",
|
||||
(entry_id,)
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(serialize_calendar_row(row))
|
||||
|
||||
|
||||
@app.route('/api/calendar/<int:entry_id>', methods=['DELETE'])
|
||||
@auth.require_admin
|
||||
def delete_calendar_entry(entry_id):
|
||||
"""Delete a calendar entry."""
|
||||
conn = sqlite3.connect(CALENDAR_DB)
|
||||
cur = conn.execute("DELETE FROM calendar_entries WHERE id = ?", (entry_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if cur.rowcount == 0:
|
||||
return jsonify({'error': 'not found'}), 404
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/calendar/export', methods=['GET'])
|
||||
@auth.require_admin
|
||||
def export_calendar():
|
||||
"""Export all calendar entries to an Excel file."""
|
||||
conn = sqlite3.connect(CALENDAR_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT date, time, title, location, details
|
||||
FROM calendar_entries
|
||||
ORDER BY date ASC, time ASC
|
||||
"""
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Kalender"
|
||||
headers = ["date", "time", "title", "location", "details"]
|
||||
ws.append(headers)
|
||||
|
||||
def fmt_date(date_str):
|
||||
try:
|
||||
return datetime.strptime(date_str, '%Y-%m-%d').strftime('%d.%m.%Y')
|
||||
except Exception:
|
||||
return date_str
|
||||
|
||||
for row in rows:
|
||||
ws.append([
|
||||
fmt_date(row['date']),
|
||||
row['time'] or '',
|
||||
row['title'],
|
||||
row['location'] or '',
|
||||
row['details'] or ''
|
||||
])
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
response = make_response(output.getvalue())
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=kalender.xlsx'
|
||||
response.headers['Content-Type'] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
return response
|
||||
|
||||
|
||||
@app.route('/api/calendar/import', methods=['POST'])
|
||||
@auth.require_admin
|
||||
def import_calendar():
|
||||
"""Import calendar entries from an uploaded Excel file, replacing existing entries."""
|
||||
if 'file' not in request.files or request.files['file'].filename == '':
|
||||
return jsonify({'error': 'No file uploaded'}), 400
|
||||
|
||||
file = request.files['file']
|
||||
try:
|
||||
wb = openpyxl.load_workbook(file, data_only=True)
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Invalid Excel file: {e}'}), 400
|
||||
|
||||
ws = wb.active
|
||||
headers = [str(cell.value).strip().lower() if cell.value else '' for cell in next(ws.iter_rows(min_row=1, max_row=1, values_only=False))]
|
||||
required = {'date', 'title'}
|
||||
optional = {'time', 'location', 'details'}
|
||||
all_allowed = required | optional
|
||||
|
||||
header_map = {}
|
||||
for idx, h in enumerate(headers):
|
||||
if h in all_allowed and h not in header_map:
|
||||
header_map[h] = idx
|
||||
|
||||
if not required.issubset(set(header_map.keys())):
|
||||
return jsonify({'error': 'Header must include at least date and title columns'}), 400
|
||||
|
||||
entries = []
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
entry = {}
|
||||
for key, col_idx in header_map.items():
|
||||
value = row[col_idx] if col_idx < len(row) else ''
|
||||
entry[key] = value if value is not None else ''
|
||||
# Normalize
|
||||
entry_date_raw = entry.get('date', '')
|
||||
entry_date = str(entry_date_raw).strip()
|
||||
if not entry_date:
|
||||
continue
|
||||
try:
|
||||
# Allow Excel date cells
|
||||
if isinstance(entry_date_raw, (datetime, date)):
|
||||
entry_date = entry_date_raw.strftime('%Y-%m-%d')
|
||||
elif '.' in entry_date:
|
||||
entry_date = datetime.strptime(entry_date, '%d.%m.%Y').strftime('%Y-%m-%d')
|
||||
else:
|
||||
# Expect yyyy-mm-dd
|
||||
datetime.strptime(entry_date, '%Y-%m-%d')
|
||||
except Exception:
|
||||
return jsonify({'error': f'Invalid date format: {entry_date}'}), 400
|
||||
|
||||
entries.append({
|
||||
'date': entry_date,
|
||||
'time': str(entry.get('time', '') or '').strip(),
|
||||
'title': str(entry.get('title', '') or '').strip(),
|
||||
'location': str(entry.get('location', '') or '').strip(),
|
||||
'details': str(entry.get('details', '') or '').strip()
|
||||
})
|
||||
|
||||
conn = sqlite3.connect(CALENDAR_DB)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM calendar_entries")
|
||||
cur.executemany(
|
||||
"""
|
||||
INSERT INTO calendar_entries (date, time, title, location, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
e['date'],
|
||||
e['time'],
|
||||
e['title'],
|
||||
e['location'],
|
||||
e['details'],
|
||||
datetime.utcnow().isoformat()
|
||||
) for e in entries
|
||||
]
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return jsonify({'success': True, 'count': len(entries)})
|
||||
|
||||
@app.route('/api/calendar', methods=['GET'])
|
||||
@auth.require_secret
|
||||
def get_calendar_entries():
|
||||
"""Return calendar entries between start and end date (inclusive)."""
|
||||
start = request.args.get('start') or date.today().isoformat()
|
||||
end = request.args.get('end') or (date.today() + timedelta(weeks=4)).isoformat()
|
||||
|
||||
conn = sqlite3.connect(CALENDAR_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, date, time, title, location, details
|
||||
FROM calendar_entries
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date ASC, time ASC
|
||||
""",
|
||||
(start, end)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
return jsonify([serialize_calendar_row(r) for r in rows])
|
||||
|
||||
|
||||
@app.route('/api/calendar', methods=['POST'])
|
||||
@auth.require_admin
|
||||
def create_calendar_entry():
|
||||
"""Store a new calendar entry."""
|
||||
data = request.get_json() or {}
|
||||
entry_date = data.get('date')
|
||||
title = data.get('title')
|
||||
if not entry_date or not title:
|
||||
return jsonify({'error': 'date and title are required'}), 400
|
||||
|
||||
time_value = data.get('time') or ''
|
||||
location = data.get('location') or ''
|
||||
details = data.get('details') or ''
|
||||
|
||||
conn = sqlite3.connect(CALENDAR_DB)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO calendar_entries (date, time, title, location, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(entry_date, time_value, title, location, details, datetime.utcnow().isoformat())
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'id': new_id,
|
||||
'date': entry_date,
|
||||
'time': time_value,
|
||||
'title': title,
|
||||
'location': location,
|
||||
'details': details
|
||||
}), 201
|
||||
|
||||
# Grab the HOST_RULE environment variable
|
||||
host_rule = os.getenv("HOST_RULE", "")
|
||||
# Use a regex to extract domain names between backticks in patterns like Host(`something`)
|
||||
|
||||
161
static/app.css
161
static/app.css
@ -431,6 +431,12 @@ footer {
|
||||
}
|
||||
|
||||
/* Messages Section Styles */
|
||||
.messages-section-header {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
@ -635,6 +641,159 @@ footer {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* Calendar Section */
|
||||
#calendar-section .container {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
border: 1px solid #ddd;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 6px 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-weekday-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
background: var(--card-background, #fff);
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
|
||||
.calendar-day-today {
|
||||
border: 2px solid #4da3ff;
|
||||
padding: 6px 8px 2px;
|
||||
}
|
||||
|
||||
.calendar-day:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.calendar-grid {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.calendar-weekday-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
border: 1px solid #ddd;
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-right: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.calendar-day:nth-child(7n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar-day-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-day-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-day-name {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.calendar-day-date {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-day table {
|
||||
margin-bottom: 0;
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-day thead th {
|
||||
background: #f1f1f1;
|
||||
color: #666;
|
||||
border-color: #ddd;
|
||||
}
|
||||
|
||||
.calendar-day.empty thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-entries-list td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.calendar-table-wrapper {
|
||||
box-sizing: border-box;
|
||||
margin-left: -12px;
|
||||
margin-right: -12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-day .btn-group .btn {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.details-indicator {
|
||||
font-weight: 700;
|
||||
color: #666;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.calendar-actions-col {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-details-text {
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-details-row td {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.calendar-inline-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* File Browser Styles */
|
||||
.file-browser-content {
|
||||
max-height: 400px;
|
||||
@ -674,4 +833,4 @@ footer {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,7 @@
|
||||
<nav class="main-tabs">
|
||||
<button class="tab-button active" data-tab="browse">Audio/Photo</button>
|
||||
<button class="tab-button" data-tab="messages">Nachrichten</button>
|
||||
<button class="tab-button" data-tab="calendar">Kalender</button>
|
||||
</nav>
|
||||
|
||||
<!-- Browse Section -->
|
||||
@ -85,6 +86,7 @@
|
||||
</main>
|
||||
|
||||
{% include 'messages_section.html' %}
|
||||
{% include 'calendar_section.html' %}
|
||||
|
||||
<search style="display: none;">
|
||||
<div class="container">
|
||||
@ -253,59 +255,6 @@
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<!-- Message Editor Modal -->
|
||||
<div class="modal fade" id="messageModal" tabindex="-1" aria-labelledby="messageModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="messageModalLabel">Nachricht bearbeiten</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="messageForm">
|
||||
<input type="hidden" id="messageId">
|
||||
<div class="mb-3">
|
||||
<label for="messageTitle" class="form-label">Titel</label>
|
||||
<input type="text" class="form-control" id="messageTitle" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="messageDateTime" class="form-label">Datum und Zeit</label>
|
||||
<input type="datetime-local" class="form-control" id="messageDateTime" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="messageContent" class="form-label">Inhalt (Markdown unterstützt)</label>
|
||||
<div class="mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="insertLinkBtn">
|
||||
<i class="bi bi-link-45deg"></i> Datei/Ordner verlinken
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="fileBrowserCollapse">
|
||||
<div class="file-browser-panel">
|
||||
<h6>Datei/Ordner auswählen</h6>
|
||||
<div id="fileBrowserPath" class="mb-2">
|
||||
<small class="text-muted">Pfad: <span id="currentPath">/</span></small>
|
||||
</div>
|
||||
<div id="fileBrowserContent" class="file-browser-content">
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">Laden...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="form-control" id="messageContent" rows="10" required></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" id="saveMessageBtn">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Load main app JS first, then gallery JS -->
|
||||
|
||||
605
templates/calendar_section.html
Normal file
605
templates/calendar_section.html
Normal file
@ -0,0 +1,605 @@
|
||||
<!-- Calendar Section -->
|
||||
<section id="calendar-section" class="tab-content">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h3 class="mb-0">Kalender</h3>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="calendar-location-filter" class="form-select form-select-sm">
|
||||
<option value="">Ort (alle)</option>
|
||||
</select>
|
||||
{% if admin_enabled %}
|
||||
<div class="btn-group" role="group">
|
||||
<button id="export-calendar-btn" class="btn btn-outline-secondary btn-sm" title="Kalender als Excel herunterladen">
|
||||
Export
|
||||
</button>
|
||||
<button id="import-calendar-btn" class="btn btn-outline-secondary btn-sm" title="Excel hochladen und Kalender überschreiben">
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
<button id="add-calendar-entry-btn" class="btn btn-primary" title="Eintrag hinzufügen">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
</button>
|
||||
<input type="file" id="import-calendar-input" accept=".xlsx" style="display:none">
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-weekday-header">
|
||||
<div>Mo</div>
|
||||
<div>Di</div>
|
||||
<div>Mi</div>
|
||||
<div>Do</div>
|
||||
<div>Fr</div>
|
||||
<div>Sa</div>
|
||||
<div>So</div>
|
||||
</div>
|
||||
|
||||
<div id="calendar-days" class="calendar-grid">
|
||||
<!-- Populated via inline script below -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Calendar Modal -->
|
||||
<div class="modal fade" id="calendarModal" tabindex="-1" aria-labelledby="calendarModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="calendarModalLabel">Kalendereintrag</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="calendarForm">
|
||||
<input type="hidden" id="calendarEntryId">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="calendarDate" class="form-label">Datum</label>
|
||||
<input type="date" class="form-control" id="calendarDate" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="calendarTime" class="form-label">Uhrzeit</label>
|
||||
<input type="time" class="form-control" id="calendarTime">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="calendarTitle" class="form-label">Titel</label>
|
||||
<input type="text" class="form-control" id="calendarTitle" required>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="calendarLocation" class="form-label">Ort</label>
|
||||
<input type="text" class="form-control" id="calendarLocation">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="calendarDetails" class="form-label">Details</label>
|
||||
<textarea class="form-control" id="calendarDetails" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" id="saveCalendarEntryBtn">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const toLocalISO = (d) => {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
function getCalendarRange() {
|
||||
const weeks = window._calendarIsAdmin ? 12 : 4;
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
// shift back to Monday (0 = Sunday)
|
||||
const diffToMonday = (start.getDay() + 6) % 7;
|
||||
start.setDate(start.getDate() - diffToMonday);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + (weeks * 7) - 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
(function renderInitialCalendarRange() {
|
||||
const daysContainer = document.getElementById('calendar-days');
|
||||
if (!daysContainer || daysContainer.childElementCount > 0) return;
|
||||
|
||||
const isAdmin = typeof admin_enabled !== 'undefined' && admin_enabled;
|
||||
const calendarColSpan = 3;
|
||||
window._calendarColSpan = calendarColSpan;
|
||||
window._calendarIsAdmin = isAdmin;
|
||||
|
||||
const formatDayName = new Intl.DateTimeFormat('de-DE', { weekday: 'long' });
|
||||
const formatDate = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const { start, end } = getCalendarRange();
|
||||
const dayCount = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
for (let i = 0; i < dayCount; i++) {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + i);
|
||||
const isoDate = toLocalISO(date);
|
||||
|
||||
const dayBlock = document.createElement('div');
|
||||
dayBlock.className = 'calendar-day empty';
|
||||
dayBlock.dataset.date = isoDate;
|
||||
if (isoDate === toLocalISO(new Date())) {
|
||||
dayBlock.classList.add('calendar-day-today');
|
||||
}
|
||||
|
||||
dayBlock.innerHTML = `
|
||||
<div class="calendar-day-header">
|
||||
<div class="calendar-day-name">${formatDayName.format(date)}</div>
|
||||
<div class="calendar-day-date">${formatDate.format(date)}</div>
|
||||
</div>
|
||||
<div class="table-responsive calendar-table-wrapper">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Zeit</th>
|
||||
<th scope="col">Titel</th>
|
||||
<th scope="col">Ort</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="calendar-entries-list">
|
||||
<tr class="calendar-empty-row">
|
||||
<td colspan="${calendarColSpan}" class="text-center text-muted">Keine Einträge</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
daysContainer.appendChild(dayBlock);
|
||||
}
|
||||
})();
|
||||
|
||||
function resetDayBlock(block) {
|
||||
const tbody = block.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
block.classList.add('empty');
|
||||
tbody.innerHTML = `
|
||||
<tr class="calendar-empty-row">
|
||||
<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function clearCalendarEntries() {
|
||||
document.querySelectorAll('.calendar-day').forEach(resetDayBlock);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return (str || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function createEmptyRow() {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'calendar-empty-row';
|
||||
row.innerHTML = `<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function formatDisplayTime(timeStr) {
|
||||
if (!timeStr) return '';
|
||||
const match = String(timeStr).match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
|
||||
if (match) {
|
||||
const h = match[1].padStart(2, '0');
|
||||
const m = match[2];
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
// Location filter helpers
|
||||
let locationOptions = new Set();
|
||||
|
||||
function resetLocationFilterOptions() {
|
||||
locationOptions = new Set();
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Ort (alle)</option>';
|
||||
}
|
||||
|
||||
function addLocationOption(location) {
|
||||
if (!location) return;
|
||||
const normalized = location.trim();
|
||||
if (!normalized || locationOptions.has(normalized)) return;
|
||||
locationOptions.add(normalized);
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
if (!select) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = normalized;
|
||||
opt.textContent = normalized;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
function applyLocationFilter() {
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
const filterValue = select ? select.value : '';
|
||||
|
||||
document.querySelectorAll('.calendar-day').forEach(day => {
|
||||
const tbody = day.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
|
||||
// Remove any stale empty rows before recalculating
|
||||
tbody.querySelectorAll('.calendar-empty-row').forEach(r => r.remove());
|
||||
|
||||
let visibleCount = 0;
|
||||
const entryRows = tbody.querySelectorAll('tr.calendar-entry-row');
|
||||
entryRows.forEach(row => {
|
||||
const rowLoc = (row.dataset.location || '').trim();
|
||||
const match = !filterValue || rowLoc === filterValue;
|
||||
row.style.display = match ? '' : 'none';
|
||||
|
||||
const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row')
|
||||
? row.nextElementSibling
|
||||
: null;
|
||||
if (detailsRow) {
|
||||
const showDetails = match && detailsRow.dataset.expanded === 'true';
|
||||
detailsRow.style.display = showDetails ? '' : 'none';
|
||||
}
|
||||
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
|
||||
if (visibleCount === 0) {
|
||||
day.classList.add('empty');
|
||||
tbody.appendChild(createEmptyRow());
|
||||
} else {
|
||||
day.classList.remove('empty');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeCalendarEntryFromUI(id) {
|
||||
if (!id) return;
|
||||
const selector = `.calendar-entry-row[data-id="${id}"], .calendar-details-row[data-parent-id="${id}"]`;
|
||||
const rows = document.querySelectorAll(selector);
|
||||
rows.forEach(row => {
|
||||
const tbody = row.parentElement;
|
||||
const dayBlock = row.closest('.calendar-day');
|
||||
row.remove();
|
||||
const remainingEntries = tbody ? tbody.querySelectorAll('tr.calendar-entry-row').length : 0;
|
||||
if (remainingEntries === 0 && dayBlock) {
|
||||
resetDayBlock(dayBlock);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to insert entries sorted by time inside an existing day block
|
||||
function addCalendarEntry(entry) {
|
||||
if (!entry || !entry.date) return;
|
||||
const block = document.querySelector(`.calendar-day[data-date="${entry.date}"]`);
|
||||
if (!block) return;
|
||||
|
||||
const tbody = block.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
|
||||
const emptyRow = tbody.querySelector('.calendar-empty-row');
|
||||
if (emptyRow) emptyRow.remove();
|
||||
block.classList.remove('empty');
|
||||
|
||||
const row = document.createElement('tr');
|
||||
const timeValue = formatDisplayTime(entry.time || '');
|
||||
row.dataset.time = timeValue;
|
||||
row.dataset.id = entry.id || '';
|
||||
row.dataset.date = entry.date;
|
||||
row.dataset.title = entry.title || '';
|
||||
row.dataset.location = entry.location || '';
|
||||
row.dataset.details = entry.details || '';
|
||||
row.className = 'calendar-entry-row';
|
||||
row.innerHTML = `
|
||||
<td>${timeValue || '-'}</td>
|
||||
<td>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
|
||||
<td>${entry.location || ''}</td>
|
||||
`;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));
|
||||
const insertIndex = rows.findIndex(r => {
|
||||
const existingTime = r.dataset.time || '';
|
||||
return timeValue && (!existingTime || timeValue < existingTime);
|
||||
});
|
||||
|
||||
if (insertIndex === -1) {
|
||||
tbody.appendChild(row);
|
||||
} else {
|
||||
tbody.insertBefore(row, rows[insertIndex]);
|
||||
}
|
||||
const detailsRow = document.createElement('tr');
|
||||
detailsRow.className = 'calendar-details-row';
|
||||
detailsRow.dataset.parentId = entry.id || '';
|
||||
detailsRow.dataset.expanded = 'false';
|
||||
const actionsHtml = window._calendarIsAdmin ? `
|
||||
<div class="calendar-inline-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary calendar-edit" title="Bearbeiten">✎</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger calendar-delete" title="Löschen">🗑</button>
|
||||
</div>
|
||||
` : '';
|
||||
const detailsText = entry.details ? escapeHtml(entry.details) : '';
|
||||
detailsRow.innerHTML = `<td colspan="${window._calendarColSpan || 3}"><div class="calendar-details-text">${detailsText}</div>${actionsHtml}</td>`;
|
||||
detailsRow.style.display = 'none';
|
||||
row.insertAdjacentElement('afterend', detailsRow);
|
||||
applyLocationFilter();
|
||||
}
|
||||
|
||||
async function loadCalendarEntries() {
|
||||
const { start, end } = getCalendarRange();
|
||||
const todayIso = toLocalISO(start);
|
||||
const endIso = toLocalISO(end);
|
||||
|
||||
clearCalendarEntries();
|
||||
resetLocationFilterOptions();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`);
|
||||
if (!response.ok) throw new Error('Fehler beim Laden');
|
||||
const entries = await response.json();
|
||||
entries.forEach(entry => {
|
||||
addLocationOption(entry.location);
|
||||
addCalendarEntry(entry);
|
||||
});
|
||||
applyLocationFilter();
|
||||
} catch (err) {
|
||||
console.error('Kalender laden fehlgeschlagen', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendarEntry(entry) {
|
||||
const isUpdate = Boolean(entry.id);
|
||||
const url = isUpdate ? `/api/calendar/${entry.id}` : '/api/calendar';
|
||||
const method = isUpdate ? 'PUT' : 'POST';
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || 'Speichern fehlgeschlagen');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function deleteCalendarEntry(id) {
|
||||
const response = await fetch(`/api/calendar/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || 'Löschen fehlgeschlagen');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expose for future dynamic usage
|
||||
window.addCalendarEntry = addCalendarEntry;
|
||||
window.loadCalendarEntries = loadCalendarEntries;
|
||||
window.deleteCalendarEntry = deleteCalendarEntry;
|
||||
window.removeCalendarEntryFromUI = removeCalendarEntryFromUI;
|
||||
window.applyLocationFilter = applyLocationFilter;
|
||||
window.exportCalendar = async function exportCalendar() {
|
||||
try {
|
||||
const res = await fetch('/api/calendar/export');
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'kalender.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Export fehlgeschlagen.');
|
||||
}
|
||||
};
|
||||
|
||||
window.importCalendar = async function importCalendar(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/calendar/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(msg || 'Import fehlgeschlagen');
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const addBtn = document.getElementById('add-calendar-entry-btn');
|
||||
const modalEl = document.getElementById('calendarModal');
|
||||
const saveBtn = document.getElementById('saveCalendarEntryBtn');
|
||||
const form = document.getElementById('calendarForm');
|
||||
const daysContainer = document.getElementById('calendar-days');
|
||||
const locationFilter = document.getElementById('calendar-location-filter');
|
||||
const calendarModal = modalEl && window.bootstrap ? new bootstrap.Modal(modalEl) : null;
|
||||
if (calendarModal) {
|
||||
window.calendarModal = calendarModal;
|
||||
}
|
||||
let calendarHasLoaded = false;
|
||||
|
||||
if (addBtn && calendarModal) {
|
||||
addBtn.addEventListener('click', () => {
|
||||
if (form) form.reset();
|
||||
|
||||
const today = new Date();
|
||||
const dateInput = document.getElementById('calendarDate');
|
||||
if (dateInput) dateInput.value = toLocalISO(today);
|
||||
|
||||
const idInput = document.getElementById('calendarEntryId');
|
||||
if (idInput) idInput.value = '';
|
||||
|
||||
calendarModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (saveBtn && calendarModal) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!form || !form.reportValidity()) return;
|
||||
|
||||
const entry = {
|
||||
id: document.getElementById('calendarEntryId')?.value || '',
|
||||
date: document.getElementById('calendarDate')?.value,
|
||||
time: document.getElementById('calendarTime')?.value,
|
||||
title: document.getElementById('calendarTitle')?.value,
|
||||
location: document.getElementById('calendarLocation')?.value,
|
||||
details: document.getElementById('calendarDetails')?.value
|
||||
};
|
||||
|
||||
try {
|
||||
if (entry.id) {
|
||||
removeCalendarEntryFromUI(entry.id);
|
||||
}
|
||||
const saved = await saveCalendarEntry(entry);
|
||||
addLocationOption(saved.location);
|
||||
addCalendarEntry(saved);
|
||||
calendarModal.hide();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Speichern fehlgeschlagen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window._calendarIsAdmin) {
|
||||
const exportBtn = document.getElementById('export-calendar-btn');
|
||||
const importBtn = document.getElementById('import-calendar-btn');
|
||||
const importInput = document.getElementById('import-calendar-input');
|
||||
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.exportCalendar();
|
||||
});
|
||||
}
|
||||
|
||||
if (importBtn && importInput) {
|
||||
importBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
importInput.value = '';
|
||||
importInput.click();
|
||||
});
|
||||
|
||||
importInput.addEventListener('change', async () => {
|
||||
if (!importInput.files || importInput.files.length === 0) return;
|
||||
const file = importInput.files[0];
|
||||
try {
|
||||
await importCalendar(file);
|
||||
loadCalendarEntries();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Import fehlgeschlagen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (daysContainer && typeof admin_enabled !== 'undefined' && admin_enabled) {
|
||||
daysContainer.addEventListener('click', async (e) => {
|
||||
const editBtn = e.target.closest('.calendar-edit');
|
||||
const deleteBtn = e.target.closest('.calendar-delete');
|
||||
if (!editBtn && !deleteBtn) return;
|
||||
|
||||
const row = e.target.closest('tr');
|
||||
if (!row) return;
|
||||
|
||||
let entryRow = row;
|
||||
if (row.classList.contains('calendar-details-row') && row.previousElementSibling && row.previousElementSibling.classList.contains('calendar-entry-row')) {
|
||||
entryRow = row.previousElementSibling;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: entryRow.dataset.id,
|
||||
date: entryRow.dataset.date,
|
||||
time: entryRow.dataset.time,
|
||||
title: entryRow.dataset.title,
|
||||
location: entryRow.dataset.location,
|
||||
details: entryRow.dataset.details
|
||||
};
|
||||
|
||||
if (editBtn) {
|
||||
if (form) form.reset();
|
||||
document.getElementById('calendarEntryId').value = entry.id || '';
|
||||
document.getElementById('calendarDate').value = entry.date || '';
|
||||
document.getElementById('calendarTime').value = entry.time || '';
|
||||
document.getElementById('calendarTitle').value = entry.title || '';
|
||||
document.getElementById('calendarLocation').value = entry.location || '';
|
||||
document.getElementById('calendarDetails').value = entry.details || '';
|
||||
if (calendarModal) calendarModal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!entry.id) return;
|
||||
const confirmDelete = window.confirm('Diesen Eintrag löschen?');
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await deleteCalendarEntry(entry.id);
|
||||
removeCalendarEntryFromUI(entry.id);
|
||||
applyLocationFilter();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Löschen fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
tabButtons.forEach(btn => {
|
||||
if (btn.getAttribute('data-tab') === 'calendar') {
|
||||
btn.addEventListener('click', () => {
|
||||
if (!calendarHasLoaded) {
|
||||
calendarHasLoaded = true;
|
||||
loadCalendarEntries();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Immediately load calendar entries for users landing directly on the calendar tab (non-admins may not switch tabs)
|
||||
if (!calendarHasLoaded) {
|
||||
calendarHasLoaded = true;
|
||||
loadCalendarEntries();
|
||||
}
|
||||
|
||||
if (locationFilter) {
|
||||
locationFilter.addEventListener('change', () => applyLocationFilter());
|
||||
}
|
||||
|
||||
// Show/hide details inline on row click (any user)
|
||||
if (daysContainer) {
|
||||
daysContainer.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.calendar-edit') || e.target.closest('.calendar-delete')) return;
|
||||
const row = e.target.closest('tr.calendar-entry-row');
|
||||
if (!row) return;
|
||||
const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row')
|
||||
? row.nextElementSibling
|
||||
: null;
|
||||
if (!detailsRow) return;
|
||||
|
||||
const isExpanded = detailsRow.dataset.expanded === 'true';
|
||||
const shouldShow = !isExpanded;
|
||||
detailsRow.dataset.expanded = shouldShow ? 'true' : 'false';
|
||||
|
||||
// Respect current filter when toggling visibility
|
||||
const filterValue = (document.getElementById('calendar-location-filter')?.value || '').trim();
|
||||
const rowLoc = (row.dataset.location || '').trim();
|
||||
const match = !filterValue || rowLoc === filterValue;
|
||||
detailsRow.style.display = (shouldShow && match) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -1,6 +1,17 @@
|
||||
<!-- Messages Section -->
|
||||
<section id="messages-section" class="tab-content">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 messages-section-header">
|
||||
<h3 class="mb-0">Nachrichten</h3>
|
||||
{% if admin_enabled %}
|
||||
<div>
|
||||
<button id="add-message-btn" class="btn btn-primary" title="Nachricht hinzufügen">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not admin_enabled %}
|
||||
{% if admin_enabled %}
|
||||
<div class="mb-3">
|
||||
<button id="add-message-btn" class="btn btn-primary" title="Nachricht hinzufügen">
|
||||
@ -8,6 +19,7 @@
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div id="messages-container" class="messages-container">
|
||||
<!-- Messages will be loaded here via JavaScript -->
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user