add calendar
This commit is contained in:
parent
f0e7e9c165
commit
1f38e3acd2
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,3 +19,4 @@
|
|||||||
/transcription_config.yml
|
/transcription_config.yml
|
||||||
|
|
||||||
/messages.json
|
/messages.json
|
||||||
|
/calendar.db
|
||||||
282
app.py
282
app.py
@ -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`)
|
||||||
|
|||||||
159
static/app.css
159
static/app.css
@ -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;
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
|||||||
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 -->
|
<!-- 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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user