Compare commits
11 Commits
7b1c97d066
...
44ec9fc0a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 44ec9fc0a0 | |||
| 51f99b75d4 | |||
| c4ba71aadc | |||
| 1c71ea60a8 | |||
| e959c83a0d | |||
| 492b47a927 | |||
| 2a171fbc8b | |||
| 1f38e3acd2 | |||
| f0e7e9c165 | |||
| d7bdb646fd | |||
| c797960fa3 |
5
.gitignore
vendored
5
.gitignore
vendored
@ -16,4 +16,7 @@
|
||||
/app_config.json
|
||||
/custom_logo
|
||||
/static/theme.css
|
||||
/transcription_config.yml
|
||||
/transcription_config.yml
|
||||
|
||||
/messages.json
|
||||
/calendar.db
|
||||
33
analytics.py
33
analytics.py
@ -572,6 +572,24 @@ def dashboard():
|
||||
cursor = log_db.execute(query, params_for_filter)
|
||||
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
|
||||
# total_accesses
|
||||
query = f'''
|
||||
@ -629,6 +647,20 @@ def dashboard():
|
||||
location_data.sort(key=lambda x: x['count'], reverse=True)
|
||||
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_long = app_config.get('TITLE_LONG' , 'Default Title')
|
||||
|
||||
@ -644,6 +676,7 @@ def dashboard():
|
||||
unique_user=unique_user,
|
||||
cached_percentage=cached_percentage,
|
||||
timeframe_data=timeframe_data,
|
||||
map_data=map_data,
|
||||
admin_enabled=auth.is_admin(),
|
||||
title_short=title_short,
|
||||
title_long=title_long
|
||||
|
||||
389
app.py
389
app.py
@ -1,4 +1,5 @@
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
# Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader
|
||||
FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
|
||||
@ -24,6 +25,8 @@ from pathlib import Path
|
||||
import re
|
||||
import qrcode
|
||||
import base64
|
||||
import json
|
||||
import io
|
||||
import search
|
||||
import auth
|
||||
import analytics as a
|
||||
@ -31,6 +34,7 @@ import folder_secret_config_editor as fsce
|
||||
import helperfunctions as hf
|
||||
import search_db_analyzer as sdb
|
||||
import fnmatch
|
||||
import openpyxl
|
||||
|
||||
app_config = auth.return_app_config()
|
||||
BASE_DIR = os.path.realpath(app_config['BASE_DIR'])
|
||||
@ -62,10 +66,380 @@ 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/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.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/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))
|
||||
|
||||
# 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
|
||||
host_rule = os.getenv("HOST_RULE", "")
|
||||
# Use a regex to extract domain names between backticks in patterns like Host(`something`)
|
||||
@ -808,11 +1182,24 @@ def handle_request_map_data():
|
||||
@auth.require_secret
|
||||
def index(path):
|
||||
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",
|
||||
search_folders = list(session['folders'].keys()),
|
||||
search_folders = folder_names,
|
||||
title_short=app_config.get('TITLE_SHORT', '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()
|
||||
)
|
||||
|
||||
|
||||
76
auth.py
76
auth.py
@ -103,36 +103,21 @@ def require_secret(f):
|
||||
|
||||
# 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.
|
||||
for secret_in_session in session['valid_secrets'][:]:
|
||||
for secret_in_session in session.get('valid_secrets', [])[:]:
|
||||
# Find the current config item with matching secret
|
||||
config_item = next(
|
||||
(c for c in folder_config if c['secret'] == secret_in_session),
|
||||
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):
|
||||
session['valid_secrets'].remove(secret_in_session)
|
||||
|
||||
for token_in_session in session['valid_tokens'][:]:
|
||||
for token_in_session in session.get('valid_tokens', [])[:]:
|
||||
if not is_valid_token(token_in_session):
|
||||
session['valid_tokens'].remove(token_in_session)
|
||||
|
||||
# 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']
|
||||
|
||||
# Check for admin access first
|
||||
args_admin = request.args.get('admin', None)
|
||||
config_admin = app_config.get('ADMIN_KEY', None)
|
||||
|
||||
@ -144,6 +129,41 @@ def require_secret(f):
|
||||
print(f"Admin access denied with key: {args_admin}")
|
||||
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
|
||||
if session['folders']:
|
||||
# assume since visitor has a valid secret, they are ok with annonymous tracking
|
||||
@ -151,14 +171,16 @@ def require_secret(f):
|
||||
if 'device_id' not in session:
|
||||
session['device_id'] = os.urandom(32).hex()
|
||||
|
||||
# AUTO-JUMP FOR TOKENS
|
||||
try:
|
||||
if args_token and is_valid_token(args_token):
|
||||
token_item = decode_token(args_token)
|
||||
target_foldername = token_item['folders'][0]['foldername']
|
||||
return redirect(f"path/{target_foldername}")
|
||||
except Exception as e:
|
||||
print(f"Error during auto-jump: {e}")
|
||||
# AUTO-JUMP FOR TOKENS - Disabled for now to debug
|
||||
# try:
|
||||
# if args_token and is_valid_token(args_token):
|
||||
# token_item = decode_token(args_token)
|
||||
# target_foldername = token_item['folders'][0]['foldername']
|
||||
# # Mark session as modified to ensure it's saved before redirect
|
||||
# session.modified = True
|
||||
# return redirect(f"/path/{target_foldername}")
|
||||
# except Exception as e:
|
||||
# print(f"Error during auto-jump: {e}")
|
||||
|
||||
return f(*args, **kwargs)
|
||||
else:
|
||||
|
||||
23
readme.md
23
readme.md
@ -9,6 +9,13 @@ 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.
|
||||
- **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**
|
||||
- Local transcription of audio files
|
||||
- Full-text search through transcripts
|
||||
@ -139,6 +146,22 @@ docker compose up -d
|
||||
|
||||
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
|
||||
|
||||
This app is under active development and contributions are welcome.
|
||||
|
||||
519
static/app.css
519
static/app.css
@ -35,11 +35,25 @@ body {
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
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 {
|
||||
max-width: 1200px;
|
||||
width: 90%;
|
||||
@ -367,4 +381,503 @@ footer {
|
||||
|
||||
.toggle-icon:hover {
|
||||
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) {
|
||||
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 => {
|
||||
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
|
||||
share_link = `<a href="#" class="create-share" data-url="${dir.path}">⚙️</a>`;
|
||||
|
||||
481
static/messages.js
Normal file
481
static/messages.js
Normal file
@ -0,0 +1,481 @@
|
||||
// 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,9 +6,10 @@
|
||||
|
||||
<title>{{ title_short }}</title>
|
||||
|
||||
<meta property="og:title" content="{{ title_long }}" />
|
||||
<meta property="og:description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft." />
|
||||
<meta property="og:title" content="{{ og_title }}" />
|
||||
<meta property="og:description" content="{{ og_description }}" />
|
||||
<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="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
|
||||
@ -59,7 +60,18 @@
|
||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" >Ordnerkonfiguration</a>
|
||||
</div>
|
||||
{% 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 id="breadcrumbs" class="breadcrumb"></div>
|
||||
<div id="content"></div>
|
||||
@ -75,6 +87,9 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% include 'messages_section.html' %}
|
||||
{% include 'calendar_section.html' %}
|
||||
|
||||
<search style="display: none;">
|
||||
<div class="container">
|
||||
<form id="searchForm" method="post" class="mb-4">
|
||||
@ -249,6 +264,7 @@
|
||||
<script src="{{ url_for('static', filename='app.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='messages.js') }}"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
|
||||
755
templates/calendar_section.html
Normal file
755
templates/calendar_section.html
Normal file
@ -0,0 +1,755 @@
|
||||
<!-- 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,6 +4,20 @@
|
||||
{# page title #}
|
||||
{% 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 #}
|
||||
{% block content %}
|
||||
|
||||
@ -185,35 +199,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Address Access Data -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
Verteilung der Zugriffe
|
||||
<!-- Map + Location Distribution -->
|
||||
<div class="row 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="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Anzahl Downloads</th>
|
||||
<th>Stadt</th>
|
||||
<th>Land</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loc in location_data %}
|
||||
<tr>
|
||||
<td>{{ loc.count }}</td>
|
||||
<td>{{ loc.city }}</td>
|
||||
<td>{{ loc.country }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">No access data available for the selected timeframe.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="col-lg-5 mb-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
Verteilung der Zugriffe
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Anzahl Downloads</th>
|
||||
<th>Stadt</th>
|
||||
<th>Land</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loc in location_data %}
|
||||
<tr>
|
||||
<td>{{ loc.count }}</td>
|
||||
<td>{{ loc.city }}</td>
|
||||
<td>{{ loc.country }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">No access data available for the selected timeframe.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -225,6 +251,65 @@
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></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
|
||||
let distinctDeviceData = {{ distinct_device_data|tojson }};
|
||||
let timeframeData = {{ timeframe_data|tojson }};
|
||||
|
||||
@ -39,6 +39,17 @@
|
||||
let editing = new Set();
|
||||
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
|
||||
function formatISO(d) {
|
||||
const [dd, mm, yyyy] = d.split('.');
|
||||
@ -87,40 +98,113 @@
|
||||
wrapper.className = 'card mb-3';
|
||||
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');
|
||||
body.className = `card-body ${cls}`;
|
||||
|
||||
// header
|
||||
const h5 = document.createElement('h5');
|
||||
folderNames = rec.folders.map(f => f.foldername).join(', ');
|
||||
h5.innerHTML = `Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}`;
|
||||
body.appendChild(h5);
|
||||
// Create row for two-column layout (only if not editing)
|
||||
if (!isEdit) {
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'row mb-3';
|
||||
|
||||
// Left column for secret and validity
|
||||
const leftCol = document.createElement('div');
|
||||
leftCol.className = 'col-md-8';
|
||||
|
||||
// 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 = true;
|
||||
secInput.dataset.field = 'secret';
|
||||
secDiv.appendChild(secInput);
|
||||
leftCol.appendChild(secDiv);
|
||||
|
||||
// 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 = !isEdit;
|
||||
secInput.dataset.field = 'secret';
|
||||
secDiv.appendChild(secInput);
|
||||
body.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';
|
||||
secDiv.appendChild(secInput);
|
||||
body.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 = !isEdit;
|
||||
valInput.dataset.field = 'validity';
|
||||
valDiv.appendChild(valInput);
|
||||
body.appendChild(valDiv);
|
||||
// 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 = false;
|
||||
valInput.dataset.field = 'validity';
|
||||
valDiv.appendChild(valInput);
|
||||
body.appendChild(valDiv);
|
||||
}
|
||||
|
||||
// folders
|
||||
const folderHeader = document.createElement('h6');
|
||||
@ -219,22 +303,23 @@
|
||||
|
||||
if (isEdit) {
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn btn-success btn-sm';
|
||||
saveBtn.className = 'btn btn-success btn-sm me-2';
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.textContent = 'speichern';
|
||||
saveBtn.addEventListener('click', () => saveRec(key));
|
||||
actions.appendChild(saveBtn);
|
||||
} else {
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-warning btn-sm';
|
||||
editBtn.type = 'button';
|
||||
editBtn.textContent = 'bearbeiten';
|
||||
editBtn.addEventListener('click', () => editRec(key));
|
||||
actions.appendChild(editBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn btn-secondary btn-sm';
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.textContent = 'abbrechen';
|
||||
cancelBtn.addEventListener('click', () => cancelEdit(key));
|
||||
actions.appendChild(cancelBtn);
|
||||
}
|
||||
|
||||
body.appendChild(actions);
|
||||
wrapper.appendChild(body);
|
||||
collapseDiv.appendChild(body);
|
||||
wrapper.appendChild(collapseDiv);
|
||||
cont.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
@ -249,9 +334,11 @@
|
||||
}
|
||||
|
||||
// CRUD helpers
|
||||
function editRec(secret) {
|
||||
editing.add(secret);
|
||||
render();
|
||||
function cancelEdit(secret) {
|
||||
editing.delete(secret);
|
||||
// If this was a new record that was never saved, remove it
|
||||
const originalExists = data.some(r => r.secret === secret && !editing.has(secret));
|
||||
loadData(); // Reload to discard changes
|
||||
}
|
||||
function cloneRec(secret) {
|
||||
const idx = data.findIndex(r => r.secret === secret);
|
||||
@ -268,6 +355,14 @@
|
||||
data.splice(idx+1, 0, rec);
|
||||
editing.add(rec.secret);
|
||||
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) {
|
||||
// find current record
|
||||
@ -364,6 +459,14 @@
|
||||
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
|
||||
editing.add(newSecret);
|
||||
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();
|
||||
});
|
||||
|
||||
80
templates/messages_section.html
Normal file
80
templates/messages_section.html
Normal file
@ -0,0 +1,80 @@
|
||||
<!-- 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