add calendar

This commit is contained in:
lelo 2025-12-17 20:33:22 +00:00
parent f0e7e9c165
commit 1f38e3acd2
6 changed files with 1063 additions and 55 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@
/transcription_config.yml /transcription_config.yml
/messages.json /messages.json
/calendar.db

282
app.py
View File

@ -1,4 +1,5 @@
import os import os
import sqlite3
# Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader # Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader
FLASK_ENV = os.environ.get('FLASK_ENV', 'production') FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
@ -25,6 +26,7 @@ import re
import qrcode import qrcode
import base64 import base64
import json import json
import io
import search import search
import auth import auth
import analytics as a import analytics as a
@ -32,6 +34,7 @@ import folder_secret_config_editor as fsce
import helperfunctions as hf import helperfunctions as hf
import search_db_analyzer as sdb import search_db_analyzer as sdb
import fnmatch import fnmatch
import openpyxl
app_config = auth.return_app_config() app_config = auth.return_app_config()
BASE_DIR = os.path.realpath(app_config['BASE_DIR']) 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/News routes
MESSAGES_FILENAME = 'messages.json' MESSAGES_FILENAME = 'messages.json'
CALENDAR_DB = 'calendar.db'
def load_messages(): def load_messages():
"""Load messages from JSON file.""" """Load messages from JSON file."""
@ -100,6 +104,45 @@ def save_messages(messages):
with open(MESSAGES_FILENAME, 'w', encoding='utf-8') as f: with open(MESSAGES_FILENAME, 'w', encoding='utf-8') as f:
json.dump(messages, f, ensure_ascii=False, indent=2) 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']) @app.route('/api/messages', methods=['GET'])
@auth.require_secret @auth.require_secret
def get_messages(): def get_messages():
@ -158,6 +201,245 @@ def delete_message(message_id):
return jsonify({'success': True}) 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 # Grab the HOST_RULE environment variable
host_rule = os.getenv("HOST_RULE", "") host_rule = os.getenv("HOST_RULE", "")
# Use a regex to extract domain names between backticks in patterns like Host(`something`) # Use a regex to extract domain names between backticks in patterns like Host(`something`)

View File

@ -431,6 +431,12 @@ footer {
} }
/* Messages Section Styles */ /* Messages Section Styles */
.messages-section-header {
max-width: 900px;
margin: 0 auto;
padding: 0 0 8px;
}
.messages-container { .messages-container {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
@ -635,6 +641,159 @@ footer {
font-size: 1.2em; 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 Styles */
.file-browser-content { .file-browser-content {
max-height: 400px; max-height: 400px;

View File

@ -65,6 +65,7 @@
<nav class="main-tabs"> <nav class="main-tabs">
<button class="tab-button active" data-tab="browse">Audio/Photo</button> <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="messages">Nachrichten</button>
<button class="tab-button" data-tab="calendar">Kalender</button>
</nav> </nav>
<!-- Browse Section --> <!-- Browse Section -->
@ -85,6 +86,7 @@
</main> </main>
{% include 'messages_section.html' %} {% include 'messages_section.html' %}
{% include 'calendar_section.html' %}
<search style="display: none;"> <search style="display: none;">
<div class="container"> <div class="container">
@ -253,59 +255,6 @@
<div class="loader"></div> <div class="loader"></div>
</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> <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 --> <!-- Load main app JS first, then gallery JS -->

View 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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[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>

View File

@ -1,6 +1,17 @@
<!-- Messages Section --> <!-- Messages Section -->
<section id="messages-section" class="tab-content"> <section id="messages-section" class="tab-content">
<div class="container"> <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 %} {% if admin_enabled %}
<div class="mb-3"> <div class="mb-3">
<button id="add-message-btn" class="btn btn-primary" title="Nachricht hinzufügen"> <button id="add-message-btn" class="btn btn-primary" title="Nachricht hinzufügen">
@ -8,6 +19,7 @@
</button> </button>
</div> </div>
{% endif %} {% endif %}
{% endif %}
<div id="messages-container" class="messages-container"> <div id="messages-container" class="messages-container">
<!-- Messages will be loaded here via JavaScript --> <!-- Messages will be loaded here via JavaScript -->
</div> </div>