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

5
.gitignore vendored
View File

@ -16,4 +16,7 @@
/app_config.json
/custom_logo
/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)
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
View File

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

@ -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 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):
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:

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.
- **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.

View File

@ -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;
}

View File

@ -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
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>
<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', () => {

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 #}
{% 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 }};

View File

@ -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();
});

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>