Compare commits

..

11 Commits

Author SHA1 Message Date
44ec9fc0a0 add map to dashboard 2025-12-20 10:01:26 +00:00
51f99b75d4 add feature based rendering 2025-12-20 09:28:58 +00:00
c4ba71aadc add: rerender 2025-12-19 09:47:47 +00:00
1c71ea60a8 improve calendar 2025-12-19 09:42:07 +00:00
e959c83a0d add: wiederholungen 2025-12-19 09:30:50 +00:00
492b47a927 add: feiertage 2025-12-19 09:11:04 +00:00
2a171fbc8b fix desktop table in calendar 2025-12-19 07:59:24 +00:00
1f38e3acd2 add calendar 2025-12-17 20:33:22 +00:00
f0e7e9c165 improvements 2025-12-17 14:39:36 +00:00
d7bdb646fd improve folder config 2025-12-17 14:05:40 +00:00
c797960fa3 add messages 2025-12-17 13:49:32 +00:00
13 changed files with 2606 additions and 105 deletions

3
.gitignore vendored
View File

@ -17,3 +17,6 @@
/custom_logo /custom_logo
/static/theme.css /static/theme.css
/transcription_config.yml /transcription_config.yml
/messages.json
/calendar.db

View File

@ -572,6 +572,24 @@ def dashboard():
cursor = log_db.execute(query, params_for_filter) cursor = log_db.execute(query, params_for_filter)
locations = cursor.fetchall() locations = cursor.fetchall()
# Map data grouped by coordinates
map_rows = []
try:
query = f'''
SELECT city, country, latitude, longitude, COUNT(*) as count
FROM file_access_log
WHERE timestamp >= ? {filetype_filter_sql}
AND latitude IS NOT NULL AND longitude IS NOT NULL
GROUP BY city, country, latitude, longitude
ORDER BY count DESC
'''
with log_db:
cursor = log_db.execute(query, params_for_filter)
map_rows = cursor.fetchall()
except sqlite3.OperationalError as exc:
# Keep dashboard working even if older DBs lack location columns
print(f"[dashboard] map query skipped: {exc}")
# 7. Summary stats # 7. Summary stats
# total_accesses # total_accesses
query = f''' query = f'''
@ -629,6 +647,20 @@ def dashboard():
location_data.sort(key=lambda x: x['count'], reverse=True) location_data.sort(key=lambda x: x['count'], reverse=True)
location_data = location_data[:20] location_data = location_data[:20]
# Prepare map data (limit to keep map readable)
map_data = []
for city, country, lat, lon, cnt in map_rows:
if lat is None or lon is None:
continue
map_data.append({
'city': city,
'country': country,
'lat': lat,
'lon': lon,
'count': cnt
})
map_data = map_data[:200]
title_short = app_config.get('TITLE_SHORT', 'Default Title') title_short = app_config.get('TITLE_SHORT', 'Default Title')
title_long = app_config.get('TITLE_LONG' , 'Default Title') title_long = app_config.get('TITLE_LONG' , 'Default Title')
@ -644,6 +676,7 @@ def dashboard():
unique_user=unique_user, unique_user=unique_user,
cached_percentage=cached_percentage, cached_percentage=cached_percentage,
timeframe_data=timeframe_data, timeframe_data=timeframe_data,
map_data=map_data,
admin_enabled=auth.is_admin(), admin_enabled=auth.is_admin(),
title_short=title_short, title_short=title_short,
title_long=title_long title_long=title_long

389
app.py
View File

@ -1,4 +1,5 @@
import os import os
import sqlite3
# Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader # Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader
FLASK_ENV = os.environ.get('FLASK_ENV', 'production') FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
@ -24,6 +25,8 @@ from pathlib import Path
import re import re
import qrcode import qrcode
import base64 import base64
import json
import io
import search import search
import auth import auth
import analytics as a import analytics as a
@ -31,6 +34,7 @@ import folder_secret_config_editor as fsce
import helperfunctions as hf import helperfunctions as hf
import search_db_analyzer as sdb import search_db_analyzer as sdb
import fnmatch import fnmatch
import openpyxl
app_config = auth.return_app_config() app_config = auth.return_app_config()
BASE_DIR = os.path.realpath(app_config['BASE_DIR']) BASE_DIR = os.path.realpath(app_config['BASE_DIR'])
@ -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', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config)) app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config))
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST']) app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
@app.route('/admin/generate_qr/<secret>')
@auth.require_admin
def generate_qr_code(secret):
scheme = request.scheme
host = request.host
url = f"{scheme}://{host}?secret={secret}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode('ascii')
return jsonify({'qr_code': img_base64})
app.add_url_rule('/admin/search_db_analyzer', view_func=auth.require_admin(sdb.search_db_analyzer)) app.add_url_rule('/admin/search_db_analyzer', view_func=auth.require_admin(sdb.search_db_analyzer))
app.add_url_rule('/admin/search_db_analyzer/query', view_func=auth.require_admin(sdb.search_db_query), methods=['POST']) app.add_url_rule('/admin/search_db_analyzer/query', view_func=auth.require_admin(sdb.search_db_query), methods=['POST'])
app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_admin(sdb.search_db_folders)) app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_admin(sdb.search_db_folders))
# Messages/News routes
MESSAGES_FILENAME = 'messages.json'
CALENDAR_DB = 'calendar.db'
def load_messages():
"""Load messages from JSON file."""
try:
with open(MESSAGES_FILENAME, 'r', encoding='utf-8') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_messages(messages):
"""Save messages to JSON file."""
with open(MESSAGES_FILENAME, 'w', encoding='utf-8') as f:
json.dump(messages, f, ensure_ascii=False, indent=2)
def init_calendar_db():
"""Ensure calendar table exists."""
conn = sqlite3.connect(CALENDAR_DB)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS calendar_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
time TEXT,
title TEXT NOT NULL,
location TEXT,
details TEXT,
created_at TEXT NOT NULL
)
"""
)
# Ensure details column exists for legacy DBs
cols = [r[1] for r in conn.execute("PRAGMA table_info(calendar_entries)").fetchall()]
if 'details' not in cols:
conn.execute("ALTER TABLE calendar_entries ADD COLUMN details TEXT")
conn.commit()
conn.commit()
conn.close()
def serialize_calendar_row(row):
return {
'id': row['id'],
'date': row['date'],
'time': row['time'] or '',
'title': row['title'],
'location': row['location'] or '',
'details': row['details'] or ''
}
init_calendar_db()
@app.route('/api/messages', methods=['GET'])
@auth.require_secret
def get_messages():
"""Get all messages."""
messages = load_messages()
# Sort by datetime descending (newest first)
messages.sort(key=lambda x: x.get('datetime', ''), reverse=True)
return jsonify(messages)
@app.route('/api/messages', methods=['POST'])
@auth.require_admin
def create_message():
"""Create a new message."""
data = request.get_json()
messages = load_messages()
# Generate new ID
new_id = max([m.get('id', 0) for m in messages], default=0) + 1
new_message = {
'id': new_id,
'title': data.get('title', ''),
'content': data.get('content', ''),
'datetime': data.get('datetime', datetime.now().isoformat())
}
messages.append(new_message)
save_messages(messages)
return jsonify(new_message), 201
@app.route('/api/messages/<int:message_id>', methods=['PUT'])
@auth.require_admin
def update_message(message_id):
"""Update an existing message."""
data = request.get_json()
messages = load_messages()
for message in messages:
if message['id'] == message_id:
message['title'] = data.get('title', message['title'])
message['content'] = data.get('content', message['content'])
message['datetime'] = data.get('datetime', message['datetime'])
save_messages(messages)
return jsonify(message)
return jsonify({'error': 'Message not found'}), 404
@app.route('/api/messages/<int:message_id>', methods=['DELETE'])
@auth.require_admin
def delete_message(message_id):
"""Delete a message."""
messages = load_messages()
messages = [m for m in messages if m['id'] != message_id]
save_messages(messages)
return jsonify({'success': True})
@app.route('/api/calendar/<int:entry_id>', methods=['PUT'])
@auth.require_admin
def update_calendar_entry(entry_id):
"""Update a calendar entry."""
data = request.get_json() or {}
entry_date = data.get('date')
title = data.get('title')
if not entry_date or not title:
return jsonify({'error': 'date and title are required'}), 400
time_value = data.get('time') or ''
location = data.get('location') or ''
details = data.get('details') or ''
conn = sqlite3.connect(CALENDAR_DB)
conn.row_factory = sqlite3.Row
cur = conn.execute(
"""
UPDATE calendar_entries
SET date = ?, time = ?, title = ?, location = ?, details = ?
WHERE id = ?
""",
(entry_date, time_value, title, location, details, entry_id)
)
if cur.rowcount == 0:
conn.close()
return jsonify({'error': 'not found'}), 404
row = conn.execute(
"SELECT id, date, time, title, location, details FROM calendar_entries WHERE id = ?",
(entry_id,)
).fetchone()
conn.commit()
conn.close()
return jsonify(serialize_calendar_row(row))
@app.route('/api/calendar/<int:entry_id>', methods=['DELETE'])
@auth.require_admin
def delete_calendar_entry(entry_id):
"""Delete a calendar entry."""
conn = sqlite3.connect(CALENDAR_DB)
cur = conn.execute("DELETE FROM calendar_entries WHERE id = ?", (entry_id,))
conn.commit()
conn.close()
if cur.rowcount == 0:
return jsonify({'error': 'not found'}), 404
return jsonify({'success': True})
@app.route('/api/calendar/export', methods=['GET'])
@auth.require_admin
def export_calendar():
"""Export all calendar entries to an Excel file."""
conn = sqlite3.connect(CALENDAR_DB)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT date, time, title, location, details
FROM calendar_entries
ORDER BY date ASC, time ASC
"""
).fetchall()
conn.close()
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Kalender"
headers = ["date", "time", "title", "location", "details"]
ws.append(headers)
def fmt_date(date_str):
try:
return datetime.strptime(date_str, '%Y-%m-%d').strftime('%d.%m.%Y')
except Exception:
return date_str
for row in rows:
ws.append([
fmt_date(row['date']),
row['time'] or '',
row['title'],
row['location'] or '',
row['details'] or ''
])
output = io.BytesIO()
wb.save(output)
output.seek(0)
response = make_response(output.getvalue())
response.headers['Content-Disposition'] = 'attachment; filename=kalender.xlsx'
response.headers['Content-Type'] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
return response
@app.route('/api/calendar/import', methods=['POST'])
@auth.require_admin
def import_calendar():
"""Import calendar entries from an uploaded Excel file, replacing existing entries."""
if 'file' not in request.files or request.files['file'].filename == '':
return jsonify({'error': 'No file uploaded'}), 400
file = request.files['file']
try:
wb = openpyxl.load_workbook(file, data_only=True)
except Exception as e:
return jsonify({'error': f'Invalid Excel file: {e}'}), 400
ws = wb.active
headers = [str(cell.value).strip().lower() if cell.value else '' for cell in next(ws.iter_rows(min_row=1, max_row=1, values_only=False))]
required = {'date', 'title'}
optional = {'time', 'location', 'details'}
all_allowed = required | optional
header_map = {}
for idx, h in enumerate(headers):
if h in all_allowed and h not in header_map:
header_map[h] = idx
if not required.issubset(set(header_map.keys())):
return jsonify({'error': 'Header must include at least date and title columns'}), 400
entries = []
for row in ws.iter_rows(min_row=2, values_only=True):
entry = {}
for key, col_idx in header_map.items():
value = row[col_idx] if col_idx < len(row) else ''
entry[key] = value if value is not None else ''
# Normalize
entry_date_raw = entry.get('date', '')
entry_date = str(entry_date_raw).strip()
if not entry_date:
continue
try:
# Allow Excel date cells
if isinstance(entry_date_raw, (datetime, date)):
entry_date = entry_date_raw.strftime('%Y-%m-%d')
elif '.' in entry_date:
entry_date = datetime.strptime(entry_date, '%d.%m.%Y').strftime('%Y-%m-%d')
else:
# Expect yyyy-mm-dd
datetime.strptime(entry_date, '%Y-%m-%d')
except Exception:
return jsonify({'error': f'Invalid date format: {entry_date}'}), 400
entries.append({
'date': entry_date,
'time': str(entry.get('time', '') or '').strip(),
'title': str(entry.get('title', '') or '').strip(),
'location': str(entry.get('location', '') or '').strip(),
'details': str(entry.get('details', '') or '').strip()
})
conn = sqlite3.connect(CALENDAR_DB)
try:
cur = conn.cursor()
cur.execute("DELETE FROM calendar_entries")
cur.executemany(
"""
INSERT INTO calendar_entries (date, time, title, location, details, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
[
(
e['date'],
e['time'],
e['title'],
e['location'],
e['details'],
datetime.utcnow().isoformat()
) for e in entries
]
)
conn.commit()
finally:
conn.close()
return jsonify({'success': True, 'count': len(entries)})
@app.route('/api/calendar', methods=['GET'])
@auth.require_secret
def get_calendar_entries():
"""Return calendar entries between start and end date (inclusive)."""
start = request.args.get('start') or date.today().isoformat()
end = request.args.get('end') or (date.today() + timedelta(weeks=4)).isoformat()
conn = sqlite3.connect(CALENDAR_DB)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT id, date, time, title, location, details
FROM calendar_entries
WHERE date BETWEEN ? AND ?
ORDER BY date ASC, time ASC
""",
(start, end)
).fetchall()
conn.close()
return jsonify([serialize_calendar_row(r) for r in rows])
@app.route('/api/calendar', methods=['POST'])
@auth.require_admin
def create_calendar_entry():
"""Store a new calendar entry."""
data = request.get_json() or {}
entry_date = data.get('date')
title = data.get('title')
if not entry_date or not title:
return jsonify({'error': 'date and title are required'}), 400
time_value = data.get('time') or ''
location = data.get('location') or ''
details = data.get('details') or ''
conn = sqlite3.connect(CALENDAR_DB)
cur = conn.execute(
"""
INSERT INTO calendar_entries (date, time, title, location, details, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(entry_date, time_value, title, location, details, datetime.utcnow().isoformat())
)
conn.commit()
new_id = cur.lastrowid
conn.close()
return jsonify({
'id': new_id,
'date': entry_date,
'time': time_value,
'title': title,
'location': location,
'details': details
}), 201
# Grab the HOST_RULE environment variable # Grab the HOST_RULE environment variable
host_rule = os.getenv("HOST_RULE", "") host_rule = os.getenv("HOST_RULE", "")
# Use a regex to extract domain names between backticks in patterns like Host(`something`) # Use a regex to extract domain names between backticks in patterns like Host(`something`)
@ -809,10 +1183,23 @@ def handle_request_map_data():
def index(path): def index(path):
app_config = auth.return_app_config() app_config = auth.return_app_config()
# Generate dynamic Open Graph meta tags for Telegram preview
og_title = app_config.get('TITLE_LONG', 'Default Title')
og_description = "... uns aber, die wir gerettet werden, ist es eine Gotteskraft."
# If secret or token is provided, show folder names in description
folder_names = list(session['folders'].keys())
if folder_names:
folder_list = ", ".join(folder_names)
og_description = folder_list
return render_template("app.html", return render_template("app.html",
search_folders = list(session['folders'].keys()), search_folders = folder_names,
title_short=app_config.get('TITLE_SHORT', 'Default Title'), title_short=app_config.get('TITLE_SHORT', 'Default Title'),
title_long=app_config.get('TITLE_LONG' , 'Default Title'), title_long=app_config.get('TITLE_LONG' , 'Default Title'),
features=app_config.get('FEATURES', None),
og_title=og_title,
og_description=og_description,
admin_enabled=auth.is_admin() admin_enabled=auth.is_admin()
) )

76
auth.py
View File

@ -103,36 +103,21 @@ def require_secret(f):
# 4) Re-check validity of each secret and token to also catch previous ones # 4) Re-check validity of each secret and token to also catch previous ones
# If a secret is no longer valid (or not in config), remove it. # If a secret is no longer valid (or not in config), remove it.
for secret_in_session in session['valid_secrets'][:]: for secret_in_session in session.get('valid_secrets', [])[:]:
# Find the current config item with matching secret # Find the current config item with matching secret
config_item = next( config_item = next(
(c for c in folder_config if c['secret'] == secret_in_session), (c for c in folder_config if c['secret'] == secret_in_session),
None None
) )
# If the config item doesnt exist or is invalid, remove secret # If the config item doesn't exist or is invalid, remove secret
if config_item is None or not is_valid_secret(config_item, secret_in_session): if config_item is None or not is_valid_secret(config_item, secret_in_session):
session['valid_secrets'].remove(secret_in_session) session['valid_secrets'].remove(secret_in_session)
for token_in_session in session['valid_tokens'][:]: for token_in_session in session.get('valid_tokens', [])[:]:
if not is_valid_token(token_in_session): if not is_valid_token(token_in_session):
session['valid_tokens'].remove(token_in_session) session['valid_tokens'].remove(token_in_session)
# 5) Build session['folders'] fresh from the valid secrets # Check for admin access first
session['folders'] = {}
for secret_in_session in session.get('valid_secrets', []):
config_item = next(
(c for c in folder_config if c['secret'] == secret_in_session),
None
)
if config_item:
for folder_info in config_item['folders']:
session['folders'][folder_info['foldername']] = folder_info['folderpath']
for token_in_session in session.get('valid_tokens', []):
token_item = decode_token(token_in_session)
for folder_info in token_item['folders']:
session['folders'][folder_info['foldername']] = folder_info['folderpath']
args_admin = request.args.get('admin', None) args_admin = request.args.get('admin', None)
config_admin = app_config.get('ADMIN_KEY', None) config_admin = app_config.get('ADMIN_KEY', None)
@ -144,6 +129,41 @@ def require_secret(f):
print(f"Admin access denied with key: {args_admin}") print(f"Admin access denied with key: {args_admin}")
session['admin'] = False session['admin'] = False
# 5) Build session['folders'] fresh from the valid secrets
session['folders'] = {}
if is_admin():
# Admin gets access to all folders in the config
for config_item in folder_config:
for folder_info in config_item['folders']:
session['folders'][folder_info['foldername']] = folder_info['folderpath']
else:
# Normal users only get folders from their valid secrets
for secret_in_session in session.get('valid_secrets', []):
config_item = next(
(c for c in folder_config if c['secret'] == secret_in_session),
None
)
if config_item:
for folder_info in config_item['folders']:
session['folders'][folder_info['foldername']] = folder_info['folderpath']
# Add token folders for both admin and regular users
for token_in_session in session.get('valid_tokens', []):
try:
token_item = decode_token(token_in_session)
print(f"DEBUG: Decoded token: {token_item}")
for folder_info in token_item.get('folders', []):
print(f"DEBUG: Adding folder '{folder_info['foldername']}' -> '{folder_info['folderpath']}'")
session['folders'][folder_info['foldername']] = folder_info['folderpath']
except Exception as e:
print(f"ERROR: Failed to process token: {e}")
# Mark session as modified to ensure it's saved
session.modified = True
print(f"DEBUG: Final session['folders'] keys: {list(session['folders'].keys())}")
print(f"DEBUG: session['valid_tokens']: {session.get('valid_tokens', [])}")
# 6) If we have folders, proceed; otherwise show index # 6) If we have folders, proceed; otherwise show index
if session['folders']: if session['folders']:
# assume since visitor has a valid secret, they are ok with annonymous tracking # assume since visitor has a valid secret, they are ok with annonymous tracking
@ -151,14 +171,16 @@ def require_secret(f):
if 'device_id' not in session: if 'device_id' not in session:
session['device_id'] = os.urandom(32).hex() session['device_id'] = os.urandom(32).hex()
# AUTO-JUMP FOR TOKENS # AUTO-JUMP FOR TOKENS - Disabled for now to debug
try: # try:
if args_token and is_valid_token(args_token): # if args_token and is_valid_token(args_token):
token_item = decode_token(args_token) # token_item = decode_token(args_token)
target_foldername = token_item['folders'][0]['foldername'] # target_foldername = token_item['folders'][0]['foldername']
return redirect(f"path/{target_foldername}") # # Mark session as modified to ensure it's saved before redirect
except Exception as e: # session.modified = True
print(f"Error during auto-jump: {e}") # return redirect(f"/path/{target_foldername}")
# except Exception as e:
# print(f"Error during auto-jump: {e}")
return f(*args, **kwargs) return f(*args, **kwargs)
else: else:

View File

@ -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. - **Primary Folders (Secret Links):** Centralized configuration with full admin control. Suitable for public events.
- **Subfolders (Token Links):** Can be shared ad hoc, without central admin access. Ideal for limited-audience events. - **Subfolders (Token Links):** Can be shared ad hoc, without central admin access. Ideal for limited-audience events.
- **News/Messages System**
- Blog-style news cards with date/time stamps
- Admin interface for creating, editing, and deleting messages
- Markdown support for rich content formatting
- Separate tab for browsing files and viewing messages
- Chronological display (newest first)
- **Transcription and Search** - **Transcription and Search**
- Local transcription of audio files - Local transcription of audio files
- Full-text search through transcripts - Full-text search through transcripts
@ -139,6 +146,22 @@ docker compose up -d
To unlock administrative controls on your device, use the dedicated admin access link. To unlock administrative controls on your device, use the dedicated admin access link.
### 6. Managing Messages/News
The application includes a news/messages system that allows admins to post announcements and updates:
- **Viewing Messages**: All users can view messages by clicking the "Nachrichten" tab in the main interface
- **Adding Messages**: Admins can click "Nachricht hinzufügen" to create a new message
- **Editing Messages**: Click the "Bearbeiten" button on any message card
- **Deleting Messages**: Click the "Löschen" button to remove a message
Messages are stored in `messages.json` and support:
- **Markdown formatting** for rich content (headings, lists, links, code blocks, etc.)
- **Date/time stamps** for chronological organization
- **Blog-style display** with newest messages first
To pre-populate messages, you can manually edit `messages.json` or copy from `example_config_files/messages.json` as a template.
## Contribution ## Contribution
This app is under active development and contributions are welcome. This app is under active development and contributions are welcome.

View File

@ -35,11 +35,25 @@ body {
} }
.wrapper { .wrapper {
display: grid; display: flex;
grid-template-rows: auto 1fr auto; flex-direction: column;
min-height: 100%; min-height: 100%;
padding-bottom: 200px; padding-bottom: 200px;
} }
.wrapper > .admin-nav,
.wrapper > .main-tabs {
flex: 0 0 auto;
}
.wrapper > main,
.wrapper > section {
flex: 1 1 auto;
}
.wrapper > footer {
flex: 0 0 auto;
}
.container { .container {
max-width: 1200px; max-width: 1200px;
width: 90%; width: 90%;
@ -368,3 +382,502 @@ footer {
.toggle-icon:hover { .toggle-icon:hover {
color: #007bff; color: #007bff;
} }
/* Tab Navigation Styles */
.main-tabs {
background-color: var(--dark-background);
display: flex;
justify-content: center;
padding: 0;
margin: 0;
gap: 2px;
border-bottom: 2px solid #444;
}
.tab-button {
background: #333;
border: 1px solid #444;
border-bottom: none;
color: #999;
padding: 6px 20px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 5px 5px 0 0;
margin: 0;
position: relative;
top: 2px;
}
.tab-button:hover {
color: #fff;
background: #3a3a3a;
}
.tab-button.active {
color: var(--main-text-color, #000);
background: var(--light-background);
border-color: #444;
border-bottom-color: var(--light-background);
z-index: 1;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Messages Section Styles */
.messages-section-header {
max-width: 900px;
margin: 0 auto;
padding: 0 0 8px;
}
.messages-container {
max-width: 900px;
margin: 0 auto;
padding: 20px 0;
}
.message-card {
background: var(--card-background, #fff);
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.3s ease;
}
.message-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #e0e0e0;
}
.message-title {
font-size: 1.5em;
font-weight: bold;
color: var(--main-text-color);
margin: 0;
}
.message-datetime {
color: #666;
font-size: 0.9em;
font-style: italic;
}
.message-content {
line-height: 1.6;
color: var(--main-text-color);
}
.message-content .folder-link-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
margin: 4px 0;
background: #f0f4f8;
color: #495057;
border: 1px solid #d1dce5;
border-radius: 6px;
font-size: 0.95em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.message-content .folder-link-btn:hover {
background: #e3eaf0;
border-color: #b8c5d0;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.12);
}
.message-content .folder-link-btn:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
}
.message-content .folder-link-btn i {
font-size: 1.1em;
}
.message-content a {
color: #007bff;
text-decoration: underline;
cursor: pointer;
}
.message-content a:hover {
color: #0056b3;
text-decoration: underline;
}
.message-content h1,
.message-content h2,
.message-content h3 {
margin-top: 20px;
margin-bottom: 10px;
}
.message-content h1 {
font-size: 1.5rem;
}
.message-content h2 {
font-size: 1.3rem;
}
.message-content h3 {
font-size: 1.1rem;
}
.message-content h4 {
font-size: 1rem;
}
.message-content h5 {
font-size: 0.9rem;
}
.message-content h6 {
font-size: 0.85rem;
}
.message-content p {
margin-bottom: 10px;
}
.message-content ul,
.message-content ol {
margin-left: 20px;
margin-bottom: 10px;
}
.message-content code {
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.message-content pre {
background-color: #f4f4f4;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
}
.message-content blockquote {
border-left: 4px solid #ccc;
padding-left: 15px;
color: #666;
font-style: italic;
}
.message-image {
width: 100%;
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 15px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.message-actions {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 10px;
}
.btn-edit,
.btn-delete {
padding: 5px 15px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.btn-edit {
background-color: #007bff;
color: white;
}
.btn-edit:hover {
background-color: #0056b3;
}
.btn-delete {
background-color: #dc3545;
color: white;
}
.btn-delete:hover {
background-color: #c82333;
}
.no-messages {
text-align: center;
padding: 40px;
color: #999;
font-size: 1.2em;
}
/* Calendar Section */
#calendar-section .container {
max-width: none;
width: 100%;
padding-left: 20px;
padding-right: 20px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0;
border: 1px solid #ddd;
border-top: 0;
border-radius: 0 0 6px 6px;
overflow: visible;
}
.calendar-weekday-header {
display: none;
}
.calendar-day {
background: var(--card-background, #fff);
border-bottom: 1px solid #ddd;
padding: 8px 10px 4px;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
}
.calendar-day-today {
background: #f7fbff;
box-shadow: inset 0 0 0 2px #4da3ff;
border-color: #4da3ff;
}
.calendar-day-sunday,
.calendar-day-holiday {
background: #fff7f7;
}
.calendar-day-today.calendar-day-sunday,
.calendar-day-today.calendar-day-holiday {
background: linear-gradient(135deg, #f7fbff 0%, #f7fbff 60%, #fff2f2 100%);
}
.calendar-day-sunday .calendar-day-header,
.calendar-day-holiday .calendar-day-header {
color: #b93838;
}
.calendar-holiday-badge {
display: inline-block;
margin-left: 6px;
padding: 2px 8px;
border-radius: 999px;
background: #ffe3e3;
color: #b93838;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.calendar-day:last-child {
border-bottom: none;
}
.calendar-day-hidden {
visibility: hidden;
pointer-events: none;
}
@media (min-width: 992px) {
.calendar-grid {
grid-template-columns: repeat(7, minmax(0, 1fr));
border-radius: 0 0 8px 8px;
}
.calendar-weekday-header {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
text-align: center;
font-weight: 600;
color: #999;
border: 1px solid #ddd;
border-bottom: 0;
border-radius: 8px 8px 0 0;
overflow: visible;
position: sticky;
top: 70px;
z-index: 900;
background: var(--card-background, #fff);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
}
.calendar-day {
border-bottom: 1px solid #ddd;
border-right: 1px solid #ddd;
}
.calendar-day:nth-child(7n) {
border-right: none;
}
.calendar-day-name {
display: none;
}
}
.calendar-day-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
gap: 8px;
}
.calendar-day-name {
font-weight: 600;
text-transform: capitalize;
}
.calendar-day-date {
color: #666;
font-size: 0.95rem;
white-space: nowrap;
}
.calendar-day table {
margin-bottom: 0;
table-layout: auto;
width: 100%;
}
.calendar-day thead th {
background: #f1f1f1;
color: #666;
border-color: #ddd;
}
.calendar-day.empty thead {
display: none;
}
.calendar-entries-list td {
vertical-align: middle;
}
.calendar-table-wrapper {
width: 100%;
}
@media (min-width: 992px) {
.calendar-table-wrapper {
box-sizing: border-box;
margin: 0;
max-height: 400px;
overflow-y: auto;
overflow-x: auto;
padding: 0;
}
}
.calendar-day .btn-group .btn {
padding: 2px 8px;
}
.details-indicator {
font-weight: 700;
color: #666;
margin-left: 6px;
cursor: pointer;
position: relative;
top: -1px;
}
.calendar-actions-col {
width: 1%;
white-space: nowrap;
}
.calendar-details-text {
white-space: pre-wrap;
margin: 0;
}
.calendar-details-row td {
background: #f8f9fa;
}
.calendar-inline-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
/* File Browser Styles */
.file-browser-content {
max-height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 5px;
}
.file-browser-content .list-group-item {
border-left: none;
border-right: none;
border-radius: 0;
}
.file-browser-content .list-group-item:first-child {
border-top: none;
}
.file-browser-content .list-group-item:last-child {
border-bottom: none;
}
#fileBrowserCollapse {
margin-top: 0;
margin-bottom: 15px;
}
.file-browser-panel {
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 5px;
padding: 15px;
margin-bottom: 10px;
}
.file-browser-panel h6 {
margin-top: 0;
margin-bottom: 10px;
color: #333;
}

View File

@ -116,7 +116,7 @@ function renderContent(data) {
if (data.breadcrumbs.length === 1 && data.toplist_enabled) { if (data.breadcrumbs.length === 1 && data.toplist_enabled) {
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="toplist">🔥 oft angehört</a></li>`; contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="toplist">🔥 oft angehört</a></li>`;
} }
console.log(data.folder_today, data.folder_yesterday);
data.directories.forEach(dir => { data.directories.forEach(dir => {
if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) { if (admin_enabled && data.breadcrumbs.length != 1 && dir.share) {
share_link = `<a href="#" class="create-share" data-url="${dir.path}">⚙️</a>`; share_link = `<a href="#" class="create-share" data-url="${dir.path}">⚙️</a>`;

481
static/messages.js Normal file
View 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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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();
}

View File

@ -6,9 +6,10 @@
<title>{{ title_short }}</title> <title>{{ title_short }}</title>
<meta property="og:title" content="{{ title_long }}" /> <meta property="og:title" content="{{ og_title }}" />
<meta property="og:description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft." /> <meta property="og:description" content="{{ og_description }}" />
<meta property="og:image" content="/icon/logo-192x192.png" /> <meta property="og:image" content="/icon/logo-192x192.png" />
<meta property="og:type" content="website" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft."> <meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
@ -59,7 +60,18 @@
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" >Ordnerkonfiguration</a> <a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" >Ordnerkonfiguration</a>
</div> </div>
{% endif %} {% endif %}
<main>
{% if features %}
<!-- Tab Navigation -->
<nav class="main-tabs">
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
</nav>
{% endif %}
<!-- Browse Section -->
<main id="browse-section" class="tab-content active">
<div class="container"> <div class="container">
<div id="breadcrumbs" class="breadcrumb"></div> <div id="breadcrumbs" class="breadcrumb"></div>
<div id="content"></div> <div id="content"></div>
@ -75,6 +87,9 @@
</div> </div>
</main> </main>
{% include 'messages_section.html' %}
{% include 'calendar_section.html' %}
<search style="display: none;"> <search style="display: none;">
<div class="container"> <div class="container">
<form id="searchForm" method="post" class="mb-4"> <form id="searchForm" method="post" class="mb-4">
@ -249,6 +264,7 @@
<script src="{{ url_for('static', filename='app.js') }}"></script> <script src="{{ url_for('static', filename='app.js') }}"></script>
<script src="{{ url_for('static', filename='gallery.js') }}"></script> <script src="{{ url_for('static', filename='gallery.js') }}"></script>
<script src="{{ url_for('static', filename='search.js') }}"></script> <script src="{{ url_for('static', filename='search.js') }}"></script>
<script src="{{ url_for('static', filename='messages.js') }}"></script>
<script> <script>
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {

View 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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
}
function createEmptyRow() {
const row = document.createElement('tr');
row.className = 'calendar-empty-row';
row.innerHTML = `<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>`;
return row;
}
function formatDisplayTime(timeStr) {
if (!timeStr) return '';
const match = String(timeStr).match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
if (match) {
const h = match[1].padStart(2, '0');
const m = match[2];
return `${h}:${m}`;
}
return timeStr;
}
// Location filter helpers
let locationOptions = new Set();
function resetLocationFilterOptions() {
locationOptions = new Set();
const select = document.getElementById('calendar-location-filter');
if (!select) return;
select.innerHTML = '<option value="">Ort (alle)</option>';
}
function addLocationOption(location) {
if (!location) return;
const normalized = location.trim();
if (!normalized || locationOptions.has(normalized)) return;
locationOptions.add(normalized);
const select = document.getElementById('calendar-location-filter');
if (!select) return;
const opt = document.createElement('option');
opt.value = normalized;
opt.textContent = normalized;
select.appendChild(opt);
}
function applyLocationFilter() {
const select = document.getElementById('calendar-location-filter');
const filterValue = select ? select.value : '';
document.querySelectorAll('.calendar-day').forEach(day => {
const tbody = day.querySelector('.calendar-entries-list');
if (!tbody) return;
// Remove any stale empty rows before recalculating
tbody.querySelectorAll('.calendar-empty-row').forEach(r => r.remove());
let visibleCount = 0;
const entryRows = tbody.querySelectorAll('tr.calendar-entry-row');
entryRows.forEach(row => {
const rowLoc = (row.dataset.location || '').trim();
const match = !filterValue || rowLoc === filterValue;
row.style.display = match ? '' : 'none';
const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row')
? row.nextElementSibling
: null;
if (detailsRow) {
const showDetails = match && detailsRow.dataset.expanded === 'true';
detailsRow.style.display = showDetails ? '' : 'none';
}
if (match) visibleCount += 1;
});
if (visibleCount === 0) {
day.classList.add('empty');
tbody.appendChild(createEmptyRow());
} else {
day.classList.remove('empty');
}
});
}
function removeCalendarEntryFromUI(id) {
if (!id) return;
const selector = `.calendar-entry-row[data-id="${id}"], .calendar-details-row[data-parent-id="${id}"]`;
const rows = document.querySelectorAll(selector);
rows.forEach(row => {
const tbody = row.parentElement;
const dayBlock = row.closest('.calendar-day');
row.remove();
const remainingEntries = tbody ? tbody.querySelectorAll('tr.calendar-entry-row').length : 0;
if (remainingEntries === 0 && dayBlock) {
resetDayBlock(dayBlock);
}
});
}
// Helper to insert entries sorted by time inside an existing day block
function addCalendarEntry(entry) {
if (!entry || !entry.date) return;
const block = document.querySelector(`.calendar-day[data-date="${entry.date}"]`);
if (!block) return;
const tbody = block.querySelector('.calendar-entries-list');
if (!tbody) return;
const emptyRow = tbody.querySelector('.calendar-empty-row');
if (emptyRow) emptyRow.remove();
block.classList.remove('empty');
const row = document.createElement('tr');
const timeValue = formatDisplayTime(entry.time || '');
row.dataset.time = timeValue;
row.dataset.id = entry.id || '';
row.dataset.date = entry.date;
row.dataset.title = entry.title || '';
row.dataset.location = entry.location || '';
row.dataset.details = entry.details || '';
row.className = 'calendar-entry-row';
row.innerHTML = `
<td>${timeValue || '-'}</td>
<td>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden"></span>' : ''}</td>
<td>${entry.location || ''}</td>
`;
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));
const insertIndex = rows.findIndex(r => {
const existingTime = r.dataset.time || '';
return timeValue && (!existingTime || timeValue < existingTime);
});
if (insertIndex === -1) {
tbody.appendChild(row);
} else {
tbody.insertBefore(row, rows[insertIndex]);
}
const detailsRow = document.createElement('tr');
detailsRow.className = 'calendar-details-row';
detailsRow.dataset.parentId = entry.id || '';
detailsRow.dataset.expanded = 'false';
const actionsHtml = window._calendarIsAdmin ? `
<div class="calendar-inline-actions">
<button type="button" class="btn btn-sm btn-outline-secondary calendar-edit" title="Bearbeiten"></button>
<button type="button" class="btn btn-sm btn-outline-danger calendar-delete" title="Löschen">🗑</button>
</div>
` : '';
const detailsText = entry.details ? escapeHtml(entry.details) : '';
detailsRow.innerHTML = `<td colspan="${window._calendarColSpan || 3}"><div class="calendar-details-text">${detailsText}</div>${actionsHtml}</td>`;
detailsRow.style.display = 'none';
row.insertAdjacentElement('afterend', detailsRow);
applyLocationFilter();
}
async function loadCalendarEntries() {
const { start, end } = getCalendarRange();
const todayIso = toLocalISO(start);
const endIso = toLocalISO(end);
clearCalendarEntries();
resetLocationFilterOptions();
try {
const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`);
if (!response.ok) throw new Error('Fehler beim Laden');
const entries = await response.json();
entries.forEach(entry => {
addLocationOption(entry.location);
addCalendarEntry(entry);
});
applyLocationFilter();
} catch (err) {
console.error('Kalender laden fehlgeschlagen', err);
}
}
async function saveCalendarEntry(entry) {
const isUpdate = Boolean(entry.id);
const url = isUpdate ? `/api/calendar/${entry.id}` : '/api/calendar';
const method = isUpdate ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
});
if (!response.ok) {
const msg = await response.text();
throw new Error(msg || 'Speichern fehlgeschlagen');
}
return response.json();
}
async function 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>

View File

@ -4,6 +4,20 @@
{# page title #} {# page title #}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
#dashboard-map {
width: 100%;
height: 420px;
min-height: 320px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
</style>
{% endblock %}
{# page content #} {# page content #}
{% block content %} {% block content %}
@ -185,14 +199,24 @@
</div> </div>
</div> </div>
<!-- IP Address Access Data --> <!-- Map + Location Distribution -->
<div class="card mb-4"> <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="col-lg-5 mb-3">
<div class="card h-100">
<div class="card-header"> <div class="card-header">
Verteilung der Zugriffe Verteilung der Zugriffe
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered"> <table class="table table-bordered mb-0">
<thead> <thead>
<tr> <tr>
<th>Anzahl Downloads</th> <th>Anzahl Downloads</th>
@ -217,6 +241,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
@ -225,6 +251,65 @@
{% block scripts %} {% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script> <script>
const rawMapData = {{ map_data|tojson|default('[]') }};
const mapData = Array.isArray(rawMapData) ? rawMapData : [];
// Leaflet map with aggregated downloads (fail-safe so charts still render)
try {
(function initDashboardMap() {
const mapElement = document.getElementById('dashboard-map');
if (!mapElement || typeof L === 'undefined') return;
const map = L.map(mapElement).setView([50, 10], 4);
const bounds = L.latLngBounds();
const defaultCenter = [50, 10];
const defaultZoom = 4;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
mapData.forEach(point => {
if (point.lat === null || point.lon === null) return;
const radius = Math.max(6, 4 + Math.log(point.count + 1) * 4);
const marker = L.circleMarker([point.lat, point.lon], {
radius,
color: '#ff0000',
fillColor: '#ff4444',
fillOpacity: 0.75,
weight: 2
}).addTo(map);
marker.bindPopup(`
<strong>${point.city}, ${point.country}</strong><br>
Downloads: ${point.count}
`);
bounds.extend([point.lat, point.lon]);
});
if (mapData.length === 0) {
const msg = document.createElement('div');
msg.textContent = 'Keine Geo-Daten für den ausgewählten Zeitraum.';
msg.className = 'text-muted small mt-3';
mapElement.appendChild(msg);
}
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: [24, 24] });
} else {
map.setView(defaultCenter, defaultZoom);
}
const refreshSize = () => setTimeout(() => map.invalidateSize(), 150);
refreshSize();
window.addEventListener('resize', refreshSize);
window.addEventListener('orientationchange', refreshSize);
})();
} catch (err) {
console.error('Dashboard map init failed:', err);
}
// Data passed from the backend as JSON // Data passed from the backend as JSON
let distinctDeviceData = {{ distinct_device_data|tojson }}; let distinctDeviceData = {{ distinct_device_data|tojson }};
let timeframeData = {{ timeframe_data|tojson }}; let timeframeData = {{ timeframe_data|tojson }};

View File

@ -39,6 +39,17 @@
let editing = new Set(); let editing = new Set();
let pendingDelete = null; let pendingDelete = null;
// QR Code generation function using backend
async function generateQRCode(secret, imgElement) {
try {
const response = await fetch(`/admin/generate_qr/${encodeURIComponent(secret)}`);
const data = await response.json();
imgElement.src = `data:image/png;base64,${data.qr_code}`;
} catch (error) {
console.error('Error generating QR code:', error);
}
}
// helper to format DD.MM.YYYY → YYYY-MM-DD // helper to format DD.MM.YYYY → YYYY-MM-DD
function formatISO(d) { function formatISO(d) {
const [dd, mm, yyyy] = d.split('.'); const [dd, mm, yyyy] = d.split('.');
@ -87,14 +98,39 @@
wrapper.className = 'card mb-3'; wrapper.className = 'card mb-3';
wrapper.dataset.secret = key; wrapper.dataset.secret = key;
// Card header for collapse toggle
const cardHeader = document.createElement('div');
cardHeader.className = 'card-header';
cardHeader.style.cursor = 'pointer';
const collapseId = `collapse-${key}`;
cardHeader.setAttribute('data-bs-toggle', 'collapse');
cardHeader.setAttribute('data-bs-target', `#${collapseId}`);
cardHeader.setAttribute('aria-expanded', 'false');
cardHeader.setAttribute('aria-controls', collapseId);
const headerTitle = document.createElement('div');
headerTitle.className = 'd-flex justify-content-between align-items-center';
const folderNames = rec.folders.map(f => f.foldername).join(', ');
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
cardHeader.appendChild(headerTitle);
wrapper.appendChild(cardHeader);
// Collapsible body
const collapseDiv = document.createElement('div');
collapseDiv.className = 'collapse';
collapseDiv.id = collapseId;
const body = document.createElement('div'); const body = document.createElement('div');
body.className = `card-body ${cls}`; body.className = `card-body ${cls}`;
// header // Create row for two-column layout (only if not editing)
const h5 = document.createElement('h5'); if (!isEdit) {
folderNames = rec.folders.map(f => f.foldername).join(', '); const topRow = document.createElement('div');
h5.innerHTML = `Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}`; topRow.className = 'row mb-3';
body.appendChild(h5);
// Left column for secret and validity
const leftCol = document.createElement('div');
leftCol.className = 'col-md-8';
// secret input // secret input
const secDiv = document.createElement('div'); const secDiv = document.createElement('div');
@ -104,7 +140,54 @@
secInput.className = 'form-control'; secInput.className = 'form-control';
secInput.type = 'text'; secInput.type = 'text';
secInput.value = rec.secret; secInput.value = rec.secret;
secInput.readOnly = !isEdit; secInput.readOnly = true;
secInput.dataset.field = 'secret';
secDiv.appendChild(secInput);
leftCol.appendChild(secDiv);
// validity input
const valDiv = document.createElement('div');
valDiv.className = 'mb-2';
valDiv.innerHTML = `Gültig bis: `;
const valInput = document.createElement('input');
valInput.className = 'form-control';
valInput.type = 'date';
valInput.value = formatISO(rec.validity);
valInput.readOnly = true;
valInput.dataset.field = 'validity';
valDiv.appendChild(valInput);
leftCol.appendChild(valDiv);
topRow.appendChild(leftCol);
// Right column for QR code
const rightCol = document.createElement('div');
rightCol.className = 'col-md-4 text-center';
const qrImg = document.createElement('img');
qrImg.className = 'qr-code';
qrImg.style.maxWidth = '200px';
qrImg.style.width = '100%';
qrImg.style.border = '1px solid #ddd';
qrImg.style.padding = '10px';
qrImg.style.borderRadius = '5px';
qrImg.alt = 'QR Code';
generateQRCode(key, qrImg);
rightCol.appendChild(qrImg);
topRow.appendChild(rightCol);
body.appendChild(topRow);
} else {
// When editing, show fields vertically without QR code
// secret input
const secDiv = document.createElement('div');
secDiv.className = 'mb-2';
secDiv.innerHTML = `Secret: `;
const secInput = document.createElement('input');
secInput.className = 'form-control';
secInput.type = 'text';
secInput.value = rec.secret;
secInput.readOnly = false;
secInput.dataset.field = 'secret'; secInput.dataset.field = 'secret';
secDiv.appendChild(secInput); secDiv.appendChild(secInput);
body.appendChild(secDiv); body.appendChild(secDiv);
@ -117,10 +200,11 @@
valInput.className = 'form-control'; valInput.className = 'form-control';
valInput.type = 'date'; valInput.type = 'date';
valInput.value = formatISO(rec.validity); valInput.value = formatISO(rec.validity);
valInput.readOnly = !isEdit; valInput.readOnly = false;
valInput.dataset.field = 'validity'; valInput.dataset.field = 'validity';
valDiv.appendChild(valInput); valDiv.appendChild(valInput);
body.appendChild(valDiv); body.appendChild(valDiv);
}
// folders // folders
const folderHeader = document.createElement('h6'); const folderHeader = document.createElement('h6');
@ -219,22 +303,23 @@
if (isEdit) { if (isEdit) {
const saveBtn = document.createElement('button'); 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.type = 'button';
saveBtn.textContent = 'speichern'; saveBtn.textContent = 'speichern';
saveBtn.addEventListener('click', () => saveRec(key)); saveBtn.addEventListener('click', () => saveRec(key));
actions.appendChild(saveBtn); actions.appendChild(saveBtn);
} else {
const editBtn = document.createElement('button'); const cancelBtn = document.createElement('button');
editBtn.className = 'btn btn-warning btn-sm'; cancelBtn.className = 'btn btn-secondary btn-sm';
editBtn.type = 'button'; cancelBtn.type = 'button';
editBtn.textContent = 'bearbeiten'; cancelBtn.textContent = 'abbrechen';
editBtn.addEventListener('click', () => editRec(key)); cancelBtn.addEventListener('click', () => cancelEdit(key));
actions.appendChild(editBtn); actions.appendChild(cancelBtn);
} }
body.appendChild(actions); body.appendChild(actions);
wrapper.appendChild(body); collapseDiv.appendChild(body);
wrapper.appendChild(collapseDiv);
cont.appendChild(wrapper); cont.appendChild(wrapper);
}); });
} }
@ -249,9 +334,11 @@
} }
// CRUD helpers // CRUD helpers
function editRec(secret) { function cancelEdit(secret) {
editing.add(secret); editing.delete(secret);
render(); // 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) { function cloneRec(secret) {
const idx = data.findIndex(r => r.secret === secret); const idx = data.findIndex(r => r.secret === secret);
@ -268,6 +355,14 @@
data.splice(idx+1, 0, rec); data.splice(idx+1, 0, rec);
editing.add(rec.secret); editing.add(rec.secret);
render(); render();
// Auto-expand the cloned item
setTimeout(() => {
const collapseEl = document.getElementById(`collapse-${rec.secret}`);
if (collapseEl) {
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
bsCollapse.show();
}
}, 100);
} }
async function renewRec(secret) { async function renewRec(secret) {
// find current record // find current record
@ -364,6 +459,14 @@
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] }); data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
editing.add(newSecret); editing.add(newSecret);
render(); render();
// Auto-expand the new item
setTimeout(() => {
const collapseEl = document.getElementById(`collapse-${newSecret}`);
if (collapseEl) {
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
bsCollapse.show();
}
}, 100);
}); });
loadData(); loadData();
}); });

View 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>