Compare commits
No commits in common. "44ec9fc0a0bff3940b870a55eac1224184915d48" and "7b1c97d066b3f96eba113a35af07bc4a345db575" have entirely different histories.
44ec9fc0a0
...
7b1c97d066
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,6 +17,3 @@
|
|||||||
/custom_logo
|
/custom_logo
|
||||||
/static/theme.css
|
/static/theme.css
|
||||||
/transcription_config.yml
|
/transcription_config.yml
|
||||||
|
|
||||||
/messages.json
|
|
||||||
/calendar.db
|
|
||||||
33
analytics.py
33
analytics.py
@ -572,24 +572,6 @@ def dashboard():
|
|||||||
cursor = log_db.execute(query, params_for_filter)
|
cursor = log_db.execute(query, params_for_filter)
|
||||||
locations = cursor.fetchall()
|
locations = cursor.fetchall()
|
||||||
|
|
||||||
# Map data grouped by coordinates
|
|
||||||
map_rows = []
|
|
||||||
try:
|
|
||||||
query = f'''
|
|
||||||
SELECT city, country, latitude, longitude, COUNT(*) as count
|
|
||||||
FROM file_access_log
|
|
||||||
WHERE timestamp >= ? {filetype_filter_sql}
|
|
||||||
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
|
||||||
GROUP BY city, country, latitude, longitude
|
|
||||||
ORDER BY count DESC
|
|
||||||
'''
|
|
||||||
with log_db:
|
|
||||||
cursor = log_db.execute(query, params_for_filter)
|
|
||||||
map_rows = cursor.fetchall()
|
|
||||||
except sqlite3.OperationalError as exc:
|
|
||||||
# Keep dashboard working even if older DBs lack location columns
|
|
||||||
print(f"[dashboard] map query skipped: {exc}")
|
|
||||||
|
|
||||||
# 7. Summary stats
|
# 7. Summary stats
|
||||||
# total_accesses
|
# total_accesses
|
||||||
query = f'''
|
query = f'''
|
||||||
@ -647,20 +629,6 @@ def dashboard():
|
|||||||
location_data.sort(key=lambda x: x['count'], reverse=True)
|
location_data.sort(key=lambda x: x['count'], reverse=True)
|
||||||
location_data = location_data[:20]
|
location_data = location_data[:20]
|
||||||
|
|
||||||
# Prepare map data (limit to keep map readable)
|
|
||||||
map_data = []
|
|
||||||
for city, country, lat, lon, cnt in map_rows:
|
|
||||||
if lat is None or lon is None:
|
|
||||||
continue
|
|
||||||
map_data.append({
|
|
||||||
'city': city,
|
|
||||||
'country': country,
|
|
||||||
'lat': lat,
|
|
||||||
'lon': lon,
|
|
||||||
'count': cnt
|
|
||||||
})
|
|
||||||
map_data = map_data[:200]
|
|
||||||
|
|
||||||
title_short = app_config.get('TITLE_SHORT', 'Default Title')
|
title_short = app_config.get('TITLE_SHORT', 'Default Title')
|
||||||
title_long = app_config.get('TITLE_LONG' , 'Default Title')
|
title_long = app_config.get('TITLE_LONG' , 'Default Title')
|
||||||
|
|
||||||
@ -676,7 +644,6 @@ def dashboard():
|
|||||||
unique_user=unique_user,
|
unique_user=unique_user,
|
||||||
cached_percentage=cached_percentage,
|
cached_percentage=cached_percentage,
|
||||||
timeframe_data=timeframe_data,
|
timeframe_data=timeframe_data,
|
||||||
map_data=map_data,
|
|
||||||
admin_enabled=auth.is_admin(),
|
admin_enabled=auth.is_admin(),
|
||||||
title_short=title_short,
|
title_short=title_short,
|
||||||
title_long=title_long
|
title_long=title_long
|
||||||
|
|||||||
389
app.py
389
app.py
@ -1,5 +1,4 @@
|
|||||||
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,8 +24,6 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
import qrcode
|
import qrcode
|
||||||
import base64
|
import base64
|
||||||
import json
|
|
||||||
import io
|
|
||||||
import search
|
import search
|
||||||
import auth
|
import auth
|
||||||
import analytics as a
|
import analytics as a
|
||||||
@ -34,7 +31,6 @@ 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'])
|
||||||
@ -66,380 +62,10 @@ app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POS
|
|||||||
app.add_url_rule('/admin/folder_secret_config_editor', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
|
app.add_url_rule('/admin/folder_secret_config_editor', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
|
||||||
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config))
|
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config))
|
||||||
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
|
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
|
||||||
|
|
||||||
@app.route('/admin/generate_qr/<secret>')
|
|
||||||
@auth.require_admin
|
|
||||||
def generate_qr_code(secret):
|
|
||||||
scheme = request.scheme
|
|
||||||
host = request.host
|
|
||||||
url = f"{scheme}://{host}?secret={secret}"
|
|
||||||
qr = qrcode.QRCode(version=1, box_size=10, border=4)
|
|
||||||
qr.add_data(url)
|
|
||||||
qr.make(fit=True)
|
|
||||||
img = qr.make_image(fill_color="black", back_color="white")
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
img.save(buffer, format="PNG")
|
|
||||||
buffer.seek(0)
|
|
||||||
img_base64 = base64.b64encode(buffer.getvalue()).decode('ascii')
|
|
||||||
return jsonify({'qr_code': img_base64})
|
|
||||||
|
|
||||||
app.add_url_rule('/admin/search_db_analyzer', view_func=auth.require_admin(sdb.search_db_analyzer))
|
app.add_url_rule('/admin/search_db_analyzer', view_func=auth.require_admin(sdb.search_db_analyzer))
|
||||||
app.add_url_rule('/admin/search_db_analyzer/query', view_func=auth.require_admin(sdb.search_db_query), methods=['POST'])
|
app.add_url_rule('/admin/search_db_analyzer/query', view_func=auth.require_admin(sdb.search_db_query), methods=['POST'])
|
||||||
app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_admin(sdb.search_db_folders))
|
app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_admin(sdb.search_db_folders))
|
||||||
|
|
||||||
# Messages/News routes
|
|
||||||
MESSAGES_FILENAME = 'messages.json'
|
|
||||||
CALENDAR_DB = 'calendar.db'
|
|
||||||
|
|
||||||
def load_messages():
|
|
||||||
"""Load messages from JSON file."""
|
|
||||||
try:
|
|
||||||
with open(MESSAGES_FILENAME, 'r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
return []
|
|
||||||
|
|
||||||
def save_messages(messages):
|
|
||||||
"""Save messages to JSON file."""
|
|
||||||
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():
|
|
||||||
"""Get all messages."""
|
|
||||||
messages = load_messages()
|
|
||||||
# Sort by datetime descending (newest first)
|
|
||||||
messages.sort(key=lambda x: x.get('datetime', ''), reverse=True)
|
|
||||||
return jsonify(messages)
|
|
||||||
|
|
||||||
@app.route('/api/messages', methods=['POST'])
|
|
||||||
@auth.require_admin
|
|
||||||
def create_message():
|
|
||||||
"""Create a new message."""
|
|
||||||
data = request.get_json()
|
|
||||||
messages = load_messages()
|
|
||||||
|
|
||||||
# Generate new ID
|
|
||||||
new_id = max([m.get('id', 0) for m in messages], default=0) + 1
|
|
||||||
|
|
||||||
new_message = {
|
|
||||||
'id': new_id,
|
|
||||||
'title': data.get('title', ''),
|
|
||||||
'content': data.get('content', ''),
|
|
||||||
'datetime': data.get('datetime', datetime.now().isoformat())
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.append(new_message)
|
|
||||||
save_messages(messages)
|
|
||||||
|
|
||||||
return jsonify(new_message), 201
|
|
||||||
|
|
||||||
@app.route('/api/messages/<int:message_id>', methods=['PUT'])
|
|
||||||
@auth.require_admin
|
|
||||||
def update_message(message_id):
|
|
||||||
"""Update an existing message."""
|
|
||||||
data = request.get_json()
|
|
||||||
messages = load_messages()
|
|
||||||
|
|
||||||
for message in messages:
|
|
||||||
if message['id'] == message_id:
|
|
||||||
message['title'] = data.get('title', message['title'])
|
|
||||||
message['content'] = data.get('content', message['content'])
|
|
||||||
message['datetime'] = data.get('datetime', message['datetime'])
|
|
||||||
save_messages(messages)
|
|
||||||
return jsonify(message)
|
|
||||||
|
|
||||||
return jsonify({'error': 'Message not found'}), 404
|
|
||||||
|
|
||||||
@app.route('/api/messages/<int:message_id>', methods=['DELETE'])
|
|
||||||
@auth.require_admin
|
|
||||||
def delete_message(message_id):
|
|
||||||
"""Delete a message."""
|
|
||||||
messages = load_messages()
|
|
||||||
messages = [m for m in messages if m['id'] != message_id]
|
|
||||||
save_messages(messages)
|
|
||||||
|
|
||||||
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`)
|
||||||
@ -1183,23 +809,10 @@ def handle_request_map_data():
|
|||||||
def index(path):
|
def index(path):
|
||||||
app_config = auth.return_app_config()
|
app_config = auth.return_app_config()
|
||||||
|
|
||||||
# Generate dynamic Open Graph meta tags for Telegram preview
|
|
||||||
og_title = app_config.get('TITLE_LONG', 'Default Title')
|
|
||||||
og_description = "... uns aber, die wir gerettet werden, ist es eine Gotteskraft."
|
|
||||||
|
|
||||||
# If secret or token is provided, show folder names in description
|
|
||||||
folder_names = list(session['folders'].keys())
|
|
||||||
if folder_names:
|
|
||||||
folder_list = ", ".join(folder_names)
|
|
||||||
og_description = folder_list
|
|
||||||
|
|
||||||
return render_template("app.html",
|
return render_template("app.html",
|
||||||
search_folders = folder_names,
|
search_folders = list(session['folders'].keys()),
|
||||||
title_short=app_config.get('TITLE_SHORT', 'Default Title'),
|
title_short=app_config.get('TITLE_SHORT', 'Default Title'),
|
||||||
title_long=app_config.get('TITLE_LONG' , 'Default Title'),
|
title_long=app_config.get('TITLE_LONG' , 'Default Title'),
|
||||||
features=app_config.get('FEATURES', None),
|
|
||||||
og_title=og_title,
|
|
||||||
og_description=og_description,
|
|
||||||
admin_enabled=auth.is_admin()
|
admin_enabled=auth.is_admin()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
76
auth.py
76
auth.py
@ -103,21 +103,36 @@ def require_secret(f):
|
|||||||
|
|
||||||
# 4) Re-check validity of each secret and token to also catch previous ones
|
# 4) Re-check validity of each secret and token to also catch previous ones
|
||||||
# If a secret is no longer valid (or not in config), remove it.
|
# If a secret is no longer valid (or not in config), remove it.
|
||||||
for secret_in_session in session.get('valid_secrets', [])[:]:
|
for secret_in_session in session['valid_secrets'][:]:
|
||||||
# Find the current config item with matching secret
|
# Find the current config item with matching secret
|
||||||
config_item = next(
|
config_item = next(
|
||||||
(c for c in folder_config if c['secret'] == secret_in_session),
|
(c for c in folder_config if c['secret'] == secret_in_session),
|
||||||
None
|
None
|
||||||
)
|
)
|
||||||
# If the config item doesn't exist or is invalid, remove secret
|
# If the config item doesn’t exist or is invalid, remove secret
|
||||||
if config_item is None or not is_valid_secret(config_item, secret_in_session):
|
if config_item is None or not is_valid_secret(config_item, secret_in_session):
|
||||||
session['valid_secrets'].remove(secret_in_session)
|
session['valid_secrets'].remove(secret_in_session)
|
||||||
|
|
||||||
for token_in_session in session.get('valid_tokens', [])[:]:
|
for token_in_session in session['valid_tokens'][:]:
|
||||||
if not is_valid_token(token_in_session):
|
if not is_valid_token(token_in_session):
|
||||||
session['valid_tokens'].remove(token_in_session)
|
session['valid_tokens'].remove(token_in_session)
|
||||||
|
|
||||||
# Check for admin access first
|
# 5) Build session['folders'] fresh from the valid secrets
|
||||||
|
session['folders'] = {}
|
||||||
|
for secret_in_session in session.get('valid_secrets', []):
|
||||||
|
config_item = next(
|
||||||
|
(c for c in folder_config if c['secret'] == secret_in_session),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if config_item:
|
||||||
|
for folder_info in config_item['folders']:
|
||||||
|
session['folders'][folder_info['foldername']] = folder_info['folderpath']
|
||||||
|
|
||||||
|
for token_in_session in session.get('valid_tokens', []):
|
||||||
|
token_item = decode_token(token_in_session)
|
||||||
|
for folder_info in token_item['folders']:
|
||||||
|
session['folders'][folder_info['foldername']] = folder_info['folderpath']
|
||||||
|
|
||||||
args_admin = request.args.get('admin', None)
|
args_admin = request.args.get('admin', None)
|
||||||
config_admin = app_config.get('ADMIN_KEY', None)
|
config_admin = app_config.get('ADMIN_KEY', None)
|
||||||
|
|
||||||
@ -129,41 +144,6 @@ def require_secret(f):
|
|||||||
print(f"Admin access denied with key: {args_admin}")
|
print(f"Admin access denied with key: {args_admin}")
|
||||||
session['admin'] = False
|
session['admin'] = False
|
||||||
|
|
||||||
# 5) Build session['folders'] fresh from the valid secrets
|
|
||||||
session['folders'] = {}
|
|
||||||
|
|
||||||
if is_admin():
|
|
||||||
# Admin gets access to all folders in the config
|
|
||||||
for config_item in folder_config:
|
|
||||||
for folder_info in config_item['folders']:
|
|
||||||
session['folders'][folder_info['foldername']] = folder_info['folderpath']
|
|
||||||
else:
|
|
||||||
# Normal users only get folders from their valid secrets
|
|
||||||
for secret_in_session in session.get('valid_secrets', []):
|
|
||||||
config_item = next(
|
|
||||||
(c for c in folder_config if c['secret'] == secret_in_session),
|
|
||||||
None
|
|
||||||
)
|
|
||||||
if config_item:
|
|
||||||
for folder_info in config_item['folders']:
|
|
||||||
session['folders'][folder_info['foldername']] = folder_info['folderpath']
|
|
||||||
|
|
||||||
# Add token folders for both admin and regular users
|
|
||||||
for token_in_session in session.get('valid_tokens', []):
|
|
||||||
try:
|
|
||||||
token_item = decode_token(token_in_session)
|
|
||||||
print(f"DEBUG: Decoded token: {token_item}")
|
|
||||||
for folder_info in token_item.get('folders', []):
|
|
||||||
print(f"DEBUG: Adding folder '{folder_info['foldername']}' -> '{folder_info['folderpath']}'")
|
|
||||||
session['folders'][folder_info['foldername']] = folder_info['folderpath']
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to process token: {e}")
|
|
||||||
|
|
||||||
# Mark session as modified to ensure it's saved
|
|
||||||
session.modified = True
|
|
||||||
print(f"DEBUG: Final session['folders'] keys: {list(session['folders'].keys())}")
|
|
||||||
print(f"DEBUG: session['valid_tokens']: {session.get('valid_tokens', [])}")
|
|
||||||
|
|
||||||
# 6) If we have folders, proceed; otherwise show index
|
# 6) If we have folders, proceed; otherwise show index
|
||||||
if session['folders']:
|
if session['folders']:
|
||||||
# assume since visitor has a valid secret, they are ok with annonymous tracking
|
# assume since visitor has a valid secret, they are ok with annonymous tracking
|
||||||
@ -171,16 +151,14 @@ def require_secret(f):
|
|||||||
if 'device_id' not in session:
|
if 'device_id' not in session:
|
||||||
session['device_id'] = os.urandom(32).hex()
|
session['device_id'] = os.urandom(32).hex()
|
||||||
|
|
||||||
# AUTO-JUMP FOR TOKENS - Disabled for now to debug
|
# AUTO-JUMP FOR TOKENS
|
||||||
# try:
|
try:
|
||||||
# if args_token and is_valid_token(args_token):
|
if args_token and is_valid_token(args_token):
|
||||||
# token_item = decode_token(args_token)
|
token_item = decode_token(args_token)
|
||||||
# target_foldername = token_item['folders'][0]['foldername']
|
target_foldername = token_item['folders'][0]['foldername']
|
||||||
# # Mark session as modified to ensure it's saved before redirect
|
return redirect(f"path/{target_foldername}")
|
||||||
# session.modified = True
|
except Exception as e:
|
||||||
# return redirect(f"/path/{target_foldername}")
|
print(f"Error during auto-jump: {e}")
|
||||||
# except Exception as e:
|
|
||||||
# print(f"Error during auto-jump: {e}")
|
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
else:
|
else:
|
||||||
|
|||||||
23
readme.md
23
readme.md
@ -9,13 +9,6 @@ This is a self-hosted media sharing and indexing platform designed for secure an
|
|||||||
- **Primary Folders (Secret Links):** Centralized configuration with full admin control. Suitable for public events.
|
- **Primary Folders (Secret Links):** Centralized configuration with full admin control. Suitable for public events.
|
||||||
- **Subfolders (Token Links):** Can be shared ad hoc, without central admin access. Ideal for limited-audience events.
|
- **Subfolders (Token Links):** Can be shared ad hoc, without central admin access. Ideal for limited-audience events.
|
||||||
|
|
||||||
- **News/Messages System**
|
|
||||||
- Blog-style news cards with date/time stamps
|
|
||||||
- Admin interface for creating, editing, and deleting messages
|
|
||||||
- Markdown support for rich content formatting
|
|
||||||
- Separate tab for browsing files and viewing messages
|
|
||||||
- Chronological display (newest first)
|
|
||||||
|
|
||||||
- **Transcription and Search**
|
- **Transcription and Search**
|
||||||
- Local transcription of audio files
|
- Local transcription of audio files
|
||||||
- Full-text search through transcripts
|
- Full-text search through transcripts
|
||||||
@ -146,22 +139,6 @@ docker compose up -d
|
|||||||
|
|
||||||
To unlock administrative controls on your device, use the dedicated admin access link.
|
To unlock administrative controls on your device, use the dedicated admin access link.
|
||||||
|
|
||||||
### 6. Managing Messages/News
|
|
||||||
|
|
||||||
The application includes a news/messages system that allows admins to post announcements and updates:
|
|
||||||
|
|
||||||
- **Viewing Messages**: All users can view messages by clicking the "Nachrichten" tab in the main interface
|
|
||||||
- **Adding Messages**: Admins can click "Nachricht hinzufügen" to create a new message
|
|
||||||
- **Editing Messages**: Click the "Bearbeiten" button on any message card
|
|
||||||
- **Deleting Messages**: Click the "Löschen" button to remove a message
|
|
||||||
|
|
||||||
Messages are stored in `messages.json` and support:
|
|
||||||
- **Markdown formatting** for rich content (headings, lists, links, code blocks, etc.)
|
|
||||||
- **Date/time stamps** for chronological organization
|
|
||||||
- **Blog-style display** with newest messages first
|
|
||||||
|
|
||||||
To pre-populate messages, you can manually edit `messages.json` or copy from `example_config_files/messages.json` as a template.
|
|
||||||
|
|
||||||
## Contribution
|
## Contribution
|
||||||
|
|
||||||
This app is under active development and contributions are welcome.
|
This app is under active development and contributions are welcome.
|
||||||
|
|||||||
517
static/app.css
517
static/app.css
@ -35,25 +35,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-rows: auto 1fr auto;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding-bottom: 200px;
|
padding-bottom: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper > .admin-nav,
|
|
||||||
.wrapper > .main-tabs {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper > main,
|
|
||||||
.wrapper > section {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper > footer {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
@ -382,502 +368,3 @@ footer {
|
|||||||
.toggle-icon:hover {
|
.toggle-icon:hover {
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab Navigation Styles */
|
|
||||||
.main-tabs {
|
|
||||||
background-color: var(--dark-background);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
gap: 2px;
|
|
||||||
border-bottom: 2px solid #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button {
|
|
||||||
background: #333;
|
|
||||||
border: 1px solid #444;
|
|
||||||
border-bottom: none;
|
|
||||||
color: #999;
|
|
||||||
padding: 6px 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border-radius: 5px 5px 0 0;
|
|
||||||
margin: 0;
|
|
||||||
position: relative;
|
|
||||||
top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: #3a3a3a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
color: var(--main-text-color, #000);
|
|
||||||
background: var(--light-background);
|
|
||||||
border-color: #444;
|
|
||||||
border-bottom-color: var(--light-background);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Messages Section Styles */
|
|
||||||
.messages-section-header {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card {
|
|
||||||
background: var(--card-background, #fff);
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
transition: box-shadow 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-card:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-title {
|
|
||||||
font-size: 1.5em;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--main-text-color);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-datetime {
|
|
||||||
color: #666;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--main-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content .folder-link-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
margin: 4px 0;
|
|
||||||
background: #f0f4f8;
|
|
||||||
color: #495057;
|
|
||||||
border: 1px solid #d1dce5;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.95em;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content .folder-link-btn:hover {
|
|
||||||
background: #e3eaf0;
|
|
||||||
border-color: #b8c5d0;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content .folder-link-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content .folder-link-btn i {
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content a {
|
|
||||||
color: #007bff;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content a:hover {
|
|
||||||
color: #0056b3;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content h1,
|
|
||||||
.message-content h2,
|
|
||||||
.message-content h3 {
|
|
||||||
margin-top: 20px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content h2 {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content h3 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content h4 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content h5 {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content h6 {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content p {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content ul,
|
|
||||||
.message-content ol {
|
|
||||||
margin-left: 20px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content code {
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content pre {
|
|
||||||
background-color: #f4f4f4;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content blockquote {
|
|
||||||
border-left: 4px solid #ccc;
|
|
||||||
padding-left: 15px;
|
|
||||||
color: #666;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-image {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 15px 0;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-actions {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit,
|
|
||||||
.btn-delete {
|
|
||||||
padding: 5px 15px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit {
|
|
||||||
background-color: #007bff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-edit:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete:hover {
|
|
||||||
background-color: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-messages {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #999;
|
|
||||||
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: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-weekday-header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-day {
|
|
||||||
background: var(--card-background, #fff);
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
padding: 8px 10px 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-day-today {
|
|
||||||
background: #f7fbff;
|
|
||||||
box-shadow: inset 0 0 0 2px #4da3ff;
|
|
||||||
border-color: #4da3ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-day-sunday,
|
|
||||||
.calendar-day-holiday {
|
|
||||||
background: #fff7f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-day-today.calendar-day-sunday,
|
|
||||||
.calendar-day-today.calendar-day-holiday {
|
|
||||||
background: linear-gradient(135deg, #f7fbff 0%, #f7fbff 60%, #fff2f2 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-day-sunday .calendar-day-header,
|
|
||||||
.calendar-day-holiday .calendar-day-header {
|
|
||||||
color: #b93838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-holiday-badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 6px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #ffe3e3;
|
|
||||||
color: #b93838;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-day:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-day-hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: 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: visible;
|
|
||||||
position: sticky;
|
|
||||||
top: 70px;
|
|
||||||
z-index: 900;
|
|
||||||
background: var(--card-background, #fff);
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-table-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
|
||||||
.calendar-table-wrapper {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-browser-content .list-group-item {
|
|
||||||
border-left: none;
|
|
||||||
border-right: none;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-browser-content .list-group-item:first-child {
|
|
||||||
border-top: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-browser-content .list-group-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#fileBrowserCollapse {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-browser-panel {
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-browser-panel h6 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -116,7 +116,7 @@ function renderContent(data) {
|
|||||||
if (data.breadcrumbs.length === 1 && data.toplist_enabled) {
|
if (data.breadcrumbs.length === 1 && data.toplist_enabled) {
|
||||||
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="toplist">🔥 oft angehört</a></li>`;
|
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="toplist">🔥 oft angehört</a></li>`;
|
||||||
}
|
}
|
||||||
|
console.log(data.folder_today, data.folder_yesterday);
|
||||||
data.directories.forEach(dir => {
|
data.directories.forEach(dir => {
|
||||||
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
|
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
|
||||||
share_link = `<a href="#" class="create-share" data-url="${dir.path}">⚙️</a>`;
|
share_link = `<a href="#" class="create-share" data-url="${dir.path}">⚙️</a>`;
|
||||||
|
|||||||
@ -1,481 +0,0 @@
|
|||||||
// Messages functionality
|
|
||||||
|
|
||||||
let messagesData = [];
|
|
||||||
let messageModal;
|
|
||||||
let currentEditingId = null;
|
|
||||||
|
|
||||||
// Initialize messages on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize Bootstrap modal
|
|
||||||
const modalElement = document.getElementById('messageModal');
|
|
||||||
if (modalElement) {
|
|
||||||
messageModal = new bootstrap.Modal(modalElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab switching
|
|
||||||
const tabButtons = document.querySelectorAll('.tab-button');
|
|
||||||
tabButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', () => {
|
|
||||||
const targetTab = button.getAttribute('data-tab');
|
|
||||||
|
|
||||||
// Update tab buttons
|
|
||||||
tabButtons.forEach(btn => {
|
|
||||||
if (btn.getAttribute('data-tab') === targetTab) {
|
|
||||||
btn.classList.add('active');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update tab content
|
|
||||||
const tabContents = document.querySelectorAll('.tab-content');
|
|
||||||
tabContents.forEach(content => {
|
|
||||||
content.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
const targetContent = document.getElementById(targetTab + '-section');
|
|
||||||
if (targetContent) {
|
|
||||||
targetContent.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load messages when switching to messages tab
|
|
||||||
if (targetTab === 'messages') {
|
|
||||||
loadMessages();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add message button
|
|
||||||
const addMessageBtn = document.getElementById('add-message-btn');
|
|
||||||
if (addMessageBtn) {
|
|
||||||
addMessageBtn.addEventListener('click', () => {
|
|
||||||
openMessageModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save message button
|
|
||||||
const saveMessageBtn = document.getElementById('saveMessageBtn');
|
|
||||||
if (saveMessageBtn) {
|
|
||||||
saveMessageBtn.addEventListener('click', () => {
|
|
||||||
saveMessage();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert link button
|
|
||||||
const insertLinkBtn = document.getElementById('insertLinkBtn');
|
|
||||||
if (insertLinkBtn) {
|
|
||||||
insertLinkBtn.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleFileBrowser();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event delegation for file browser actions
|
|
||||||
const fileBrowserContent = document.getElementById('fileBrowserContent');
|
|
||||||
if (fileBrowserContent) {
|
|
||||||
fileBrowserContent.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Check if button was clicked
|
|
||||||
const button = e.target.closest('button[data-action="insert-link"]');
|
|
||||||
if (button) {
|
|
||||||
const path = button.getAttribute('data-path');
|
|
||||||
const name = button.getAttribute('data-name');
|
|
||||||
const type = button.getAttribute('data-type');
|
|
||||||
insertLink(path, name, type);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if list item was clicked for navigation
|
|
||||||
const navItem = e.target.closest('li[data-action="navigate"]');
|
|
||||||
if (navItem && !e.target.closest('button')) {
|
|
||||||
const path = navItem.getAttribute('data-path');
|
|
||||||
loadFileBrowser(path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if list item was clicked for insert (files)
|
|
||||||
const insertItem = e.target.closest('li[data-action="insert-link"]');
|
|
||||||
if (insertItem && !e.target.closest('button')) {
|
|
||||||
const path = insertItem.getAttribute('data-path');
|
|
||||||
const name = insertItem.getAttribute('data-name');
|
|
||||||
const type = insertItem.getAttribute('data-type');
|
|
||||||
insertLink(path, name, type);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event delegation for folder links in messages
|
|
||||||
const messagesContainer = document.getElementById('messages-container');
|
|
||||||
if (messagesContainer) {
|
|
||||||
messagesContainer.addEventListener('click', (e) => {
|
|
||||||
const folderLink = e.target.closest('button[data-folder-path]');
|
|
||||||
if (folderLink) {
|
|
||||||
e.preventDefault();
|
|
||||||
const folderPath = folderLink.getAttribute('data-folder-path');
|
|
||||||
|
|
||||||
// Update tab buttons without changing hash
|
|
||||||
const tabButtons = document.querySelectorAll('.tab-button');
|
|
||||||
tabButtons.forEach(btn => {
|
|
||||||
if (btn.getAttribute('data-tab') === 'browse') {
|
|
||||||
btn.classList.add('active');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update tab content
|
|
||||||
const tabContents = document.querySelectorAll('.tab-content');
|
|
||||||
tabContents.forEach(content => {
|
|
||||||
content.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
const browseSection = document.getElementById('browse-section');
|
|
||||||
if (browseSection) {
|
|
||||||
browseSection.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the directory directly without setTimeout
|
|
||||||
if (typeof loadDirectory === 'function') {
|
|
||||||
loadDirectory(folderPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
async function loadMessages() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/messages');
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load messages');
|
|
||||||
}
|
|
||||||
messagesData = await response.json();
|
|
||||||
renderMessages();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading messages:', error);
|
|
||||||
const container = document.getElementById('messages-container');
|
|
||||||
container.innerHTML = '<div class="alert alert-danger">Fehler beim Laden der Nachrichten</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMessages() {
|
|
||||||
const container = document.getElementById('messages-container');
|
|
||||||
|
|
||||||
if (messagesData.length === 0) {
|
|
||||||
container.innerHTML = '<div class="no-messages">Keine Nachrichten vorhanden</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
messagesData.forEach(message => {
|
|
||||||
const datetime = formatDateTime(message.datetime);
|
|
||||||
|
|
||||||
// Convert folder: protocol to clickable buttons before markdown parsing
|
|
||||||
let processedContent = message.content || '';
|
|
||||||
processedContent = processedContent.replace(/\[([^\]]+)\]\(folder:([^)]+)\)/g,
|
|
||||||
(match, name, path) => `<button class="folder-link-btn" data-folder-path="${escapeHtml(path)}"><i class="bi bi-folder2"></i> ${escapeHtml(name)}</button>`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Configure marked to allow all links
|
|
||||||
marked.setOptions({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true
|
|
||||||
});
|
|
||||||
const contentHtml = marked.parse(processedContent);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="message-card" data-message-id="${message.id}">
|
|
||||||
<div class="message-header">
|
|
||||||
<h2 class="message-title">${escapeHtml(message.title)}</h2>
|
|
||||||
<div class="message-datetime">${datetime}</div>
|
|
||||||
</div>
|
|
||||||
<div class="message-content">
|
|
||||||
${contentHtml}
|
|
||||||
</div>
|
|
||||||
${admin_enabled ? `
|
|
||||||
<div class="message-actions">
|
|
||||||
<button class="btn-edit" onclick="editMessage(${message.id})" title="Bearbeiten">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn-delete" onclick="deleteMessage(${message.id})" title="Löschen">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(datetimeStr) {
|
|
||||||
try {
|
|
||||||
const date = new Date(datetimeStr);
|
|
||||||
const options = {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
};
|
|
||||||
return date.toLocaleDateString('de-DE', options);
|
|
||||||
} catch (error) {
|
|
||||||
return datetimeStr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const map = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
};
|
|
||||||
return text.replace(/[&<>"']/g, m => map[m]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMessageModal(messageId = null) {
|
|
||||||
currentEditingId = messageId;
|
|
||||||
|
|
||||||
const modalTitle = document.getElementById('messageModalLabel');
|
|
||||||
const messageIdInput = document.getElementById('messageId');
|
|
||||||
const titleInput = document.getElementById('messageTitle');
|
|
||||||
const datetimeInput = document.getElementById('messageDateTime');
|
|
||||||
const contentInput = document.getElementById('messageContent');
|
|
||||||
|
|
||||||
if (messageId) {
|
|
||||||
// Edit mode
|
|
||||||
const message = messagesData.find(m => m.id === messageId);
|
|
||||||
if (message) {
|
|
||||||
modalTitle.textContent = 'Nachricht bearbeiten';
|
|
||||||
messageIdInput.value = message.id;
|
|
||||||
titleInput.value = message.title;
|
|
||||||
|
|
||||||
// Format datetime for input (convert to local time)
|
|
||||||
const date = new Date(message.datetime);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
const formattedDate = `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
datetimeInput.value = formattedDate;
|
|
||||||
|
|
||||||
contentInput.value = message.content;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Add mode
|
|
||||||
modalTitle.textContent = 'Neue Nachricht';
|
|
||||||
messageIdInput.value = '';
|
|
||||||
titleInput.value = '';
|
|
||||||
|
|
||||||
// Set current datetime (use local time)
|
|
||||||
const now = new Date();
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(now.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(now.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
||||||
const formattedNow = `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
datetimeInput.value = formattedNow;
|
|
||||||
|
|
||||||
contentInput.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
messageModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMessage() {
|
|
||||||
const messageId = document.getElementById('messageId').value;
|
|
||||||
const title = document.getElementById('messageTitle').value.trim();
|
|
||||||
const datetime = document.getElementById('messageDateTime').value;
|
|
||||||
const content = document.getElementById('messageContent').value.trim();
|
|
||||||
|
|
||||||
if (!title || !datetime || !content) {
|
|
||||||
alert('Bitte füllen Sie alle Felder aus.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert datetime-local to ISO string
|
|
||||||
const datetimeISO = new Date(datetime).toISOString();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
title: title,
|
|
||||||
datetime: datetimeISO,
|
|
||||||
content: content
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
if (messageId) {
|
|
||||||
// Update existing message
|
|
||||||
response = await fetch(`/api/messages/${messageId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create new message
|
|
||||||
response = await fetch('/api/messages', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to save message');
|
|
||||||
}
|
|
||||||
|
|
||||||
messageModal.hide();
|
|
||||||
loadMessages();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving message:', error);
|
|
||||||
alert('Fehler beim Speichern der Nachricht.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editMessage(messageId) {
|
|
||||||
openMessageModal(messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteMessage(messageId) {
|
|
||||||
if (!confirm('Möchten Sie diese Nachricht wirklich löschen?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/messages/${messageId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete message');
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMessages();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting message:', error);
|
|
||||||
alert('Fehler beim Löschen der Nachricht.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// File browser functionality
|
|
||||||
let currentBrowserPath = '';
|
|
||||||
let fileBrowserCollapse = null;
|
|
||||||
|
|
||||||
function toggleFileBrowser() {
|
|
||||||
const collapse = document.getElementById('fileBrowserCollapse');
|
|
||||||
|
|
||||||
// Initialize collapse on first use
|
|
||||||
if (!fileBrowserCollapse) {
|
|
||||||
fileBrowserCollapse = new bootstrap.Collapse(collapse, { toggle: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (collapse.classList.contains('show')) {
|
|
||||||
fileBrowserCollapse.hide();
|
|
||||||
} else {
|
|
||||||
fileBrowserCollapse.show();
|
|
||||||
loadFileBrowser('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFileBrowser(path) {
|
|
||||||
currentBrowserPath = path;
|
|
||||||
const container = document.getElementById('fileBrowserContent');
|
|
||||||
const pathDisplay = document.getElementById('currentPath');
|
|
||||||
|
|
||||||
pathDisplay.textContent = path || '/';
|
|
||||||
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm" role="status"></div></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/path/${path}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load directory');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
renderFileBrowser(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading directory:', error);
|
|
||||||
container.innerHTML = '<div class="alert alert-danger">Fehler beim Laden des Verzeichnisses</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFileBrowser(data) {
|
|
||||||
const container = document.getElementById('fileBrowserContent');
|
|
||||||
let html = '<ul class="list-group list-group-flush">';
|
|
||||||
|
|
||||||
// Add parent directory link if not at root
|
|
||||||
if (currentBrowserPath) {
|
|
||||||
const parentPath = currentBrowserPath.split('/').slice(0, -1).join('/');
|
|
||||||
html += `
|
|
||||||
<li class="list-group-item list-group-item-action" style="cursor: pointer;" data-action="navigate" data-path="${escapeHtml(parentPath)}">
|
|
||||||
<i class="bi bi-arrow-up"></i> ..
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add directories
|
|
||||||
data.directories.forEach(dir => {
|
|
||||||
html += `
|
|
||||||
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" style="cursor: pointer;" data-action="navigate" data-path="${escapeHtml(dir.path)}" data-name="${escapeHtml(dir.name)}">
|
|
||||||
<span>
|
|
||||||
<i class="bi bi-folder"></i> ${escapeHtml(dir.name)}
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-primary" data-action="insert-link" data-path="${escapeHtml(dir.path)}" data-name="${escapeHtml(dir.name)}" data-type="folder">
|
|
||||||
<i class="bi bi-link-45deg"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add files
|
|
||||||
data.files.forEach(file => {
|
|
||||||
const icon = file.file_type === 'music' ? 'bi-music-note' :
|
|
||||||
file.file_type === 'image' ? 'bi-image' : 'bi-file-earmark';
|
|
||||||
html += `
|
|
||||||
<li class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" style="cursor: pointer;" data-action="insert-link" data-path="${escapeHtml(file.path)}" data-name="${escapeHtml(file.name)}" data-type="file">
|
|
||||||
<span>
|
|
||||||
<i class="bi ${icon}"></i> ${escapeHtml(file.name)}
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-primary" data-action="insert-link" data-path="${escapeHtml(file.path)}" data-name="${escapeHtml(file.name)}" data-type="file">
|
|
||||||
<i class="bi bi-link-45deg"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</ul>';
|
|
||||||
container.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertLink(path, name, type) {
|
|
||||||
const contentTextarea = document.getElementById('messageContent');
|
|
||||||
// For folders, use folder: protocol; for files use /media/
|
|
||||||
const url = type === 'folder' ? `folder:${path}` : `/media/${path}`;
|
|
||||||
const linkText = `[${name}](${url})`;
|
|
||||||
|
|
||||||
// Insert at cursor position
|
|
||||||
const start = contentTextarea.selectionStart;
|
|
||||||
const end = contentTextarea.selectionEnd;
|
|
||||||
const text = contentTextarea.value;
|
|
||||||
|
|
||||||
contentTextarea.value = text.substring(0, start) + linkText + text.substring(end);
|
|
||||||
contentTextarea.selectionStart = contentTextarea.selectionEnd = start + linkText.length;
|
|
||||||
contentTextarea.focus();
|
|
||||||
|
|
||||||
// Optionally close the browser after inserting
|
|
||||||
fileBrowserCollapse.hide();
|
|
||||||
}
|
|
||||||
@ -6,10 +6,9 @@
|
|||||||
|
|
||||||
<title>{{ title_short }}</title>
|
<title>{{ title_short }}</title>
|
||||||
|
|
||||||
<meta property="og:title" content="{{ og_title }}" />
|
<meta property="og:title" content="{{ title_long }}" />
|
||||||
<meta property="og:description" content="{{ og_description }}" />
|
<meta property="og:description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft." />
|
||||||
<meta property="og:image" content="/icon/logo-192x192.png" />
|
<meta property="og:image" content="/icon/logo-192x192.png" />
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||||
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
|
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
|
||||||
@ -60,18 +59,7 @@
|
|||||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" >Ordnerkonfiguration</a>
|
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" >Ordnerkonfiguration</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<main>
|
||||||
{% if features %}
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<nav class="main-tabs">
|
|
||||||
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
|
|
||||||
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
|
|
||||||
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Browse Section -->
|
|
||||||
<main id="browse-section" class="tab-content active">
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div id="breadcrumbs" class="breadcrumb"></div>
|
<div id="breadcrumbs" class="breadcrumb"></div>
|
||||||
<div id="content"></div>
|
<div id="content"></div>
|
||||||
@ -87,9 +75,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% include 'messages_section.html' %}
|
|
||||||
{% include 'calendar_section.html' %}
|
|
||||||
|
|
||||||
<search style="display: none;">
|
<search style="display: none;">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<form id="searchForm" method="post" class="mb-4">
|
<form id="searchForm" method="post" class="mb-4">
|
||||||
@ -264,7 +249,6 @@
|
|||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='gallery.js') }}"></script>
|
<script src="{{ url_for('static', filename='gallery.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='messages.js') }}"></script>
|
|
||||||
<script>
|
<script>
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
|
|||||||
@ -1,755 +0,0 @@
|
|||||||
<!-- 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
{% if admin_enabled %}
|
|
||||||
<div class="row g-3 mt-1" id="calendarRecurrenceRow">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="calendarRepeatCount" class="form-label">Anzahl Wiederholung (Wöchentlich)</label>
|
|
||||||
<input type="number" class="form-control" id="calendarRepeatCount" min="1" max="52" step="1" value="1">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const holidayCache = new Map();
|
|
||||||
|
|
||||||
function calculateEasterSunday(year) {
|
|
||||||
const a = year % 19;
|
|
||||||
const b = Math.floor(year / 100);
|
|
||||||
const c = year % 100;
|
|
||||||
const d = Math.floor(b / 4);
|
|
||||||
const e = b % 4;
|
|
||||||
const f = Math.floor((b + 8) / 25);
|
|
||||||
const g = Math.floor((b - f + 1) / 3);
|
|
||||||
const h = (19 * a + b - d - g + 15) % 30;
|
|
||||||
const i = Math.floor(c / 4);
|
|
||||||
const k = c % 4;
|
|
||||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
||||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
||||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
|
||||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
|
||||||
return new Date(year, month - 1, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGermanHolidays(year) {
|
|
||||||
if (holidayCache.has(year)) return holidayCache.get(year);
|
|
||||||
|
|
||||||
const holidays = new Map();
|
|
||||||
const add = (month, day, name) => {
|
|
||||||
holidays.set(toLocalISO(new Date(year, month - 1, day)), name);
|
|
||||||
};
|
|
||||||
|
|
||||||
add(1, 1, 'Neujahr');
|
|
||||||
add(5, 1, 'Tag der Arbeit');
|
|
||||||
add(10, 3, 'Tag der Deutschen Einheit');
|
|
||||||
add(12, 25, '1. Weihnachtstag');
|
|
||||||
add(12, 26, '2. Weihnachtstag');
|
|
||||||
|
|
||||||
const easterSunday = calculateEasterSunday(year);
|
|
||||||
const addOffset = (offset, name) => {
|
|
||||||
const d = new Date(easterSunday);
|
|
||||||
d.setDate(easterSunday.getDate() + offset);
|
|
||||||
holidays.set(toLocalISO(d), name);
|
|
||||||
};
|
|
||||||
|
|
||||||
addOffset(-2, 'Karfreitag');
|
|
||||||
addOffset(0, 'Ostersonntag');
|
|
||||||
addOffset(1, 'Ostermontag');
|
|
||||||
addOffset(39, 'Christi Himmelfahrt');
|
|
||||||
addOffset(49, 'Pfingstsonntag');
|
|
||||||
addOffset(50, 'Pfingstmontag');
|
|
||||||
|
|
||||||
holidayCache.set(year, holidays);
|
|
||||||
return holidays;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHolidayName(date) {
|
|
||||||
const holidays = getGermanHolidays(date.getFullYear());
|
|
||||||
return holidays.get(toLocalISO(date)) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDesktopView() {
|
|
||||||
return window.matchMedia && window.matchMedia('(min-width: 992px)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarRange() {
|
|
||||||
const weeks = window._calendarIsAdmin ? 12 : 4;
|
|
||||||
const start = new Date();
|
|
||||||
start.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (isDesktopView()) {
|
|
||||||
// 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 renderWeekdayHeader(startDate) {
|
|
||||||
const header = document.querySelector('.calendar-weekday-header');
|
|
||||||
if (!header) return;
|
|
||||||
header.innerHTML = '';
|
|
||||||
const formatDayShort = new Intl.DateTimeFormat('de-DE', { weekday: 'short' });
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const d = new Date(startDate);
|
|
||||||
d.setDate(startDate.getDate() + i);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = formatDayShort.format(d);
|
|
||||||
header.appendChild(div);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCalendarStructure(force = false) {
|
|
||||||
const daysContainer = document.getElementById('calendar-days');
|
|
||||||
if (!daysContainer || (daysContainer.childElementCount > 0 && !force)) return;
|
|
||||||
|
|
||||||
const isAdmin = typeof admin_enabled !== 'undefined' && admin_enabled;
|
|
||||||
const calendarColSpan = 3;
|
|
||||||
window._calendarColSpan = calendarColSpan;
|
|
||||||
window._calendarIsAdmin = isAdmin;
|
|
||||||
daysContainer.innerHTML = '';
|
|
||||||
|
|
||||||
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 todayIso = toLocalISO(new Date());
|
|
||||||
renderWeekdayHeader(start);
|
|
||||||
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 isSunday = date.getDay() === 0;
|
|
||||||
const holidayName = getHolidayName(date);
|
|
||||||
const holidayBadge = holidayName ? `<span class="calendar-holiday-badge">${holidayName}</span>` : '';
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
if (isSunday) {
|
|
||||||
dayBlock.classList.add('calendar-day-sunday');
|
|
||||||
}
|
|
||||||
if (holidayName) {
|
|
||||||
dayBlock.classList.add('calendar-day-holiday');
|
|
||||||
}
|
|
||||||
if (isDesktopView() && isoDate < todayIso) {
|
|
||||||
dayBlock.classList.add('calendar-day-hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
dayBlock.innerHTML = `
|
|
||||||
<div class="calendar-day-header">
|
|
||||||
<div class="calendar-day-name">${formatDayName.format(date)}</div>
|
|
||||||
<div class="calendar-day-date">${formatDate.format(date)}${holidayBadge}</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 createRecurringEntries(entry, repeatCount) {
|
|
||||||
const count = Math.max(1, Math.min(52, repeatCount || 1));
|
|
||||||
const baseDate = entry.date;
|
|
||||||
if (!baseDate) return [];
|
|
||||||
const base = new Date(baseDate);
|
|
||||||
const savedEntries = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const next = new Date(base);
|
|
||||||
next.setDate(base.getDate() + i * 7);
|
|
||||||
const entryForWeek = { ...entry, date: toLocalISO(next) };
|
|
||||||
const saved = await saveCalendarEntry(entryForWeek);
|
|
||||||
savedEntries.push(saved);
|
|
||||||
}
|
|
||||||
return savedEntries;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 repeatCountInput = document.getElementById('calendarRepeatCount');
|
|
||||||
const recurrenceRow = document.getElementById('calendarRecurrenceRow');
|
|
||||||
const calendarModal = modalEl && window.bootstrap ? new bootstrap.Modal(modalEl) : null;
|
|
||||||
if (calendarModal) {
|
|
||||||
window.calendarModal = calendarModal;
|
|
||||||
}
|
|
||||||
let calendarHasLoaded = false;
|
|
||||||
|
|
||||||
buildCalendarStructure();
|
|
||||||
|
|
||||||
let lastViewportIsDesktop = isDesktopView();
|
|
||||||
let resizeDebounce;
|
|
||||||
const handleViewportChange = () => {
|
|
||||||
const nowDesktop = isDesktopView();
|
|
||||||
if (nowDesktop === lastViewportIsDesktop) return;
|
|
||||||
lastViewportIsDesktop = nowDesktop;
|
|
||||||
buildCalendarStructure(true);
|
|
||||||
if (calendarHasLoaded) {
|
|
||||||
loadCalendarEntries();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
if (resizeDebounce) clearTimeout(resizeDebounce);
|
|
||||||
resizeDebounce = setTimeout(handleViewportChange, 150);
|
|
||||||
});
|
|
||||||
|
|
||||||
const setRecurrenceMode = (isEditing) => {
|
|
||||||
if (!recurrenceRow) return;
|
|
||||||
recurrenceRow.style.display = isEditing ? 'none' : '';
|
|
||||||
if (!isEditing && repeatCountInput) {
|
|
||||||
repeatCountInput.value = '1';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = '';
|
|
||||||
|
|
||||||
setRecurrenceMode(false);
|
|
||||||
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
|
|
||||||
};
|
|
||||||
const isEditing = Boolean(entry.id);
|
|
||||||
const repeatCount = (!isEditing && window._calendarIsAdmin && repeatCountInput)
|
|
||||||
? Math.max(1, Math.min(52, parseInt(repeatCountInput.value, 10) || 1))
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEditing) {
|
|
||||||
removeCalendarEntryFromUI(entry.id);
|
|
||||||
const saved = await saveCalendarEntry(entry);
|
|
||||||
addLocationOption(saved.location);
|
|
||||||
addCalendarEntry(saved);
|
|
||||||
} else {
|
|
||||||
const savedEntries = await createRecurringEntries(entry, repeatCount);
|
|
||||||
savedEntries.forEach(saved => {
|
|
||||||
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 || '';
|
|
||||||
setRecurrenceMode(true);
|
|
||||||
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>
|
|
||||||
@ -4,20 +4,6 @@
|
|||||||
{# page title #}
|
{# page title #}
|
||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block head_extra %}
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<style>
|
|
||||||
#dashboard-map {
|
|
||||||
width: 100%;
|
|
||||||
height: 420px;
|
|
||||||
min-height: 320px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{# page content #}
|
{# page content #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@ -199,24 +185,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map + Location Distribution -->
|
<!-- IP Address Access Data -->
|
||||||
<div class="row mb-4">
|
<div class="card mb-4">
|
||||||
<div class="col-lg-7 mb-3">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Zugriffe auf der Karte</h5>
|
|
||||||
<div id="dashboard-map"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-5 mb-3">
|
|
||||||
<div class="card h-100">
|
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
Verteilung der Zugriffe
|
Verteilung der Zugriffe
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-bordered mb-0">
|
<table class="table table-bordered">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Anzahl Downloads</th>
|
<th>Anzahl Downloads</th>
|
||||||
@ -241,8 +217,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -251,65 +225,6 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const rawMapData = {{ map_data|tojson|default('[]') }};
|
|
||||||
const mapData = Array.isArray(rawMapData) ? rawMapData : [];
|
|
||||||
|
|
||||||
// Leaflet map with aggregated downloads (fail-safe so charts still render)
|
|
||||||
try {
|
|
||||||
(function initDashboardMap() {
|
|
||||||
const mapElement = document.getElementById('dashboard-map');
|
|
||||||
if (!mapElement || typeof L === 'undefined') return;
|
|
||||||
|
|
||||||
const map = L.map(mapElement).setView([50, 10], 4);
|
|
||||||
const bounds = L.latLngBounds();
|
|
||||||
const defaultCenter = [50, 10];
|
|
||||||
const defaultZoom = 4;
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
mapData.forEach(point => {
|
|
||||||
if (point.lat === null || point.lon === null) return;
|
|
||||||
const radius = Math.max(6, 4 + Math.log(point.count + 1) * 4);
|
|
||||||
const marker = L.circleMarker([point.lat, point.lon], {
|
|
||||||
radius,
|
|
||||||
color: '#ff0000',
|
|
||||||
fillColor: '#ff4444',
|
|
||||||
fillOpacity: 0.75,
|
|
||||||
weight: 2
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
marker.bindPopup(`
|
|
||||||
<strong>${point.city}, ${point.country}</strong><br>
|
|
||||||
Downloads: ${point.count}
|
|
||||||
`);
|
|
||||||
bounds.extend([point.lat, point.lon]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mapData.length === 0) {
|
|
||||||
const msg = document.createElement('div');
|
|
||||||
msg.textContent = 'Keine Geo-Daten für den ausgewählten Zeitraum.';
|
|
||||||
msg.className = 'text-muted small mt-3';
|
|
||||||
mapElement.appendChild(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bounds.isEmpty()) {
|
|
||||||
map.fitBounds(bounds, { padding: [24, 24] });
|
|
||||||
} else {
|
|
||||||
map.setView(defaultCenter, defaultZoom);
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshSize = () => setTimeout(() => map.invalidateSize(), 150);
|
|
||||||
refreshSize();
|
|
||||||
window.addEventListener('resize', refreshSize);
|
|
||||||
window.addEventListener('orientationchange', refreshSize);
|
|
||||||
})();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Dashboard map init failed:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data passed from the backend as JSON
|
// Data passed from the backend as JSON
|
||||||
let distinctDeviceData = {{ distinct_device_data|tojson }};
|
let distinctDeviceData = {{ distinct_device_data|tojson }};
|
||||||
let timeframeData = {{ timeframe_data|tojson }};
|
let timeframeData = {{ timeframe_data|tojson }};
|
||||||
|
|||||||
@ -39,17 +39,6 @@
|
|||||||
let editing = new Set();
|
let editing = new Set();
|
||||||
let pendingDelete = null;
|
let pendingDelete = null;
|
||||||
|
|
||||||
// QR Code generation function using backend
|
|
||||||
async function generateQRCode(secret, imgElement) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/admin/generate_qr/${encodeURIComponent(secret)}`);
|
|
||||||
const data = await response.json();
|
|
||||||
imgElement.src = `data:image/png;base64,${data.qr_code}`;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating QR code:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper to format DD.MM.YYYY → YYYY-MM-DD
|
// helper to format DD.MM.YYYY → YYYY-MM-DD
|
||||||
function formatISO(d) {
|
function formatISO(d) {
|
||||||
const [dd, mm, yyyy] = d.split('.');
|
const [dd, mm, yyyy] = d.split('.');
|
||||||
@ -98,39 +87,14 @@
|
|||||||
wrapper.className = 'card mb-3';
|
wrapper.className = 'card mb-3';
|
||||||
wrapper.dataset.secret = key;
|
wrapper.dataset.secret = key;
|
||||||
|
|
||||||
// Card header for collapse toggle
|
|
||||||
const cardHeader = document.createElement('div');
|
|
||||||
cardHeader.className = 'card-header';
|
|
||||||
cardHeader.style.cursor = 'pointer';
|
|
||||||
const collapseId = `collapse-${key}`;
|
|
||||||
cardHeader.setAttribute('data-bs-toggle', 'collapse');
|
|
||||||
cardHeader.setAttribute('data-bs-target', `#${collapseId}`);
|
|
||||||
cardHeader.setAttribute('aria-expanded', 'false');
|
|
||||||
cardHeader.setAttribute('aria-controls', collapseId);
|
|
||||||
|
|
||||||
const headerTitle = document.createElement('div');
|
|
||||||
headerTitle.className = 'd-flex justify-content-between align-items-center';
|
|
||||||
const folderNames = rec.folders.map(f => f.foldername).join(', ');
|
|
||||||
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
|
|
||||||
cardHeader.appendChild(headerTitle);
|
|
||||||
wrapper.appendChild(cardHeader);
|
|
||||||
|
|
||||||
// Collapsible body
|
|
||||||
const collapseDiv = document.createElement('div');
|
|
||||||
collapseDiv.className = 'collapse';
|
|
||||||
collapseDiv.id = collapseId;
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = `card-body ${cls}`;
|
body.className = `card-body ${cls}`;
|
||||||
|
|
||||||
// Create row for two-column layout (only if not editing)
|
// header
|
||||||
if (!isEdit) {
|
const h5 = document.createElement('h5');
|
||||||
const topRow = document.createElement('div');
|
folderNames = rec.folders.map(f => f.foldername).join(', ');
|
||||||
topRow.className = 'row mb-3';
|
h5.innerHTML = `Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}`;
|
||||||
|
body.appendChild(h5);
|
||||||
// Left column for secret and validity
|
|
||||||
const leftCol = document.createElement('div');
|
|
||||||
leftCol.className = 'col-md-8';
|
|
||||||
|
|
||||||
// secret input
|
// secret input
|
||||||
const secDiv = document.createElement('div');
|
const secDiv = document.createElement('div');
|
||||||
@ -140,54 +104,7 @@
|
|||||||
secInput.className = 'form-control';
|
secInput.className = 'form-control';
|
||||||
secInput.type = 'text';
|
secInput.type = 'text';
|
||||||
secInput.value = rec.secret;
|
secInput.value = rec.secret;
|
||||||
secInput.readOnly = true;
|
secInput.readOnly = !isEdit;
|
||||||
secInput.dataset.field = 'secret';
|
|
||||||
secDiv.appendChild(secInput);
|
|
||||||
leftCol.appendChild(secDiv);
|
|
||||||
|
|
||||||
// validity input
|
|
||||||
const valDiv = document.createElement('div');
|
|
||||||
valDiv.className = 'mb-2';
|
|
||||||
valDiv.innerHTML = `Gültig bis: `;
|
|
||||||
const valInput = document.createElement('input');
|
|
||||||
valInput.className = 'form-control';
|
|
||||||
valInput.type = 'date';
|
|
||||||
valInput.value = formatISO(rec.validity);
|
|
||||||
valInput.readOnly = true;
|
|
||||||
valInput.dataset.field = 'validity';
|
|
||||||
valDiv.appendChild(valInput);
|
|
||||||
leftCol.appendChild(valDiv);
|
|
||||||
|
|
||||||
topRow.appendChild(leftCol);
|
|
||||||
|
|
||||||
// Right column for QR code
|
|
||||||
const rightCol = document.createElement('div');
|
|
||||||
rightCol.className = 'col-md-4 text-center';
|
|
||||||
|
|
||||||
const qrImg = document.createElement('img');
|
|
||||||
qrImg.className = 'qr-code';
|
|
||||||
qrImg.style.maxWidth = '200px';
|
|
||||||
qrImg.style.width = '100%';
|
|
||||||
qrImg.style.border = '1px solid #ddd';
|
|
||||||
qrImg.style.padding = '10px';
|
|
||||||
qrImg.style.borderRadius = '5px';
|
|
||||||
qrImg.alt = 'QR Code';
|
|
||||||
generateQRCode(key, qrImg);
|
|
||||||
rightCol.appendChild(qrImg);
|
|
||||||
|
|
||||||
topRow.appendChild(rightCol);
|
|
||||||
body.appendChild(topRow);
|
|
||||||
} else {
|
|
||||||
// When editing, show fields vertically without QR code
|
|
||||||
// secret input
|
|
||||||
const secDiv = document.createElement('div');
|
|
||||||
secDiv.className = 'mb-2';
|
|
||||||
secDiv.innerHTML = `Secret: `;
|
|
||||||
const secInput = document.createElement('input');
|
|
||||||
secInput.className = 'form-control';
|
|
||||||
secInput.type = 'text';
|
|
||||||
secInput.value = rec.secret;
|
|
||||||
secInput.readOnly = false;
|
|
||||||
secInput.dataset.field = 'secret';
|
secInput.dataset.field = 'secret';
|
||||||
secDiv.appendChild(secInput);
|
secDiv.appendChild(secInput);
|
||||||
body.appendChild(secDiv);
|
body.appendChild(secDiv);
|
||||||
@ -200,11 +117,10 @@
|
|||||||
valInput.className = 'form-control';
|
valInput.className = 'form-control';
|
||||||
valInput.type = 'date';
|
valInput.type = 'date';
|
||||||
valInput.value = formatISO(rec.validity);
|
valInput.value = formatISO(rec.validity);
|
||||||
valInput.readOnly = false;
|
valInput.readOnly = !isEdit;
|
||||||
valInput.dataset.field = 'validity';
|
valInput.dataset.field = 'validity';
|
||||||
valDiv.appendChild(valInput);
|
valDiv.appendChild(valInput);
|
||||||
body.appendChild(valDiv);
|
body.appendChild(valDiv);
|
||||||
}
|
|
||||||
|
|
||||||
// folders
|
// folders
|
||||||
const folderHeader = document.createElement('h6');
|
const folderHeader = document.createElement('h6');
|
||||||
@ -303,23 +219,22 @@
|
|||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
const saveBtn = document.createElement('button');
|
const saveBtn = document.createElement('button');
|
||||||
saveBtn.className = 'btn btn-success btn-sm me-2';
|
saveBtn.className = 'btn btn-success btn-sm';
|
||||||
saveBtn.type = 'button';
|
saveBtn.type = 'button';
|
||||||
saveBtn.textContent = 'speichern';
|
saveBtn.textContent = 'speichern';
|
||||||
saveBtn.addEventListener('click', () => saveRec(key));
|
saveBtn.addEventListener('click', () => saveRec(key));
|
||||||
actions.appendChild(saveBtn);
|
actions.appendChild(saveBtn);
|
||||||
|
} else {
|
||||||
const cancelBtn = document.createElement('button');
|
const editBtn = document.createElement('button');
|
||||||
cancelBtn.className = 'btn btn-secondary btn-sm';
|
editBtn.className = 'btn btn-warning btn-sm';
|
||||||
cancelBtn.type = 'button';
|
editBtn.type = 'button';
|
||||||
cancelBtn.textContent = 'abbrechen';
|
editBtn.textContent = 'bearbeiten';
|
||||||
cancelBtn.addEventListener('click', () => cancelEdit(key));
|
editBtn.addEventListener('click', () => editRec(key));
|
||||||
actions.appendChild(cancelBtn);
|
actions.appendChild(editBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.appendChild(actions);
|
body.appendChild(actions);
|
||||||
collapseDiv.appendChild(body);
|
wrapper.appendChild(body);
|
||||||
wrapper.appendChild(collapseDiv);
|
|
||||||
cont.appendChild(wrapper);
|
cont.appendChild(wrapper);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -334,11 +249,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CRUD helpers
|
// CRUD helpers
|
||||||
function cancelEdit(secret) {
|
function editRec(secret) {
|
||||||
editing.delete(secret);
|
editing.add(secret);
|
||||||
// If this was a new record that was never saved, remove it
|
render();
|
||||||
const originalExists = data.some(r => r.secret === secret && !editing.has(secret));
|
|
||||||
loadData(); // Reload to discard changes
|
|
||||||
}
|
}
|
||||||
function cloneRec(secret) {
|
function cloneRec(secret) {
|
||||||
const idx = data.findIndex(r => r.secret === secret);
|
const idx = data.findIndex(r => r.secret === secret);
|
||||||
@ -355,14 +268,6 @@
|
|||||||
data.splice(idx+1, 0, rec);
|
data.splice(idx+1, 0, rec);
|
||||||
editing.add(rec.secret);
|
editing.add(rec.secret);
|
||||||
render();
|
render();
|
||||||
// Auto-expand the cloned item
|
|
||||||
setTimeout(() => {
|
|
||||||
const collapseEl = document.getElementById(`collapse-${rec.secret}`);
|
|
||||||
if (collapseEl) {
|
|
||||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
|
||||||
bsCollapse.show();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
async function renewRec(secret) {
|
async function renewRec(secret) {
|
||||||
// find current record
|
// find current record
|
||||||
@ -459,14 +364,6 @@
|
|||||||
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
|
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
|
||||||
editing.add(newSecret);
|
editing.add(newSecret);
|
||||||
render();
|
render();
|
||||||
// Auto-expand the new item
|
|
||||||
setTimeout(() => {
|
|
||||||
const collapseEl = document.getElementById(`collapse-${newSecret}`);
|
|
||||||
if (collapseEl) {
|
|
||||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
|
||||||
bsCollapse.show();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
});
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
<!-- 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">
|
|
||||||
<i class="bi bi-plus-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<div id="messages-container" class="messages-container">
|
|
||||||
<!-- Messages will be loaded here via JavaScript -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Message 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>
|
|
||||||
Loading…
x
Reference in New Issue
Block a user