Compare commits

...

10 Commits

Author SHA1 Message Date
ebf02f654a Merge remote-tracking branch 'origin/development' 2026-01-26 19:05:59 +00:00
5a3ad7ef6e gui improvements 2026-01-26 19:02:43 +00:00
6cc1fb8d56 preview for Liedtext 2026-01-26 18:21:36 +00:00
6f761435f3 add cache in ram for top list 2026-01-26 17:39:53 +00:00
9b3d594f1d add number of search results 2026-01-26 17:20:05 +00:00
c855bfdbc1 improve analyzer 2026-01-26 17:10:03 +00:00
6a27d47222 play files inside statistics 2026-01-26 16:41:57 +00:00
53b9fec7bd more global audio player 2026-01-26 15:14:31 +00:00
7b001efb35 Merge remote-tracking branch 'origin/master' into development 2026-01-26 10:48:34 +00:00
ac9182bf16 update transcription 2026-01-24 18:45:16 +01:00
33 changed files with 3365 additions and 2077 deletions

2
app.py
View File

@ -620,7 +620,7 @@ def generate_breadcrumbs(subpath=None):
for part in parts: for part in parts:
path_accum = f"{path_accum}/{part}" if path_accum else part path_accum = f"{path_accum}/{part}" if path_accum else part
if 'toplist' in part: if 'toplist' in part:
part = part.replace('toplist', 'oft angehört') part = part.replace('toplist', 'häufig angehört')
breadcrumbs.append({'name': part, 'path': path_accum}) breadcrumbs.append({'name': part, 'path': path_accum})
return breadcrumbs return breadcrumbs

View File

@ -3,6 +3,7 @@ import re
import os import os
import sqlite3 import sqlite3
from datetime import datetime, timedelta from datetime import datetime, timedelta
import time
from typing import Optional from typing import Optional
import auth import auth
@ -12,6 +13,9 @@ CATEGORY_KEYWORDS = app_config['CATEGORY_KEYWORDS']
log_db = sqlite3.connect("access_log.db", check_same_thread=False) log_db = sqlite3.connect("access_log.db", check_same_thread=False)
_TOPLIST_CACHE_TTL = 600
_TOPLIST_CACHE = {}
# Precompiled regex to find date-like patterns: either dotted X.Y.Z or ISO dashed YYYY-MM-DD # Precompiled regex to find date-like patterns: either dotted X.Y.Z or ISO dashed YYYY-MM-DD
@ -110,6 +114,16 @@ def extract_structure_from_string(input_string):
return category, titel, name return category, titel, name
def generate_top_list(category): def generate_top_list(category):
now_ts = time.time()
cache_key = (category, tuple(sorted(session.get('folders', {}).keys())))
cached = _TOPLIST_CACHE.get(cache_key)
if cached and now_ts - cached["ts"] < _TOPLIST_CACHE_TTL:
return list(cached["data"])
# Prune expired cache entries opportunistically.
for key, value in list(_TOPLIST_CACHE.items()):
if now_ts - value["ts"] >= _TOPLIST_CACHE_TTL:
_TOPLIST_CACHE.pop(key, None)
now = datetime.now() now = datetime.now()
@ -169,4 +183,5 @@ def generate_top_list(category):
'file_type': 'music' 'file_type': 'music'
}) })
_TOPLIST_CACHE[cache_key] = {"ts": now_ts, "data": list(filelist)}
return filelist return filelist

View File

@ -4,6 +4,7 @@ import sqlite3
from datetime import datetime from datetime import datetime
from time import monotonic from time import monotonic
import re import re
from typing import Optional
import helperfunctions as hf import helperfunctions as hf
SEARCH_DB_NAME = 'search.db' SEARCH_DB_NAME = 'search.db'
@ -139,6 +140,20 @@ def build_transcript_index(transcript_dir: str, stats: dict):
return index return index
except FileNotFoundError: except FileNotFoundError:
return None return None
def read_text_file(path: str) -> Optional[str]:
try:
with open(path, 'r', encoding='utf-8') as handle:
return handle.read()
except UnicodeDecodeError:
try:
with open(path, 'r', encoding='cp1252') as handle:
return handle.read()
except Exception:
return None
except Exception:
return None
except PermissionError: except PermissionError:
log_permission_error(transcript_dir, stats) log_permission_error(transcript_dir, stats)
return None return None
@ -264,6 +279,14 @@ def updatefileindex():
except Exception: except Exception:
transcript_errors += 1 transcript_errors += 1
if transcript is None and filetype == '.sng':
sng_text = read_text_file(entry_path)
if sng_text is not None:
transcript = sng_text
transcripts_read += 1
else:
transcript_errors += 1
category, titel, name = extract_structure(entry.name) category, titel, name = extract_structure(entry.name)
performance_date = extract_date(relative_path) performance_date = extract_date(relative_path)

View File

@ -3,6 +3,7 @@ from flask import Flask, render_template, request, request, jsonify, session
import random import random
import json import json
from datetime import datetime from datetime import datetime
from urllib.parse import quote
app = Flask(__name__) app = Flask(__name__)
@ -15,9 +16,11 @@ with open("app_config.json", 'r') as file:
FILETYPE_GROUPS = { FILETYPE_GROUPS = {
'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'), 'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'),
'video': ('.mp4', '.mov', '.mkv', '.avi', '.webm'), 'video': ('.mp4', '.mov', '.mkv', '.avi', '.webm'),
'image': ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff') 'image': ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff'),
'liedtext': ('.sng',)
} }
ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group})) ALL_GROUP_EXTS = tuple(sorted({ext for group in FILETYPE_GROUPS.values() for ext in group}))
ALL_GROUP_KEYS = set(FILETYPE_GROUPS.keys())
def searchcommand(): def searchcommand():
query = request.form.get("query", "").strip() query = request.form.get("query", "").strip()
@ -88,7 +91,7 @@ def searchcommand():
include_other = 'other' in filetypes include_other = 'other' in filetypes
# If not all groups selected, apply filter # If not all groups selected, apply filter
if set(filetypes) != {'audio', 'video', 'image', 'other'}: if set(filetypes) != (ALL_GROUP_KEYS | {'other'}):
clauses = [] clauses = []
if selected_groups: if selected_groups:
ext_list = tuple({ext for g in selected_groups for ext in FILETYPE_GROUPS[g]}) ext_list = tuple({ext for g in selected_groups for ext in FILETYPE_GROUPS[g]})
@ -108,6 +111,7 @@ def searchcommand():
sql += " WHERE " + " AND ".join(conditions) sql += " WHERE " + " AND ".join(conditions)
cursor.execute(sql, params) cursor.execute(sql, params)
raw_results = cursor.fetchall() raw_results = cursor.fetchall()
total_results = len(raw_results)
# Process results # Process results
results = [] results = []
@ -119,6 +123,8 @@ def searchcommand():
transcript.lower().count(w.lower()) for w in words transcript.lower().count(w.lower()) for w in words
) )
record.pop('transcript', None) record.pop('transcript', None)
record['fulltext_url'] = f"/media/{quote(record.get('relative_path', ''), safe='/')}"
record['fulltext_type'] = 'sng' if (record.get('filetype') or '').lower() == '.sng' else 'transcript'
# convert date to TT.MM.YYYY format # convert date to TT.MM.YYYY format
if record.get('performance_date'): if record.get('performance_date'):
try: try:
@ -135,5 +141,5 @@ def searchcommand():
results.sort(key=lambda x: x.get(key, 0), reverse=True) results.sort(key=lambda x: x.get(key, 0), reverse=True)
# Limit results # Limit results
results = results[:100] results = results[:20]
return jsonify(results=results) return jsonify(results=results, total=total_results)

View File

@ -10,6 +10,32 @@ APP_CONFIG = auth.return_app_config()
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SEARCH_DB_PATH = os.path.join(BASE_DIR, "search.db") SEARCH_DB_PATH = os.path.join(BASE_DIR, "search.db")
# Filetype buckets (extension-based, lowercase, leading dot).
FILETYPE_BUCKETS = [
("Liedtexte", {
".sng"
}),
("Image", {
".png", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".svg", ".ico"
}),
("Video", {
".mp4", ".m4v", ".mov", ".avi", ".mkv", ".webm", ".mpg", ".mpeg", ".3gp", ".3g2", ".wmv"
}),
("Photo", {
".jpg", ".jpeg", ".heic", ".heif", ".dng", ".cr2", ".cr3", ".arw", ".nef", ".orf", ".rw2", ".raf"
}),
("Document", {
".pdf", ".doc", ".docx", ".ppt", ".pptx", ".txt", ".rtf", ".md", ".odt", ".pages", ".key", ".note"
}),
("Table", {
".xls", ".xlsx", ".csv", ".ods", ".tsv"
}),
("Audio", {
".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma", ".aiff", ".alac"
}),
]
FILETYPE_ORDER = [label for label, _ in FILETYPE_BUCKETS] + ["Misc"]
# Open search.db in read-only mode to avoid accidental writes. # Open search.db in read-only mode to avoid accidental writes.
search_db = sqlite3.connect(f"file:{SEARCH_DB_PATH}?mode=ro", uri=True, check_same_thread=False) search_db = sqlite3.connect(f"file:{SEARCH_DB_PATH}?mode=ro", uri=True, check_same_thread=False)
search_db.row_factory = sqlite3.Row search_db.row_factory = sqlite3.Row
@ -20,6 +46,32 @@ def _normalize_folder_path(folder_path: str) -> str:
return folder_path.replace("\\", "/").strip().strip("/") return folder_path.replace("\\", "/").strip().strip("/")
def _bucket_for_filetype(filetype: str) -> str:
if not filetype:
return "Misc"
ft = str(filetype).strip().lower()
if not ft:
return "Misc"
if "/" in ft and not ft.startswith("."):
if ft.startswith("image/"):
return "Image"
if ft.startswith("video/"):
return "Video"
if ft.startswith("audio/"):
return "Audio"
if ft.startswith("text/") or ft in ("application/pdf",):
return "Document"
if not ft.startswith("."):
ft = f".{ft}"
for label, exts in FILETYPE_BUCKETS:
if ft in exts:
return label
return "Misc"
def _list_children(parent: str = "") -> List[str]: def _list_children(parent: str = "") -> List[str]:
""" """
Return the next folder level for the given parent. Return the next folder level for the given parent.
@ -82,13 +134,35 @@ def _query_counts(folder_path: str) -> Dict[str, Any]:
cursor = search_db.cursor() cursor = search_db.cursor()
rows = cursor.execute(sql, params).fetchall() rows = cursor.execute(sql, params).fetchall()
type_rows = cursor.execute(
f"""
SELECT COALESCE(filetype, '') AS filetype_label,
COUNT(*) AS file_count
FROM files
WHERE {where_sql}
GROUP BY filetype_label
""",
params
).fetchall()
total = sum(row["file_count"] for row in rows) total = sum(row["file_count"] for row in rows)
categories = [ categories = [
{"category": row["category_label"], "count": row["file_count"]} {"category": row["category_label"], "count": row["file_count"]}
for row in rows for row in rows
] ]
return {"total": total, "categories": categories} type_totals: Dict[str, int] = {}
for row in type_rows:
bucket = _bucket_for_filetype(row["filetype_label"])
type_totals[bucket] = type_totals.get(bucket, 0) + row["file_count"]
filetypes = [
{"type": label, "count": type_totals[label]}
for label in FILETYPE_ORDER
if type_totals.get(label)
]
return {"total": total, "categories": categories, "filetypes": filetypes}
def search_db_analyzer(): def search_db_analyzer():

View File

@ -59,6 +59,15 @@ main > * {
padding: 10px 20px; padding: 10px 20px;
} }
/* Ensure modals appear above the sticky header */
.modal {
z-index: 3000;
}
.modal-backdrop {
z-index: 2990;
}
.site-header img.logo { .site-header img.logo {
height: 54px; height: 54px;
margin-right: 6px; margin-right: 6px;
@ -80,13 +89,22 @@ main > * {
padding-bottom: 220px; padding-bottom: 220px;
} }
.wrapper > .admin-nav, .wrapper > .admin-nav {
.wrapper > .main-tabs {
flex: 0 0 auto; flex: 0 0 auto;
} }
.wrapper > main, .wrapper > #page-content {
.wrapper > section { flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.wrapper > #page-content > .main-tabs {
flex: 0 0 auto;
}
.wrapper > #page-content > main,
.wrapper > #page-content > section {
flex: 1 1 auto; flex: 1 1 auto;
padding-top: 6px; padding-top: 6px;
} }
@ -98,7 +116,7 @@ main > * {
.container { .container {
max-width: 1200px; max-width: 1200px;
width: 95%; width: 95%;
margin-top: 12px;
} }
.container a { .container a {
@ -110,16 +128,6 @@ main > * {
margin: 12px 0; margin: 12px 0;
} }
/* Carded content shell */
main.tab-content,
section.tab-content {
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--border-color);
box-shadow: none;
padding: 20px;
margin-top: -1px;
}
/* Search view container (custom element) */ /* Search view container (custom element) */
search { search {
display: block; display: block;
@ -165,7 +173,7 @@ search {
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 10px 16px; padding: 10px 16px;
margin: 0 0 14px; margin: 0;
} }
.breadcrumb a { .breadcrumb a {
@ -186,7 +194,6 @@ search {
color: var(--brand-ink); color: var(--brand-ink);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
margin-bottom: 12px;
width: 100vw; width: 100vw;
margin-left: calc(50% - 50vw); margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw); margin-right: calc(50% - 50vw);
@ -226,6 +233,12 @@ search {
flex-shrink: 0; flex-shrink: 0;
} }
.admin-nav .dropdown {
display: flex;
align-items: center;
flex-shrink: 0;
}
.admin-nav a:hover { .admin-nav a:hover {
color: var(--brand-sky); color: var(--brand-sky);
border-color: var(--border-color); border-color: var(--border-color);
@ -240,6 +253,8 @@ search {
.admin-nav .dropdown-toggle { .admin-nav .dropdown-toggle {
color: var(--brand-ink); color: var(--brand-ink);
padding: 8px 12px; padding: 8px 12px;
display: inline-flex;
align-items: center;
} }
.admin-nav .dropdown-toggle::after { .admin-nav .dropdown-toggle::after {
@ -442,6 +457,83 @@ footer .audio-player-container {
overflow-x: auto; overflow-x: auto;
} }
#transcriptContent .song-sheet {
display: flex;
flex-direction: column;
gap: 12px;
}
#transcriptContent .song-header h2 {
margin: 0;
font-size: 22px;
}
#transcriptContent .song-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
padding: 10px 12px;
background: #f8fbff;
border: 1px solid var(--border-color);
border-radius: 10px;
}
#transcriptContent .song-meta-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted-text);
margin-bottom: 4px;
}
#transcriptContent .song-meta-value {
font-weight: 600;
color: var(--brand-ink);
}
#transcriptContent .song-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
#transcriptContent .song-chip {
padding: 2px 8px;
border-radius: 999px;
background: #eef4ff;
color: var(--brand-ink);
font-size: 12px;
font-weight: 600;
}
#transcriptContent .song-section {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
box-shadow: var(--card-shadow);
}
#transcriptContent .song-section h4 {
margin: 0 0 8px;
font-size: 16px;
color: var(--brand-navy);
}
#transcriptContent .song-section p {
margin: 0;
white-space: pre-line;
line-height: 1.6;
}
#transcriptContent .song-translation {
color: var(--muted-text);
}
#transcriptContent .song-small {
font-size: 0.9em;
}
/* Center the reload control */ /* Center the reload control */
#directory-controls { #directory-controls {
@ -597,21 +689,26 @@ footer .audio-player-container {
/* Tab Navigation Styles */ /* Tab Navigation Styles */
.main-tabs { .main-tabs {
display: flex;
justify-content: flex-start;
padding: 6px 18px 0;
gap: 0;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
flex-wrap: nowrap;
border-radius: 0; border-radius: 0;
width: 100vw; width: 100vw;
margin-left: calc(50% - 50vw); margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw); margin-right: calc(50% - 50vw);
box-sizing: border-box; box-sizing: border-box;
overflow: visible;
}
.main-tabs-scroll {
display: flex;
justify-content: flex-start;
padding: 16px 18px 0;
gap: 0;
flex-wrap: nowrap;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: visible;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: thin; scrollbar-width: thin;
box-sizing: border-box;
} }
.tab-button { .tab-button {
@ -637,8 +734,7 @@ footer .audio-player-container {
} }
.tab-button:hover { .tab-button:hover {
color: var(--brand-ink); background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(230, 230, 230, 0.85));
background: rgba(255, 255, 255, 0.92);
} }
.tab-button.active { .tab-button.active {
@ -653,25 +749,33 @@ footer .audio-player-container {
margin-bottom: -1px; margin-bottom: -1px;
} }
/* Carded content shell */
.tab-content { .tab-content {
display: none; display: none;
background: rgba(255, 255, 255, 0.92);
border: 1px solid var(--border-color);
box-shadow: none;
padding: 20px;
margin-top: -1px;
} }
.tab-content.active { .tab-content.active {
display: block; display: block;
} }
/* Messages Section Styles */ /* Messages/Calendar shared structure */
.messages-section-header { .tab-section-header {
max-width: 900px; margin-top: 10px;
padding: 0;
}
.tab-section-body {
margin: 0 auto; margin: 0 auto;
padding: 0 0 10px; padding: 0 0 24px;
} }
.messages-container { .messages-container {
max-width: 900px; max-width: 900px;
margin: 0 auto;
padding: 10px 0 24px;
} }
.message-card { .message-card {
@ -876,20 +980,13 @@ footer .audio-player-container {
} }
/* Calendar Section */ /* Calendar Section */
#calendar-section .container {
max-width: none;
width: 100%;
padding-left: 20px;
padding-right: 20px;
}
.calendar-grid { .calendar-grid {
display: grid; display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0; gap: 10px;
border: 1px solid var(--border-color); border: 0;
border-top: 0; border-radius: 0;
border-radius: 0 0 10px 10px;
overflow: visible; overflow: visible;
} }
@ -899,14 +996,46 @@ footer .audio-player-container {
.calendar-day { .calendar-day {
background: #ffffff; background: #ffffff;
border-bottom: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 12px 12px 6px; border-radius: 0;
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.1);
padding: 16px 12px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: visible;
position: relative;
}
.calendar-day::before {
content: "";
position: absolute;
bottom: 0;
right: 0;
width: 26px;
height: 26px;
background: linear-gradient(315deg, #eef1f6 0%, #ffffff 70%);
box-shadow: -3px -3px 8px rgba(15, 23, 42, 0.12);
border-left: 1px solid var(--border-color);
border-top: 1px solid rgba(15, 23, 42, 0.08);
clip-path: polygon(0 100%, 100% 100%, 100% 0);
}
.calendar-day::after {
content: "";
position: absolute;
bottom: 0;
right: 0;
width: 26px;
height: 26px;
background: linear-gradient(315deg,
transparent 45%,
rgba(15, 23, 42, 0.18) 48%,
transparent 52%);
clip-path: polygon(0 100%, 100% 100%, 100% 0);
pointer-events: none;
} }
.calendar-day-today { .calendar-day-today {
@ -1207,3 +1336,8 @@ footer .audio-player-container {
background-color: rgba(246, 195, 68, 0.25); background-color: rgba(246, 195, 68, 0.25);
border-radius: 4px; border-radius: 4px;
} }
.file-access-actions {
flex-wrap: nowrap;
white-space: nowrap;
}

View File

@ -42,21 +42,28 @@ async function generateThumbsSequentially(entries) {
} }
} }
// Cache common DOM elements function getMainContainer() {
const mainContainer = document.querySelector('main'); return document.querySelector('main');
const searchContainer = document.querySelector('search'); }
const footer = document.querySelector('footer');
console.log(mainContainer, searchContainer, footer); function getSearchContainer() {
return document.querySelector('search');
}
function viewSearch() { function viewSearch() {
// Hide the main container and show the search container // Hide the main container and show the search container
const mainContainer = getMainContainer();
const searchContainer = getSearchContainer();
if (!mainContainer || !searchContainer) return;
mainContainer.style.display = 'none'; mainContainer.style.display = 'none';
searchContainer.style.display = 'block'; searchContainer.style.display = 'block';
} }
function viewMain() { function viewMain() {
// Hide the search container and show the main container // Hide the search container and show the main container
const mainContainer = getMainContainer();
const searchContainer = getSearchContainer();
if (!mainContainer || !searchContainer) return;
searchContainer.style.display = 'none'; searchContainer.style.display = 'none';
mainContainer.style.display = 'block'; mainContainer.style.display = 'block';
} }
@ -300,6 +307,8 @@ function renderContent(data) {
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') }); currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
} else if (file.file_type === 'image') { } else if (file.file_type === 'image') {
symbol = '<i class="bi bi-image"></i>'; symbol = '<i class="bi bi-image"></i>';
} else if ((file.name || '').toLowerCase().endsWith('.sng')) {
symbol = '<i class="bi bi-music-note-list"></i>';
} }
const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : ''; const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : '';
contentHTML += `<li class="file-item"> contentHTML += `<li class="file-item">
@ -478,6 +487,12 @@ document.querySelectorAll('.play-file').forEach(link => {
const { fileType, url: relUrl, index } = this.dataset; const { fileType, url: relUrl, index } = this.dataset;
const now = Date.now(); const now = Date.now();
const lowerUrl = (relUrl || '').toLowerCase();
if (lowerUrl.endsWith('.sng')) {
openSngModal(relUrl);
return;
}
if (fileType === 'music') { if (fileType === 'music') {
// If this is the same track already loaded, ignore to avoid extra GETs and unselects. // If this is the same track already loaded, ignore to avoid extra GETs and unselects.
@ -572,7 +587,6 @@ document.querySelectorAll('.play-file').forEach(link => {
link.addEventListener('click', function (event) { link.addEventListener('click', function (event) {
event.preventDefault(); event.preventDefault();
const url = '/create_token/' + this.getAttribute('data-url'); const url = '/create_token/' + this.getAttribute('data-url');
console.log(url);
fetch(url) fetch(url)
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
@ -581,7 +595,6 @@ document.querySelectorAll('.play-file').forEach(link => {
return response.text(); return response.text();
}) })
.then(data => { .then(data => {
console.log(data);
document.getElementById('transcriptContent').innerHTML = data; document.getElementById('transcriptContent').innerHTML = data;
document.getElementById('transcriptModal').style.display = 'block'; document.getElementById('transcriptModal').style.display = 'block';
}) })
@ -593,33 +606,224 @@ document.querySelectorAll('.play-file').forEach(link => {
}); });
}); });
// Handle back/forward navigation. // Handle back/forward navigation (bind once).
window.addEventListener('popstate', function (event) { if (!window.__appPopstateBound) {
const subpath = event.state ? event.state.subpath : ''; window.addEventListener('popstate', function (event) {
loadDirectory(subpath); const subpath = event.state ? event.state.subpath : '';
}); loadDirectory(subpath);
});
window.__appPopstateBound = true;
}
} // End of attachEventListeners function } // End of attachEventListeners function
// Modal close logic for transcript modal. function escapeHtml(text) {
document.addEventListener('DOMContentLoaded', function () { return (text || '').replace(/[&<>"']/g, (char) => ({
const closeBtn = document.querySelector('#transcriptModal .close'); '&': '&amp;',
closeBtn.addEventListener('click', function () { '<': '&lt;',
document.getElementById('transcriptModal').style.display = 'none'; '>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function formatSngLine(line) {
let safe = escapeHtml(line);
safe = safe.replace(/&lt;dfn&gt;/gi, '<span class="song-translation">');
safe = safe.replace(/&lt;\/dfn&gt;/gi, '</span>');
safe = safe.replace(/&lt;small&gt;/gi, '<span class="song-small">');
safe = safe.replace(/&lt;\/small&gt;/gi, '</span>');
return safe;
}
function parseSngText(rawText) {
const text = (rawText || '').replace(/\r/g, '');
const lines = text.split('\n');
const meta = {};
const sections = [];
let current = null;
let inBody = false;
const pushCurrent = () => {
if (!current) return;
while (current.lines.length && current.lines[current.lines.length - 1].trim() === '') {
current.lines.pop();
}
if (current.title || current.lines.length) {
sections.push(current);
}
current = null;
};
for (const rawLine of lines) {
const line = rawLine.replace(/\uFEFF/g, '');
const trimmed = line.trim();
if (!inBody && trimmed.startsWith('#')) {
const idx = trimmed.indexOf('=');
if (idx > 1) {
const key = trimmed.slice(1, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (key) meta[key] = value;
}
continue;
}
if (trimmed === '---' || trimmed === '-----' || trimmed === '----') {
inBody = true;
pushCurrent();
continue;
}
if (!inBody && trimmed === '') {
continue;
}
inBody = true;
if (!current) {
if (!trimmed) continue;
current = { title: trimmed, lines: [] };
continue;
}
current.lines.push(line);
}
pushCurrent();
if (!sections.length) {
const bodyLines = lines
.filter(l => l && !l.trim().startsWith('#') && !['---', '-----', '----'].includes(l.trim()));
if (bodyLines.length) {
sections.push({ title: '', lines: bodyLines });
}
}
return { meta, sections };
}
function buildSngHtml(parsed, fallbackTitle) {
const meta = parsed.meta || {};
const sections = parsed.sections || [];
const title = meta.Title || fallbackTitle || 'Liedtext';
const metaRows = [];
const pushMeta = (label, value, asChips = false) => {
if (!value) return;
if (asChips) {
const chips = value
.split(',')
.map(v => v.trim())
.filter(Boolean)
.map(v => `<span class="song-chip">${escapeHtml(v)}</span>`)
.join('');
if (chips) {
metaRows.push(`<div><div class="song-meta-label">${escapeHtml(label)}</div><div class="song-chip-row">${chips}</div></div>`);
}
return;
}
metaRows.push(`<div><div class="song-meta-label">${escapeHtml(label)}</div><div class="song-meta-value">${escapeHtml(value)}</div></div>`);
};
pushMeta('Liederbuch', meta.Songbook);
pushMeta('ID', meta.ChurchSongID);
pushMeta('Stichworte', meta.Keywords, true);
pushMeta('Reihenfolge', meta.VerseOrder, true);
pushMeta('Sprache', meta.LangCount);
let html = '<div class="song-sheet">';
html += `<div class="song-header"><h2>${escapeHtml(title)}</h2></div>`;
if (metaRows.length) {
html += `<div class="song-meta">${metaRows.join('')}</div>`;
}
sections.forEach(section => {
const heading = section.title ? `<h4>${escapeHtml(section.title)}</h4>` : '';
const body = section.lines.map(l => formatSngLine(l)).join('<br>');
html += `<section class="song-section">${heading}<p>${body}</p></section>`;
}); });
html += '</div>';
return html;
}
function openSngModal(relUrl) {
if (!relUrl) return;
const modal = document.getElementById('transcriptModal');
const content = document.getElementById('transcriptContent');
if (!modal || !content) return;
const encodedPath = encodeSubpath(relUrl);
const filename = decodeURIComponent(relUrl.split('/').pop() || '').replace(/\.sng$/i, '');
content.innerHTML = '<p>Lade Liedtext…</p>';
modal.style.display = 'block';
fetch(`/media/${encodedPath}`)
.then(response => {
if (!response.ok) throw new Error('Laden fehlgeschlagen');
return response.arrayBuffer();
})
.then(buffer => {
let text = '';
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch (err) {
text = new TextDecoder('windows-1252').decode(buffer);
}
const parsed = parseSngText(text);
content.innerHTML = buildSngHtml(parsed, filename);
})
.catch(() => {
content.innerHTML = '<p>Der Liedtext konnte nicht geladen werden.</p>';
});
}
window.openSngModal = openSngModal;
function initTranscriptModal() {
const modal = document.getElementById('transcriptModal');
if (!modal || modal.dataset.bound) return;
const closeBtn = modal.querySelector('.close');
if (closeBtn) {
closeBtn.addEventListener('click', function () {
modal.style.display = 'none';
});
}
window.addEventListener('click', function (event) { window.addEventListener('click', function (event) {
if (event.target == document.getElementById('transcriptModal')) { if (event.target === modal) {
document.getElementById('transcriptModal').style.display = 'none'; modal.style.display = 'none';
} }
}); });
modal.dataset.bound = '1';
}
function initAppPage() {
attachEventListeners();
initTranscriptModal();
if (typeof window.initSearch === 'function') window.initSearch();
if (typeof window.initMessages === 'function') window.initMessages();
if (typeof window.initGallery === 'function') window.initGallery();
if (typeof window.initCalendar === 'function') window.initCalendar();
syncThemeColor();
// Load initial directory based on URL. // Load initial directory based on URL.
let initialSubpath = ''; let initialSubpath = '';
const pendingFolderOpen = getPendingFolderOpen();
if (window.location.pathname.indexOf('/path/') === 0) { if (window.location.pathname.indexOf('/path/') === 0) {
initialSubpath = window.location.pathname.substring(6); // remove "/path/" initialSubpath = window.location.pathname.substring(6); // remove "/path/"
} }
loadDirectory(initialSubpath); if (pendingFolderOpen?.folder !== undefined) {
}); initialSubpath = pendingFolderOpen.folder || '';
}
loadDirectory(initialSubpath).then(() => {
if (pendingFolderOpen?.file) {
highlightPendingFile(pendingFolderOpen.file);
}
if (pendingFolderOpen) {
clearPendingFolderOpen();
}
});
}
@ -656,28 +860,32 @@ function reloadDirectory() {
} }
document.getElementById('globalAudio').addEventListener('ended', () => { if (!window.__appAudioEndedBound) {
const globalAudio = document.getElementById('globalAudio');
reloadDirectory().then(() => { if (globalAudio) {
globalAudio.addEventListener('ended', () => {
// If we had a track playing, try to find it in the updated list. reloadDirectory().then(() => {
if (currentTrackPath) { // If we had a track playing, try to find it in the updated list.
const newIndex = currentMusicFiles.findIndex(file => file.path === currentTrackPath); if (currentTrackPath) {
currentMusicIndex = newIndex; const newIndex = currentMusicFiles.findIndex(file => file.path === currentTrackPath);
} currentMusicIndex = newIndex;
}
// Now, if there's a next track, auto-play it.
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) { // Now, if there's a next track, auto-play it.
const nextFile = currentMusicFiles[currentMusicIndex + 1]; if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`); const nextFile = currentMusicFiles[currentMusicIndex + 1];
if (nextLink) { const nextLink = document.querySelector(`.play-file[data-url=\"${nextFile.path}\"]`);
nextLink.click(); if (nextLink) {
} nextLink.click();
} }
}).catch(error => { }
console.error('Error during reload:', error); }).catch(error => {
}); console.error('Error during reload:', error);
}); });
});
window.__appAudioEndedBound = true;
}
}
// document.addEventListener("DOMContentLoaded", function() { // document.addEventListener("DOMContentLoaded", function() {
// // Automatically reload every 5 minutes (300,000 milliseconds) // // Automatically reload every 5 minutes (300,000 milliseconds)
@ -701,4 +909,40 @@ function syncThemeColor() {
.forEach(svg => svg.setAttribute('fill', cssVar)); .forEach(svg => svg.setAttribute('fill', cssVar));
} }
document.addEventListener('DOMContentLoaded', syncThemeColor); // syncThemeColor is called during app init.
function getPendingFolderOpen() {
try {
const raw = sessionStorage.getItem('pendingFolderOpen');
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function clearPendingFolderOpen() {
try {
sessionStorage.removeItem('pendingFolderOpen');
} catch {
// ignore
}
}
function highlightPendingFile(filePath) {
if (!filePath) return;
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
if (target) {
target.classList.add('search-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
window.PageRegistry.register('app', { init: initAppPage });
} else {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAppPage);
} else {
initAppPage();
}
}

View File

@ -1,3 +1,5 @@
const AUDIO_PLAYER_STATE_KEY = 'globalAudioPlayerState';
class SimpleAudioPlayer { class SimpleAudioPlayer {
constructor({ constructor({
audioSelector = '#globalAudio', audioSelector = '#globalAudio',
@ -53,6 +55,11 @@ class SimpleAudioPlayer {
// Setup MediaSession handlers + heartbeat // Setup MediaSession handlers + heartbeat
this._initMediaSession(); this._initMediaSession();
// Restore previous playback state (if any) and persist on navigation
this._restoreState();
window.addEventListener('pagehide', () => this._saveState());
window.addEventListener('beforeunload', () => this._saveState());
} }
/** /**
@ -68,9 +75,8 @@ class SimpleAudioPlayer {
if (!Number.isFinite(duration) || duration <= 0) return; if (!Number.isFinite(duration) || duration <= 0) return;
if (!Number.isFinite(position) || position < 0) return; if (!Number.isFinite(position) || position < 0) return;
const playbackRate = this.audio.paused let playbackRate = Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1;
? 0 if (!playbackRate) playbackRate = 1;
: (Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1);
try { try {
if ('playbackState' in navigator.mediaSession) { if ('playbackState' in navigator.mediaSession) {
@ -206,32 +212,96 @@ class SimpleAudioPlayer {
} }
toggleCollapse() { toggleCollapse() {
const collapsed = this.container.classList.toggle('collapsed'); const collapsed = !this.container.classList.contains('collapsed');
this._setCollapsed(collapsed);
this._saveState();
}
_setCollapsed(collapsed) {
if (!this.container) return;
this.container.classList.toggle('collapsed', collapsed);
if (this.minBtn) { if (this.minBtn) {
this.minBtn.setAttribute('aria-expanded', (!collapsed).toString()); this.minBtn.setAttribute('aria-expanded', (!collapsed).toString());
this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen'); this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen');
} }
} }
async fileDownload() { _saveState() {
const src = this.audio.currentSrc || this.audio.src; try {
if (!src) return; if (!this.currentRelUrl) {
sessionStorage.removeItem(AUDIO_PLAYER_STATE_KEY);
return;
}
const state = {
relUrl: this.currentRelUrl,
currentTime: Number.isFinite(this.audio.currentTime) ? this.audio.currentTime : 0,
paused: this.audio.paused,
collapsed: this.container?.classList.contains('collapsed') || false
};
sessionStorage.setItem(AUDIO_PLAYER_STATE_KEY, JSON.stringify(state));
} catch {
// Best-effort persistence only.
}
}
// Extract the subpath from the src _restoreState() {
const urlObj = new URL(src, window.location.href); let stateRaw;
let subpath = urlObj.pathname; try {
subpath = subpath.slice('/media'.length); stateRaw = sessionStorage.getItem(AUDIO_PLAYER_STATE_KEY);
} catch {
return;
}
if (!stateRaw) return;
let state;
try {
state = JSON.parse(stateRaw);
} catch {
return;
}
if (!state || !state.relUrl) return;
this._setCollapsed(Boolean(state.collapsed));
const resumeTime = Number.isFinite(state.currentTime) ? state.currentTime : 0;
const autoplay = !state.paused;
this.loadTrack(state.relUrl, { autoplay, resumeTime });
}
async fileDownload() {
let relUrl = this.currentRelUrl || '';
const src = this.audio.currentSrc || this.audio.src;
if (!relUrl && src) {
try {
const urlObj = new URL(src, window.location.href);
if (urlObj.pathname.startsWith('/media/')) {
relUrl = urlObj.pathname.slice('/media/'.length);
}
} catch {
// ignore parsing errors
}
}
if (!relUrl) return;
const encodedSubpath = relUrl
.replace(/^\/+/, '')
.split('/')
.map(segment => encodeURIComponent(decodeURIComponent(segment)))
.join('/');
// Fetch the tokenized URL from your backend // Fetch the tokenized URL from your backend
let tokenizedUrl; let tokenizedUrl;
try { try {
const resp = await fetch(`/create_dltoken${subpath}`); const resp = await fetch(`/create_dltoken/${encodedSubpath}`);
if (!resp.ok) return; if (!resp.ok) return;
tokenizedUrl = await resp.text(); tokenizedUrl = (await resp.text()).trim();
} catch { } catch {
return; return;
} }
if (!tokenizedUrl) return;
// Build the URL with cache-buster // Build the URL with cache-buster
const downloadUrl = new URL(tokenizedUrl, window.location.href); const downloadUrl = new URL(tokenizedUrl, window.location.href);
@ -243,7 +313,13 @@ class SimpleAudioPlayer {
document.body.removeChild(a); document.body.removeChild(a);
} }
async loadTrack(relUrl, reqId) { async loadTrack(relUrl, reqId, options = {}) {
if (reqId && typeof reqId === 'object') {
options = reqId;
reqId = null;
}
const { autoplay = true, resumeTime = 0 } = options;
this.currentRelUrl = relUrl; this.currentRelUrl = relUrl;
const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2))); const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)));
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`; const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`;
@ -277,7 +353,35 @@ class SimpleAudioPlayer {
this.audio.src = urlWithReq; this.audio.src = urlWithReq;
this.audio.load(); this.audio.load();
await this.audio.play();
const wantsResume = Number.isFinite(resumeTime) && resumeTime > 0;
if (wantsResume) {
this.audio.addEventListener('loadedmetadata', () => {
const safeTime = Number.isFinite(this.audio.duration)
? Math.min(resumeTime, this.audio.duration)
: resumeTime;
this.audio.currentTime = safeTime;
this.updateTimeline();
if (autoplay) {
this.audio.play().catch(() => this._updatePlayState());
} else {
this._updatePlayState();
}
}, { once: true });
}
if (!wantsResume) {
if (autoplay) {
try {
await this.audio.play();
} catch {
this._updatePlayState();
}
} else {
this._updatePlayState();
}
}
// Full breadcrumb // Full breadcrumb
const parts = relUrl.split('/'); const parts = relUrl.split('/');
@ -293,6 +397,7 @@ class SimpleAudioPlayer {
artwork: [{ src:'/icon/logo-192x192.png', sizes:'192x192', type:'image/png' }] artwork: [{ src:'/icon/logo-192x192.png', sizes:'192x192', type:'image/png' }]
}); });
} }
this._saveState();
} catch (err) { } catch (err) {
if (err.name !== 'AbortError') { if (err.name !== 'AbortError') {
this.nowInfo.textContent = 'Error loading track'; this.nowInfo.textContent = 'Error loading track';
@ -325,6 +430,7 @@ class SimpleAudioPlayer {
// Prev & Next // Prev & Next
navigator.mediaSession.setActionHandler('previoustrack', wrap(() => { navigator.mediaSession.setActionHandler('previoustrack', wrap(() => {
if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return;
if (currentMusicIndex > 0) { if (currentMusicIndex > 0) {
const prevFile = currentMusicFiles[currentMusicIndex - 1]; const prevFile = currentMusicFiles[currentMusicIndex - 1];
const prevLink = document.querySelector(`.play-file[data-url="${prevFile.path}"]`); const prevLink = document.querySelector(`.play-file[data-url="${prevFile.path}"]`);
@ -334,6 +440,7 @@ class SimpleAudioPlayer {
} }
})); }));
navigator.mediaSession.setActionHandler('nexttrack', wrap(() => { navigator.mediaSession.setActionHandler('nexttrack', wrap(() => {
if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return;
if (currentMusicIndex < currentMusicFiles.length - 1) { if (currentMusicIndex < currentMusicFiles.length - 1) {
const nextFile = currentMusicFiles[currentMusicIndex + 1]; const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`); const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`);

691
static/calendar.js Normal file
View File

@ -0,0 +1,691 @@
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;
if (entry.id) {
const existing = document.querySelector(`.calendar-entry-row[data-id="${entry.id}"]`);
if (existing) 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 fetchToken = (window._calendarFetchToken = (window._calendarFetchToken || 0) + 1);
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();
if (fetchToken !== window._calendarFetchToken) return;
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();
};
function initCalendar() {
const section = document.getElementById('calendar-section');
if (!section || section.dataset.bound) return;
section.dataset.bound = '1';
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;
if (!modalEl.dataset.focusGuard) {
modalEl.addEventListener('hide.bs.modal', () => {
const active = document.activeElement;
if (active && modalEl.contains(active)) {
active.blur();
requestAnimationFrame(() => {
if (document.body) document.body.focus({ preventScroll: true });
});
}
});
modalEl.dataset.focusGuard = '1';
}
}
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';
});
}
}
window.initCalendar = initCalendar;

319
static/connections.js Normal file
View File

@ -0,0 +1,319 @@
(() => {
let map = null;
let socket = null;
let activeDots = null;
let animateInterval = null;
let statsInterval = null;
let refreshMapSize = null;
const TABLE_LIMIT = 20;
function initConnectionsPage() {
const mapEl = document.getElementById('map');
if (!mapEl || mapEl.dataset.bound) return;
if (typeof L === 'undefined' || typeof io === 'undefined') return;
mapEl.dataset.bound = '1';
activeDots = new Map();
// Initialize the map centered on Europe
map = L.map('map').setView([50.0, 10.0], 5);
refreshMapSize = () => setTimeout(() => map && map.invalidateSize(), 150);
refreshMapSize();
window.addEventListener('resize', refreshMapSize);
window.addEventListener('orientationchange', refreshMapSize);
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Custom pulsing circle marker
const PulsingMarker = L.CircleMarker.extend({
options: {
radius: 10,
fillColor: '#ff4444',
color: '#ff0000',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}
});
function updatePopup(dot) {
const location = dot.connections[0];
const content = `
<strong>${location.city}, ${location.country}</strong><br>
Connections: ${dot.mass}<br>
Latest: ${new Date(location.timestamp).toLocaleString()}
`;
dot.marker.bindPopup(content);
}
function getTimeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
function updateMapStats() {
let mostRecent = null;
activeDots.forEach(dot => {
if (!mostRecent || dot.lastUpdate > mostRecent) {
mostRecent = dot.lastUpdate;
}
});
if (mostRecent) {
const timeAgo = getTimeAgo(mostRecent);
const lastConnection = document.getElementById('last-connection');
if (lastConnection) lastConnection.textContent = timeAgo;
}
}
function addOrUpdateDot(lat, lon, city, country, timestamp) {
const key = `${lat},${lon}`;
const now = Date.now();
if (activeDots.has(key)) {
const dot = activeDots.get(key);
dot.mass += 1;
dot.lastUpdate = now;
dot.connections.push({ city, country, timestamp });
const newRadius = 10 + Math.log(dot.mass) * 5;
dot.marker.setRadius(newRadius);
updatePopup(dot);
} else {
const marker = new PulsingMarker([lat, lon], {
radius: 10
}).addTo(map);
const dot = {
marker: marker,
mass: 1,
createdAt: now,
lastUpdate: now,
lat: lat,
lon: lon,
connections: [{ city, country, timestamp }]
};
activeDots.set(key, dot);
updatePopup(dot);
}
updateMapStats();
}
function formatBytes(bytes) {
if (!bytes && bytes !== 0) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let num = bytes;
let unitIndex = 0;
while (num >= 1024 && unitIndex < units.length - 1) {
num /= 1024;
unitIndex++;
}
const rounded = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
return `${rounded} ${units[unitIndex]}`;
}
function toBool(val) {
if (val === true || val === 1) return true;
if (typeof val === 'string') {
const lower = val.toLowerCase();
return lower === 'true' || lower === '1' || lower === 'yes';
}
return false;
}
function animateDots() {
if (!map) return;
const now = Date.now();
const fadeTime = 10 * 60 * 1000; // 10 minutes
activeDots.forEach((dot, key) => {
const age = now - dot.lastUpdate;
if (age > fadeTime) {
map.removeLayer(dot.marker);
activeDots.delete(key);
} else {
const fadeProgress = age / fadeTime;
const baseRadius = 10 + Math.log(dot.mass) * 5;
const currentRadius = baseRadius * (1 - fadeProgress * 0.8);
dot.marker.setRadius(Math.max(currentRadius, 2));
const opacity = 1 - fadeProgress * 0.7;
dot.marker.setStyle({
fillOpacity: opacity * 0.8,
opacity: opacity
});
}
});
updateMapStats();
}
animateInterval = setInterval(animateDots, 100);
statsInterval = setInterval(() => {
if (activeDots.size > 0) {
updateMapStats();
}
}, 1000);
// WebSocket connection
// Force polling to avoid websocket upgrade issues behind some proxies.
socket = io({
transports: ['polling'],
upgrade: false
});
socket.on('connect', () => {
socket.emit('request_initial_data');
socket.emit('request_map_data');
});
socket.on('map_initial_data', function(data) {
data.connections.forEach(conn => {
if (conn.lat && conn.lon) {
addOrUpdateDot(conn.lat, conn.lon, conn.city, conn.country, conn.timestamp);
}
});
});
socket.on('new_connection', function(data) {
if (data.lat && data.lon) {
addOrUpdateDot(data.lat, data.lon, data.city, data.country, data.timestamp);
}
});
function updateStats(data) {
const headerCount = document.getElementById('connectionsTotalHeader');
if (headerCount) headerCount.textContent = `${data.length} Verbindungen`;
const totalConnections = document.getElementById('totalConnections');
if (totalConnections) totalConnections.textContent = data.length;
const totalBytes = data.reduce((sum, rec) => {
const val = parseFloat(rec.filesize);
return sum + (isNaN(val) ? 0 : val);
}, 0);
const totalSize = document.getElementById('totalSize');
if (totalSize) totalSize.textContent = formatBytes(totalBytes);
const cachedCount = data.reduce((sum, rec) => sum + (toBool(rec.cached) ? 1 : 0), 0);
const pct = data.length ? ((cachedCount / data.length) * 100).toFixed(0) : 0;
const cachedPercent = document.getElementById('cachedPercent');
if (cachedPercent) cachedPercent.textContent = `${pct}%`;
}
function createRow(record, animate = true) {
const tr = document.createElement('tr');
tr.dataset.timestamp = record.timestamp;
if (animate) {
tr.classList.add('slide-in');
tr.addEventListener('animationend', () =>
tr.classList.remove('slide-in'), { once: true });
}
tr.innerHTML = `
<td>${record.timestamp}</td>
<td>${record.location}</td>
<td>${record.user_agent}</td>
<td>${record.full_path}</td>
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
<td>${record.mime_typ}</td>
<td>${record.cached}</td>
`;
return tr;
}
function updateTable(data) {
const tbody = document.getElementById('connectionsTableBody');
if (!tbody) return;
const existing = {};
Array.from(tbody.children).forEach(r => {
existing[r.dataset.timestamp] = r;
});
const frag = document.createDocumentFragment();
data.forEach(rec => {
let row = existing[rec.timestamp];
if (row) {
row.innerHTML = createRow(rec, false).innerHTML;
row.classList.remove('slide-out');
} else {
row = createRow(rec, true);
}
frag.appendChild(row);
});
tbody.innerHTML = '';
tbody.appendChild(frag);
}
function animateTableWithNewRow(data) {
const tbody = document.getElementById('connectionsTableBody');
if (!tbody) return;
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
const newRecs = data.filter(r => !currentTs.has(r.timestamp));
if (newRecs.length) {
const temp = createRow(newRecs[0], false);
temp.style.visibility = 'hidden';
tbody.appendChild(temp);
const h = temp.getBoundingClientRect().height;
temp.remove();
tbody.style.transform = `translateY(${h}px)`;
setTimeout(() => {
updateTable(data);
tbody.style.transition = 'none';
tbody.style.transform = 'translateY(0)';
void tbody.offsetWidth;
tbody.style.transition = 'transform 0.5s ease-out';
}, 500);
} else {
updateTable(data);
}
}
socket.on('recent_connections', data => {
updateStats(data);
const tableData = data.slice(0, TABLE_LIMIT);
animateTableWithNewRow(tableData);
});
}
function destroyConnectionsPage() {
if (socket) {
socket.off();
socket.disconnect();
socket = null;
}
if (animateInterval) {
clearInterval(animateInterval);
animateInterval = null;
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
if (refreshMapSize) {
window.removeEventListener('resize', refreshMapSize);
window.removeEventListener('orientationchange', refreshMapSize);
refreshMapSize = null;
}
if (map) {
map.remove();
map = null;
}
activeDots = null;
}
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
window.PageRegistry.register('connections', {
init: initConnectionsPage,
destroy: destroyConnectionsPage
});
}
})();

263
static/dashboard.js Normal file
View File

@ -0,0 +1,263 @@
(() => {
let charts = [];
let mainMap = null;
let modalMap = null;
let refreshHandlers = [];
let modalShownHandler = null;
function parseDashboardData() {
const script = document.getElementById('dashboard-page-data');
if (!script) return null;
try {
return JSON.parse(script.textContent || '{}');
} catch (err) {
console.error('Failed to parse dashboard data', err);
return null;
}
}
function createLeafletMap(mapElement, mapData) {
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.isValid()) {
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);
refreshHandlers.push(refreshSize);
return { map, refreshSize };
}
function initDashboardPage() {
const mapElement = document.getElementById('dashboard-map');
if (!mapElement || mapElement.dataset.bound) return;
mapElement.dataset.bound = '1';
const data = parseDashboardData();
if (!data) return;
const mapData = Array.isArray(data.map_data) ? data.map_data : [];
const distinctDeviceData = Array.isArray(data.distinct_device_data) ? data.distinct_device_data : [];
const timeframeData = Array.isArray(data.timeframe_data) ? data.timeframe_data : [];
const userAgentData = Array.isArray(data.user_agent_data) ? data.user_agent_data : [];
const folderData = Array.isArray(data.folder_data) ? data.folder_data : [];
const timeframe = data.timeframe || '';
if (typeof L !== 'undefined') {
try {
mainMap = createLeafletMap(mapElement, mapData);
const modalEl = document.getElementById('mapModal');
if (modalEl) {
modalShownHandler = () => {
const modalMapElement = document.getElementById('dashboard-map-modal');
if (!modalMapElement) return;
const modalBody = modalEl.querySelector('.modal-body');
const modalHeader = modalEl.querySelector('.modal-header');
if (modalBody && modalHeader) {
const availableHeight = window.innerHeight - modalHeader.offsetHeight;
modalBody.style.height = `${availableHeight}px`;
modalMapElement.style.height = '100%';
}
if (!modalMap) {
modalMap = createLeafletMap(modalMapElement, mapData);
}
setTimeout(() => {
if (modalMap && modalMap.map) {
modalMap.map.invalidateSize();
if (modalMap.refreshSize) modalMap.refreshSize();
}
}, 200);
};
modalEl.addEventListener('shown.bs.modal', modalShownHandler);
}
} catch (err) {
console.error('Dashboard map init failed:', err);
}
}
if (typeof Chart === 'undefined') return;
const shiftedLabels = timeframeData.map((item) => {
if (timeframe === 'last24hours') {
const bucketDate = new Date(item.bucket);
const now = new Date();
const isCurrentHour =
bucketDate.getFullYear() === now.getFullYear() &&
bucketDate.getMonth() === now.getMonth() &&
bucketDate.getDate() === now.getDate() &&
bucketDate.getHours() === now.getHours();
const bucketEnd = isCurrentHour ? now : new Date(bucketDate.getTime() + 3600 * 1000);
return `${bucketDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${bucketEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
} else if (timeframe === '7days' || timeframe === '30days') {
const localDate = new Date(item.bucket);
return localDate.toLocaleDateString();
} else if (timeframe === '365days') {
const [year, month] = item.bucket.split('-');
const dateObj = new Date(year, month - 1, 1);
return dateObj.toLocaleString([], { month: 'short', year: 'numeric' });
} else {
return item.bucket;
}
});
const ctxDistinctDevice = document.getElementById('distinctDeviceChart');
if (ctxDistinctDevice) {
charts.push(new Chart(ctxDistinctDevice.getContext('2d'), {
type: 'bar',
data: {
labels: shiftedLabels,
datasets: [{
label: 'Device Count',
data: distinctDeviceData.map(item => item.count),
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Time Range' } },
y: {
title: { display: true, text: 'Device Count' },
beginAtZero: true,
ticks: { maxTicksLimit: 10 }
}
}
}
}));
}
const ctxTimeframe = document.getElementById('downloadTimeframeChart');
if (ctxTimeframe) {
charts.push(new Chart(ctxTimeframe.getContext('2d'), {
type: 'bar',
data: {
labels: shiftedLabels,
datasets: [{
label: 'Download Count',
data: timeframeData.map(item => item.count),
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Time Range' } },
y: {
title: { display: true, text: 'Download Count' },
beginAtZero: true,
ticks: { maxTicksLimit: 10 }
}
}
}
}));
}
const ctxUserAgent = document.getElementById('userAgentChart');
if (ctxUserAgent) {
charts.push(new Chart(ctxUserAgent.getContext('2d'), {
type: 'pie',
data: {
labels: userAgentData.map(item => item.device),
datasets: [{
data: userAgentData.map(item => item.count)
}]
},
options: { responsive: true }
}));
}
const ctxFolder = document.getElementById('folderChart');
if (ctxFolder) {
charts.push(new Chart(ctxFolder.getContext('2d'), {
type: 'pie',
data: {
labels: folderData.map(item => item.folder),
datasets: [{
data: folderData.map(item => item.count)
}]
},
options: { responsive: true }
}));
}
}
function destroyDashboardPage() {
charts.forEach(chart => {
try {
chart.destroy();
} catch (err) {
// ignore
}
});
charts = [];
if (mainMap && mainMap.map) {
mainMap.map.remove();
}
if (modalMap && modalMap.map) {
modalMap.map.remove();
}
mainMap = null;
modalMap = null;
refreshHandlers.forEach(handler => {
window.removeEventListener('resize', handler);
window.removeEventListener('orientationchange', handler);
});
refreshHandlers = [];
const modalEl = document.getElementById('mapModal');
if (modalEl && modalShownHandler) {
modalEl.removeEventListener('shown.bs.modal', modalShownHandler);
}
modalShownHandler = null;
}
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
window.PageRegistry.register('dashboard', {
init: initDashboardPage,
destroy: destroyDashboardPage
});
}
})();

98
static/file_access.js Normal file
View File

@ -0,0 +1,98 @@
(() => {
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
const safeDecode = (value) => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const normalizePath = (path) => {
if (!path) return '';
return path
.replace(/\\/g, '/')
.replace(/^\/+/, '');
};
const encodePath = (path) => {
const normalized = normalizePath(path);
if (!normalized) return '';
return normalized
.split('/')
.map(segment => encodeURIComponent(safeDecode(segment)))
.join('/');
};
const isAudio = (path) =>
audioExts.some(ext => path.toLowerCase().endsWith(ext));
const highlightFile = (filePath) => {
const normalized = normalizePath(filePath);
const target = document.querySelector(`.play-file[data-url="${normalized}"]`);
if (target) {
target.classList.add('search-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
const openFolder = (folderPath, filePath) => {
const normalizedFolder = normalizePath(folderPath);
const normalizedFile = normalizePath(filePath);
const payload = { folder: normalizedFolder || '', file: normalizedFile || '' };
sessionStorage.setItem('pendingFolderOpen', JSON.stringify(payload));
const url = normalizedFolder ? `/path/${encodePath(normalizedFolder)}` : '/';
if (window.PageRouter && typeof window.PageRouter.loadPage === 'function') {
window.PageRouter.loadPage(url, { push: true, scroll: true });
return;
}
window.location.href = url;
};
const initFileAccess = () => {
const table = document.getElementById('file-access-table');
if (!table || table.dataset.bound) return;
table.dataset.bound = '1';
table.querySelectorAll('.play-access-btn').forEach(btn => {
const path = normalizePath(btn.dataset.path || '');
if (!path) return;
if (!isAudio(path)) {
btn.innerHTML = '<i class="bi bi-download"></i>';
btn.setAttribute('title', 'Download');
btn.setAttribute('aria-label', 'Download');
btn.addEventListener('click', () => {
window.location.href = `/media/${encodePath(path)}`;
});
return;
}
btn.addEventListener('click', () => {
const activePlayer = (typeof player !== 'undefined' && player) ? player : window.player;
if (activePlayer && typeof activePlayer.loadTrack === 'function') {
activePlayer.loadTrack(path);
}
});
});
table.querySelectorAll('.folder-open-btn').forEach(btn => {
const folder = normalizePath(btn.dataset.folder || '');
const file = normalizePath(btn.dataset.file || '');
btn.addEventListener('click', () => {
// If we're already on the app page, load directly.
if (window.PageRegistry?.getCurrent?.() === 'app' && typeof loadDirectory === 'function') {
loadDirectory(folder).then(() => highlightFile(file));
} else {
openFolder(folder, file);
}
});
});
};
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
window.PageRegistry.register('file_access', { init: initFileAccess });
}
})();

View File

@ -0,0 +1,503 @@
(() => {
let ALPHABET = [];
let data = [];
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('.');
return (dd && mm && yyyy) ? `${yyyy}-${mm}-${dd}` : '';
}
// load from server
async function loadData() {
try {
const res = await fetch('/admin/folder_secret_config_editor/data');
data = await res.json();
render();
} catch (err) {
console.error(err);
}
}
// send delete/update actions
async function sendAction(payload) {
await fetch('/admin/folder_secret_config_editor/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
await loadData();
}
// delete record after confirmation
function deleteRec(secret) {
sendAction({ action: 'delete', secret });
}
// build all cards
function render() {
const cont = document.getElementById('records');
cont.innerHTML = '';
data.forEach(rec => {
const key = rec.secret;
const isEdit = editing.has(key);
const expired = (formatISO(rec.validity) && new Date(formatISO(rec.validity)) < new Date());
const cls = isEdit ? 'unlocked' : 'locked';
// outer card
const wrapper = document.createElement('div');
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}`;
// 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);
// 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 = false;
valInput.dataset.field = 'validity';
valDiv.appendChild(valInput);
body.appendChild(valDiv);
}
// folders
const folderHeader = document.createElement('h6');
folderHeader.textContent = 'Ordner';
body.appendChild(folderHeader);
rec.folders.forEach((f, i) => {
const fwrap = document.createElement('div');
fwrap.style = 'border:1px solid #ccc; padding:10px; margin-bottom:10px; border-radius:5px;';
// name
fwrap.appendChild(document.createTextNode("Ordnername: "));
const nameGroup = document.createElement('div');
nameGroup.className = 'input-group mb-2';
const nameInput = document.createElement('input');
nameInput.className = 'form-control';
nameInput.type = 'text';
nameInput.value = f.foldername;
nameInput.readOnly = !isEdit;
nameInput.dataset.field = `foldername-${i}`;
nameGroup.appendChild(nameInput);
if (isEdit) {
const remBtn = document.createElement('button');
remBtn.className = 'btn btn-outline-danger';
remBtn.type = 'button';
remBtn.textContent = 'entfernen';
remBtn.addEventListener('click', () => removeFolder(key, i));
nameGroup.appendChild(remBtn);
}
fwrap.appendChild(nameGroup);
// path
fwrap.appendChild(document.createTextNode("Ordnerpfad: "));
const pathInput = document.createElement('input');
pathInput.className = 'form-control mb-2';
pathInput.type = 'text';
pathInput.value = f.folderpath;
pathInput.readOnly = !isEdit;
pathInput.dataset.field = `folderpath-${i}`;
fwrap.appendChild(pathInput);
body.appendChild(fwrap);
});
if (isEdit) {
const addFld = document.createElement('button');
addFld.className = 'btn btn-sm btn-primary mb-2';
addFld.type = 'button';
addFld.textContent = 'Ordner hinzufügen';
addFld.addEventListener('click', () => addFolder(key));
body.appendChild(addFld);
}
// actions row
const actions = document.createElement('div');
if (!isEdit) {
const openButton = document.createElement('button');
openButton.className = 'btn btn-secondary btn-sm me-2';
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
openButton.textContent = 'Link öffnen';
actions.appendChild(openButton);
}
if (!isEdit) {
const openButton = document.createElement('button');
openButton.className = 'btn btn-secondary btn-sm me-2';
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
openButton.textContent = 'Link kopieren';
actions.appendChild(openButton);
}
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-sm me-2 delete-btn';
delBtn.type = 'button';
delBtn.textContent = 'löschen';
delBtn.dataset.secret = key;
actions.appendChild(delBtn);
const cloneBtn = document.createElement('button');
cloneBtn.className = 'btn btn-secondary btn-sm me-2';
cloneBtn.type = 'button';
cloneBtn.textContent = 'clonen';
cloneBtn.addEventListener('click', () => cloneRec(key));
actions.appendChild(cloneBtn);
if (isEdit || expired) {
const renewBtn = document.createElement('button');
renewBtn.className = 'btn btn-info btn-sm me-2';
renewBtn.type = 'button';
renewBtn.textContent = 'erneuern';
renewBtn.addEventListener('click', () => renewRec(key));
actions.appendChild(renewBtn);
}
if (isEdit) {
const saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-success btn-sm me-2';
saveBtn.type = 'button';
saveBtn.textContent = 'speichern';
saveBtn.addEventListener('click', () => saveRec(key));
actions.appendChild(saveBtn);
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);
collapseDiv.appendChild(body);
wrapper.appendChild(collapseDiv);
cont.appendChild(wrapper);
});
}
// generate a unique secret
function generateSecret(existing) {
let s;
do {
s = Array.from({length:32}, () => ALPHABET[Math.floor(Math.random()*ALPHABET.length)]).join('');
} while (existing.includes(s));
return s;
}
// CRUD helpers
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);
const rec = JSON.parse(JSON.stringify(data[idx]));
const existing = data.map(r => r.secret);
rec.secret = generateSecret(existing);
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 35);
// Format as DD.MM.YYYY for validity input
const dd = String(futureDate.getDate()).padStart(2, '0');
const mm = String(futureDate.getMonth() + 1).padStart(2, '0');
const yyyy = futureDate.getFullYear();
rec.validity = `${dd}.${mm}.${yyyy}`;
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
const rec = data.find(r => r.secret === secret);
if (!rec) return;
// generate a fresh unique secret
const existing = data.map(r => r.secret);
const newSecret = generateSecret(existing);
// validity = today + 35 days, formatted as YYYY-MM-DD
const future = new Date();
future.setDate(future.getDate() + 35);
const yyyy = future.getFullYear();
const mm = String(future.getMonth() + 1).padStart(2, '0');
const dd = String(future.getDate()).padStart(2, '0');
const validity = `${yyyy}-${mm}-${dd}`;
// keep folders unchanged
const folders = rec.folders.map(f => ({
foldername: f.foldername,
folderpath: f.folderpath
}));
// persist via existing endpoint
await sendAction({
action: 'update',
oldSecret: secret,
newSecret,
validity,
folders
});
}
function addFolder(secret) {
data.find(r => r.secret === secret).folders.push({foldername:'', folderpath:''});
render();
}
function removeFolder(secret, i) {
data.find(r => r.secret === secret).folders.splice(i,1);
render();
}
async function saveRec(secret) {
const card = document.querySelector(`[data-secret="${secret}"]`);
const newSecret = card.querySelector('input[data-field="secret"]').value.trim();
const validity = card.querySelector('input[data-field="validity"]').value;
const rec = data.find(r => r.secret === secret);
const folders = rec.folders.map((_, i) => ({
foldername: card.querySelector(`input[data-field="foldername-${i}"]`).value.trim(),
folderpath: card.querySelector(`input[data-field="folderpath-${i}"]`).value.trim()
}));
if (!newSecret || data.some(r => r.secret !== secret && r.secret === newSecret)) {
return alert('Secret must be unique and non-empty');
}
if (!validity) {
return alert('Validity required');
}
if (folders.some(f => !f.foldername || !f.folderpath)) {
return alert('Folder entries cannot be empty');
}
await sendAction({
action: 'update',
oldSecret: secret,
newSecret,
validity,
folders
});
editing.delete(secret);
await loadData();
}
let addBtnHandler = null;
let deleteClickHandler = null;
let confirmDeleteHandler = null;
function bindHandlers() {
const root = document.getElementById('records');
if (!root || root.dataset.bound) return false;
root.dataset.bound = '1';
const dataScript = document.getElementById('folder-config-page-data');
if (dataScript) {
try {
const parsed = JSON.parse(dataScript.textContent || '{}');
if (Array.isArray(parsed.alphabet)) {
ALPHABET = parsed.alphabet;
} else if (typeof parsed.alphabet === 'string') {
ALPHABET = parsed.alphabet.split('');
} else {
ALPHABET = [];
}
} catch (err) {
console.error('Failed to parse folder config data', err);
}
}
addBtnHandler = () => {
const existing = data.map(r => r.secret);
const newSecret = generateSecret(existing);
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
editing.add(newSecret);
render();
setTimeout(() => {
const collapseEl = document.getElementById(`collapse-${newSecret}`);
if (collapseEl) {
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
bsCollapse.show();
}
}, 100);
};
const addBtn = document.getElementById('add-btn');
if (addBtn) addBtn.addEventListener('click', addBtnHandler);
deleteClickHandler = (e) => {
if (e.target.matches('.delete-btn')) {
pendingDelete = e.target.dataset.secret;
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
modal.show();
}
};
document.addEventListener('click', deleteClickHandler);
confirmDeleteHandler = () => {
if (pendingDelete) deleteRec(pendingDelete);
pendingDelete = null;
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
if (modal) modal.hide();
};
const confirmBtn = document.querySelector('.confirm-delete');
if (confirmBtn) confirmBtn.addEventListener('click', confirmDeleteHandler);
return true;
}
function unbindHandlers() {
const addBtn = document.getElementById('add-btn');
if (addBtn && addBtnHandler) addBtn.removeEventListener('click', addBtnHandler);
addBtnHandler = null;
if (deleteClickHandler) document.removeEventListener('click', deleteClickHandler);
deleteClickHandler = null;
const confirmBtn = document.querySelector('.confirm-delete');
if (confirmBtn && confirmDeleteHandler) confirmBtn.removeEventListener('click', confirmDeleteHandler);
confirmDeleteHandler = null;
}
function initFolderConfigPage() {
data = [];
editing = new Set();
pendingDelete = null;
if (!bindHandlers()) return;
loadData();
}
function destroyFolderConfigPage() {
unbindHandlers();
}
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
window.PageRegistry.register('folder_secret_config', {
init: initFolderConfigPage,
destroy: destroyFolderConfigPage
});
}
})();

View File

@ -596,23 +596,19 @@ function initGalleryControls() {
function initGallery() { function initGallery() {
const galleryModal = document.getElementById('gallery-modal');
if (!galleryModal || galleryModal.dataset.galleryBound) return;
initGallerySwipe(); initGallerySwipe();
initGalleryControls(); initGalleryControls();
// Close modal when clicking outside the image. // Close modal when clicking outside the image.
const galleryModal = document.getElementById('gallery-modal');
galleryModal.addEventListener('click', function(e) { galleryModal.addEventListener('click', function(e) {
if (e.target === galleryModal) { if (e.target === galleryModal) {
closeGalleryModal(); closeGalleryModal();
} }
}); });
galleryModal.dataset.galleryBound = '1';
} }
// Initialize the gallery once the DOM content is loaded. window.initGallery = initGallery;
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', initGallery);
} else {
initGallery();
}

View File

@ -5,11 +5,27 @@ let messageModal;
let currentEditingId = null; let currentEditingId = null;
// Initialize messages on page load // Initialize messages on page load
document.addEventListener('DOMContentLoaded', function() { function initMessages() {
const container = document.getElementById('messages-container');
if (!container || container.dataset.bound) return;
container.dataset.bound = '1';
// Initialize Bootstrap modal // Initialize Bootstrap modal
const modalElement = document.getElementById('messageModal'); const modalElement = document.getElementById('messageModal');
if (modalElement) { if (modalElement) {
messageModal = new bootstrap.Modal(modalElement); messageModal = new bootstrap.Modal(modalElement);
if (!modalElement.dataset.focusGuard) {
modalElement.addEventListener('hide.bs.modal', () => {
const active = document.activeElement;
if (active && modalElement.contains(active)) {
active.blur();
// Ensure focus isn't left on an element being hidden
requestAnimationFrame(() => {
if (document.body) document.body.focus({ preventScroll: true });
});
}
});
modalElement.dataset.focusGuard = '1';
}
} }
// Tab switching // Tab switching
@ -146,7 +162,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
}); }
async function loadMessages() { async function loadMessages() {
try { try {
@ -479,3 +495,6 @@ function insertLink(path, name, type) {
// Optionally close the browser after inserting // Optionally close the browser after inserting
fileBrowserCollapse.hide(); fileBrowserCollapse.hide();
} }
window.initMessages = initMessages;

37
static/page_registry.js Normal file
View File

@ -0,0 +1,37 @@
(() => {
const registry = new Map();
let currentId = null;
async function destroyCurrent(ctx) {
if (!currentId) return;
const handlers = registry.get(currentId);
if (handlers && typeof handlers.destroy === 'function') {
await handlers.destroy(ctx || {});
}
}
async function initPage(id, ctx) {
currentId = id || null;
if (!currentId) return;
const handlers = registry.get(currentId);
if (handlers && typeof handlers.init === 'function') {
await handlers.init(ctx || {});
}
}
function register(id, handlers) {
if (!id) return;
registry.set(id, handlers || {});
}
function getCurrent() {
return currentId;
}
window.PageRegistry = {
register,
destroyCurrent,
initPage,
getCurrent
};
})();

135
static/page_router.js Normal file
View File

@ -0,0 +1,135 @@
(() => {
const pageContent = document.getElementById('page-content');
if (!pageContent) return;
const isModifiedEvent = (event) =>
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
const isSameOrigin = (url) => {
try {
const target = new URL(url, window.location.href);
return target.origin === window.location.origin;
} catch {
return false;
}
};
const cloneWithAttributes = (node) => {
const clone = document.createElement(node.tagName.toLowerCase());
Array.from(node.attributes).forEach(attr => {
clone.setAttribute(attr.name, attr.value);
});
if (node.tagName === 'SCRIPT' && node.textContent) {
clone.textContent = node.textContent;
}
if (node.tagName === 'STYLE' && node.textContent) {
clone.textContent = node.textContent;
}
return clone;
};
const replaceHeadExtras = async (doc) => {
const oldNodes = Array.from(document.head.querySelectorAll('[data-page-head]'));
oldNodes.forEach(node => node.remove());
const newNodes = Array.from(doc.head.querySelectorAll('[data-page-head]'));
const loadPromises = [];
newNodes.forEach(node => {
const clone = cloneWithAttributes(node);
if (clone.tagName === 'SCRIPT' && clone.src) {
loadPromises.push(new Promise(resolve => {
clone.onload = resolve;
clone.onerror = resolve;
}));
}
document.head.appendChild(clone);
});
if (loadPromises.length) {
await Promise.all(loadPromises);
}
};
const loadPage = async (url, { push = true, scroll = true } = {}) => {
try {
const response = await fetch(url, {
headers: { 'X-Requested-With': 'PageRouter' }
});
if (!response.ok) {
window.location.href = url;
return;
}
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const nextContent = doc.getElementById('page-content');
if (!nextContent) {
window.location.href = url;
return;
}
const nextPageId = nextContent.dataset.pageId || '';
const currentId = window.PageRegistry?.getCurrent?.() || pageContent.dataset.pageId || '';
if (window.PageRegistry?.destroyCurrent) {
await window.PageRegistry.destroyCurrent({ from: currentId, to: nextPageId, url });
}
document.title = doc.title;
await replaceHeadExtras(doc);
pageContent.innerHTML = nextContent.innerHTML;
pageContent.dataset.pageId = nextPageId;
if (push) {
history.pushState({ url }, '', url);
}
if (scroll) {
window.scrollTo(0, 0);
}
if (window.PageRegistry?.initPage) {
await window.PageRegistry.initPage(nextPageId, { url, doc });
}
} catch (err) {
window.location.href = url;
}
};
document.addEventListener('click', (event) => {
const link = event.target.closest('a[data-ajax-nav]');
if (!link) return;
if (isModifiedEvent(event)) return;
if (link.target && link.target !== '_self') return;
if (link.hasAttribute('download')) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#')) return;
if (!isSameOrigin(href)) return;
event.preventDefault();
loadPage(href, { push: true, scroll: true });
});
window.addEventListener('popstate', (event) => {
const url = (event.state && event.state.url) || window.location.href;
loadPage(url, { push: false, scroll: false });
});
const boot = () => {
const initialId = pageContent.dataset.pageId || '';
if (window.PageRegistry?.initPage) {
window.PageRegistry.initPage(initialId, { url: window.location.href, doc: document });
}
};
window.PageRouter = { loadPage };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

View File

@ -1,41 +1,68 @@
document.addEventListener('DOMContentLoaded', function() { function initSearch() {
const form = document.getElementById('searchForm');
if (!form || form.dataset.bound) return;
form.dataset.bound = '1';
// Function to render search results from a response object // Function to render search results from a response object
function renderResults(data) { function renderResults(data) {
const resultsDiv = document.getElementById('results'); const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = '<h5>Suchergebnisse:</h5>'; const results = Array.isArray(data.results) ? data.results : [];
const total = Number.isFinite(data.total) ? data.total : results.length;
const shown = results.length;
const remaining = Math.max(0, total - shown);
resultsDiv.innerHTML = `<h5>${total} Treffer</h5>`;
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac']; const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
if (data.results && data.results.length > 0) { if (results.length > 0) {
data.results.forEach(file => { results.forEach(file => {
const card = document.createElement('div'); const card = document.createElement('div');
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.'); const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/'); const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md'; const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
const isAudio = audioExts.includes((file.filetype || '').toLowerCase()); const isAudio = audioExts.includes((file.filetype || '').toLowerCase());
const isSng = (file.filetype || '').toLowerCase() === '.sng' || (file.filename || '').toLowerCase().endsWith('.sng');
const encodedRelPath = encodeURI(file.relative_path); const encodedRelPath = encodeURI(file.relative_path);
const fulltextType = file.fulltext_type || 'transcript';
const fulltextUrl = file.fulltext_url || transcriptURL;
const fulltextLabel = 'Treffer im Volltext';
const fulltextAction = fulltextType === 'sng'
? `<a href="#" class="open-sng-link" data-path="${file.relative_path}" title="Liedtext öffnen"><i class="bi bi-journal-text"></i></a>`
: `<a href="#" class="show-transcript" data-url="${fulltextUrl}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></a>`;
card.className = 'card'; card.className = 'card';
const fileAction = isAudio let fileAction = '';
? `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>` if (isSng) {
: `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`; fileAction = `<button class="btn btn-light open-sng-btn" data-path="${file.relative_path}" style="width:100%;"><i class="bi bi-music-note-list"></i> ${filenameWithoutExtension}</button>`;
} else if (isAudio) {
fileAction = `<button class="btn btn-light play-audio-btn" onclick="player.loadTrack('${file.relative_path}')" style="width:100%;"><i class="bi bi-volume-up"></i> ${filenameWithoutExtension}</button>`;
} else {
fileAction = `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
}
card.innerHTML = ` card.innerHTML = `
<div class="card-body"> <div class="card-body">
<p>${fileAction}</p> <p>${fileAction}</p>
<p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${parentFolder}" data-file="${file.relative_path}" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder || 'Ordner'}</button></p> <p><button class="btn btn-light btn-sm folder-open-btn" data-folder="${parentFolder}" data-file="${file.relative_path}" style="width:100%;"><i class="bi bi-folder"></i> ${parentFolder || 'Ordner'}</button></p>
<p class="card-text">Anzahl Downloads: ${file.hitcount}</p> <p class="card-text">Anzahl Downloads: ${file.hitcount}</p>
${ file.performance_date !== undefined ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``} ${ (file.performance_date !== undefined && file.performance_date !== null && String(file.performance_date).trim() !== '') ? `<p class="card-text">Datum: ${file.performance_date}</p>` : ``}
${ file.transcript_hits !== undefined ? `<p class="card-text">Treffer im Transkript: ${file.transcript_hits} <a href="#" class="show-transcript" data-url="${transcriptURL}" data-audio-url="${file.relative_path}" highlight="${file.query}"><i class="bi bi-journal-text"></i></a></p>` : ``} ${ file.transcript_hits !== undefined ? `<p class="card-text">${fulltextLabel}: ${file.transcript_hits} ${fulltextAction}</p>` : ``}
</div> </div>
`; `;
resultsDiv.appendChild(card); resultsDiv.appendChild(card);
}); });
if (remaining > 0) {
const more = document.createElement('p');
more.className = 'text-muted';
more.textContent = `und weitere ${remaining} Treffer`;
resultsDiv.appendChild(more);
}
attachEventListeners(); attachEventListeners();
attachSearchFolderButtons(); attachSearchFolderButtons();
attachSearchSngButtons();
attachSearchFulltextButtons();
} else { } else {
resultsDiv.innerHTML = '<p>No results found.</p>'; resultsDiv.innerHTML = `<h5>${total} Treffer</h5><p>Keine Treffer gefunden.</p>`;
} }
} }
@ -88,7 +115,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Form submission event // Form submission event
document.getElementById('searchForm').addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
const query = document.getElementById('query').value.trim(); const query = document.getElementById('query').value.trim();
const includeTranscript = document.getElementById('includeTranscript').checked; const includeTranscript = document.getElementById('includeTranscript').checked;
@ -203,7 +230,7 @@ document.addEventListener('DOMContentLoaded', function() {
// window.location.href = '/'; // window.location.href = '/';
viewMain(); viewMain();
}); });
}); }
function attachSearchFolderButtons() { function attachSearchFolderButtons() {
document.querySelectorAll('.folder-open-btn').forEach(btn => { document.querySelectorAll('.folder-open-btn').forEach(btn => {
@ -216,6 +243,30 @@ function attachSearchFolderButtons() {
}); });
} }
function attachSearchSngButtons() {
document.querySelectorAll('.open-sng-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const path = btn.dataset.path;
if (typeof window.openSngModal === 'function') {
window.openSngModal(path);
}
});
});
}
function attachSearchFulltextButtons() {
document.querySelectorAll('.open-sng-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const path = link.dataset.path;
if (typeof window.openSngModal === 'function') {
window.openSngModal(path);
}
});
});
}
function openFolderAndHighlight(folderPath, filePath) { function openFolderAndHighlight(folderPath, filePath) {
const targetFolder = folderPath || ''; const targetFolder = folderPath || '';
// Switch back to main view before loading folder // Switch back to main view before loading folder
@ -241,4 +292,7 @@ function syncThemeColor() {
} }
// sync once on load // sync once on load
document.addEventListener('DOMContentLoaded', syncThemeColor); // syncThemeColor handled by app init.
window.initSearch = initSearch;

View File

@ -0,0 +1,264 @@
(() => {
function initSearchDbAnalyzer() {
const form = document.getElementById('analyzer-form');
if (!form || form.dataset.bound) return;
form.dataset.bound = '1';
const dataScript = document.getElementById('search-db-analyzer-data');
let foldersUrl = '';
let queryUrl = '';
if (dataScript) {
try {
const parsed = JSON.parse(dataScript.textContent || '{}');
foldersUrl = parsed.folders_url || '';
queryUrl = parsed.query_url || '';
} catch (err) {
console.error('Failed to parse analyzer data', err);
}
}
const feedback = document.getElementById('analyzer-feedback');
const resultWrapper = document.getElementById('analyzer-result');
const resultBody = document.getElementById('result-body');
const filetypeBody = document.getElementById('filetype-body');
const totalCount = document.getElementById('totalCount');
const levelsContainer = document.getElementById('folder-levels');
const levelSelects = [];
const createSelect = (level, options) => {
const wrapper = document.createElement('div');
wrapper.className = 'folder-level';
const select = document.createElement('select');
select.className = 'form-select';
select.dataset.level = level;
select.required = level === 0;
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = level === 0 ? 'Bitte auswählen' : 'Optionaler Unterordner';
select.appendChild(placeholder);
options.forEach((opt) => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
select.appendChild(option);
});
select.addEventListener('change', () => {
removeLevelsFrom(level + 1);
updateControlButtons();
});
const controls = document.createElement('div');
controls.className = 'd-flex gap-2 align-items-center';
const btnGroup = document.createElement('div');
btnGroup.className = 'btn-group btn-group-sm';
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-outline-secondary';
addBtn.textContent = '+';
addBtn.addEventListener('click', () => handleAddLevel());
btnGroup.appendChild(addBtn);
let removeBtn = null;
if (level > 0) {
removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-outline-danger';
removeBtn.textContent = '-';
removeBtn.addEventListener('click', () => {
removeLevelsFrom(level);
updateControlButtons();
});
btnGroup.appendChild(removeBtn);
}
controls.append(select, btnGroup);
wrapper.append(controls);
levelsContainer.appendChild(wrapper);
levelSelects.push({ wrapper, select, addBtn, removeBtn, btnGroup });
};
const removeLevelsFrom = (startIndex) => {
while (levelSelects.length > startIndex) {
const item = levelSelects.pop();
levelsContainer.removeChild(item.wrapper);
}
};
const currentPath = () => {
const parts = levelSelects
.map(({ select }) => select.value)
.filter((v) => v && v.trim() !== '');
return parts.join('/');
};
const fetchChildren = async (parentPath = '') => {
if (!foldersUrl) throw new Error('Ordner-Endpoint fehlt.');
const params = parentPath ? `?parent=${encodeURIComponent(parentPath)}` : '';
const response = await fetch(`${foldersUrl}${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Ordner konnten nicht geladen werden.');
}
return data.children || [];
};
const updateControlButtons = () => {
if (!levelSelects.length) {
return;
}
levelSelects.forEach((item, idx) => {
const isLast = idx === levelSelects.length - 1;
if (item.btnGroup) {
item.btnGroup.classList.toggle('d-none', !isLast);
}
if (item.addBtn) {
item.addBtn.disabled = !item.select.value;
}
if (item.removeBtn) {
const hideRemove = !isLast || idx === 0;
item.removeBtn.classList.toggle('d-none', hideRemove);
item.removeBtn.disabled = hideRemove;
}
});
};
const initBaseLevel = async () => {
try {
const children = await fetchChildren('');
if (!children.length) {
feedback.textContent = 'Keine Ordner in der Datenbank gefunden.';
feedback.style.display = 'block';
return;
}
createSelect(0, children);
updateControlButtons();
} catch (error) {
feedback.textContent = error.message;
feedback.style.display = 'block';
}
};
const handleAddLevel = async () => {
feedback.style.display = 'none';
const path = currentPath();
if (!path) {
feedback.textContent = 'Bitte zuerst einen Hauptordner auswählen.';
feedback.style.display = 'block';
return;
}
try {
const children = await fetchChildren(path);
if (!children.length) {
feedback.textContent = 'Keine weiteren Unterordner vorhanden.';
feedback.style.display = 'block';
return;
}
createSelect(levelSelects.length, children);
updateControlButtons();
} catch (error) {
feedback.textContent = error.message;
feedback.style.display = 'block';
}
};
initBaseLevel();
form.addEventListener('submit', async (event) => {
event.preventDefault();
feedback.style.display = 'none';
resultWrapper.style.display = 'none';
const folderPath = currentPath();
if (!folderPath) {
feedback.textContent = 'Bitte einen Ordner auswählen.';
feedback.style.display = 'block';
return;
}
const submitButton = form.querySelector('button[type="submit"]');
submitButton.disabled = true;
submitButton.textContent = 'Wird geladen...';
try {
if (!queryUrl) throw new Error('Abfrage-Endpoint fehlt.');
const response = await fetch(queryUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Abfrage fehlgeschlagen.');
}
resultBody.innerHTML = '';
if (filetypeBody) {
filetypeBody.innerHTML = '';
}
if (data.categories && data.categories.length) {
data.categories.forEach((row) => {
const tr = document.createElement('tr');
const cat = document.createElement('td');
const cnt = document.createElement('td');
cat.textContent = row.category || 'Keine Kategorie';
cnt.textContent = row.count;
tr.append(cat, cnt);
resultBody.appendChild(tr);
});
} else {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 2;
td.textContent = 'Keine Treffer gefunden.';
tr.appendChild(td);
resultBody.appendChild(tr);
}
if (filetypeBody) {
if (data.filetypes && data.filetypes.length) {
data.filetypes.forEach((row) => {
const tr = document.createElement('tr');
const type = document.createElement('td');
const cnt = document.createElement('td');
type.textContent = row.type || 'Misc';
cnt.textContent = row.count;
tr.append(type, cnt);
filetypeBody.appendChild(tr);
});
} else {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 2;
td.textContent = 'Keine Datei-Typen gefunden.';
tr.appendChild(td);
filetypeBody.appendChild(tr);
}
}
totalCount.textContent = `${data.total || 0} Dateien`;
resultWrapper.style.display = 'block';
} catch (error) {
feedback.textContent = error.message;
feedback.style.display = 'block';
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Abfrage starten';
}
});
}
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
window.PageRegistry.register('search_db_analyzer', { init: initSearchDbAnalyzer });
}
})();

View File

@ -1,33 +1,34 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{{ title_short }}{% endblock %} {% block title %}{{ title_short }}{% endblock %}
{% block page_id %}app{% endblock %}
{% block head_extra %} {% block head_extra %}
<meta property="og:title" content="{{ og_title }}"> <meta property="og:title" content="{{ og_title }}" data-page-head>
<meta property="og:description" content="{{ og_description }}"> <meta property="og:description" content="{{ og_description }}" data-page-head>
<meta property="og:image" content="/icon/logo-192x192.png"> <meta property="og:image" content="/icon/logo-192x192.png" data-page-head>
<meta property="og:type" content="website"> <meta property="og:type" content="website" data-page-head>
<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." data-page-head>
<meta name="author" content="{{ title_short }}"> <meta name="author" content="{{ title_short }}" data-page-head>
<link rel="icon" href="/icon/logo-192x192.png" type="image/png" sizes="192x192"> <link rel="icon" href="/icon/logo-192x192.png" type="image/png" sizes="192x192" data-page-head>
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}"> <link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}" data-page-head>
<link rel="touch-icon" href="/icon/logo-192x192.png"> <link rel="touch-icon" href="/icon/logo-192x192.png" data-page-head>
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes" data-page-head>
<meta name="mobile-web-app-status-bar-style" content="default"> <meta name="mobile-web-app-status-bar-style" content="default" data-page-head>
<meta name="mobile-web-app-title" content="Gottesdienste"> <meta name="mobile-web-app-title" content="Gottesdienste" data-page-head>
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}" data-page-head>
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}"> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" data-page-head></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>const admin_enabled = {{ admin_enabled | tojson | safe }};</script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Tab Navigation --> <!-- Tab Navigation -->
{% if features %} {% if features %}
<nav class="main-tabs"> <nav class="main-tabs">
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %} <div class="main-tabs-scroll">
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %} {% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</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 %}
</div>
</nav> </nav>
{% endif %} {% endif %}
@ -62,6 +63,11 @@
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<!-- Transkript durchsuchen -->
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="includeTranscript" name="includeTranscript">
<label class="form-check-label" for="includeTranscript">Im Volltext suchen</label>
</div>
</div> </div>
<!-- Toggle für Suchoptionen --> <!-- Toggle für Suchoptionen -->
@ -80,6 +86,20 @@
<!-- Suchoptionen einklappbar --> <!-- Suchoptionen einklappbar -->
<div id="searchOptions" class="collapse border rounded p-3 mb-3"> <div id="searchOptions" class="collapse border rounded p-3 mb-3">
<!-- In Ordner suchen -->
<div class="mb-3">
<label for="folder" class="form-label">In Ordner suchen:</label>
<select id="folder" name="folder" class="form-select">
<option value="">Alle</option>
{% for folder in search_folders %}
<option value="{{ folder }}">{{ folder }}</option>
{% endfor %}
</select>
</div>
<hr>
<!-- Kategorie --> <!-- Kategorie -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Kategorie:</label> <label class="form-label">Kategorie:</label>
@ -113,19 +133,6 @@
<hr> <hr>
<!-- In Ordner suchen -->
<div class="mb-3">
<label for="folder" class="form-label">In Ordner suchen:</label>
<select id="folder" name="folder" class="form-select">
<option value="">Alle</option>
{% for folder in search_folders %}
<option value="{{ folder }}">{{ folder }}</option>
{% endfor %}
</select>
</div>
<hr>
<!-- Dateityp --> <!-- Dateityp -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Dateityp:</label> <label class="form-label">Dateityp:</label>
@ -142,6 +149,10 @@
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-image" value="image"> <input class="form-check-input" type="checkbox" name="filetype" id="filetype-image" value="image">
<label class="form-check-label" for="filetype-image">Bild</label> <label class="form-check-label" for="filetype-image">Bild</label>
</div> </div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-liedtext" value="liedtext">
<label class="form-check-label" for="filetype-liedtext">Liedtext</label>
</div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-other" value="other"> <input class="form-check-input" type="checkbox" name="filetype" id="filetype-other" value="other">
<label class="form-check-label" for="filetype-other">Sonstige</label> <label class="form-check-label" for="filetype-other">Sonstige</label>
@ -152,14 +163,6 @@
<hr> <hr>
<!-- Transkript durchsuchen -->
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="includeTranscript" name="includeTranscript">
<label class="form-check-label" for="includeTranscript">Im Transkript suchen</label>
</div>
<hr>
<!-- Zeitraum --> <!-- Zeitraum -->
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
@ -184,45 +187,6 @@
</div> </div>
</search> </search>
<!-- Global Audio Player in Footer -->
<footer>
<div class="audio-player-container" id="audioPlayerContainer">
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
<span class="icon-collapse" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 12h14" />
</svg>
</span>
<span class="icon-expand" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" />
</svg>
</span>
</button>
<div class="audio-player">
<audio id="globalAudio" prefetch="auto">
Your browser does not support the audio element.
</audio>
<div class="controls">
<button class="player-button icon-color">
</button>
<div class="slider">
<input type="range" class="timeline" max="100" value="0" step="0.1">
<div id="timeInfo" class="now-playing-info"></div>
</div>
<button class="sound-button icon-color" onclick="player.fileDownload()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2.25A9.75 9.75 0 1 1 2.25 12 9.75 9.75 0 0 1 12 2.25Zm0 5a.75.75 0 0 0-.75.75v4.19l-1.72-1.72a.75.75 0 1 0-1.06 1.06l3.03 3.03a.75.75 0 0 0 1.06 0l3.03-3.03a.75.75 0 1 0-1.06-1.06L12.75 12.2V8a.75.75 0 0 0-.75-.75ZM7.5 15.25a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 0-1.5Z" />
</svg>
</button>
</div>
</div>
<div id="nowPlayingInfo" class="now-playing-info">
keine Datei ausgewählt...
</div>
</div>
</footer>
<!-- Transcript Modal --> <!-- Transcript Modal -->
<div id="transcriptModal"> <div id="transcriptModal">
<div class="modal-content"> <div class="modal-content">
@ -253,11 +217,6 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='audioplayer.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='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

@ -18,13 +18,15 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
<script src="{{ url_for('static', filename='functions.js') }}"></script> <script src="{{ url_for('static', filename='functions.js') }}"></script>
<script>const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};</script>
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
<a href="/"> <a href="/" data-ajax-nav>
<img src="/custom_logo/logoW.png" alt="Logo" class="logo"> <img src="/custom_logo/logoW.png" alt="Logo" class="logo">
</a> </a>
<h1>{{ title_long }}</h1> <h1>{{ title_long }}</h1>
@ -35,22 +37,22 @@
<div class="admin-nav"> <div class="admin-nav">
<div class="admin-nav-rail"> <div class="admin-nav-rail">
<div class="admin-nav-track"> <div class="admin-nav-track">
<a href="{{ url_for('index') }}">App</a> <a href="{{ url_for('index') }}" data-ajax-nav>App</a>
<span> | </span> <span> | </span>
<a href="{{ url_for('mylinks') }}">Meine Links</a> <a href="{{ url_for('mylinks') }}" data-ajax-nav>Meine Links</a>
<span> | </span> <span> | </span>
<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" data-ajax-nav>Ordnerkonfiguration</a>
<span> | </span> <span> | </span>
<div class="dropdown dropend d-inline-block"> <div class="dropdown dropend d-inline-block">
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false"> <a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
Auswertungen Auswertungen
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}">Dashbord</a></li> <li><a class="dropdown-item" href="{{ url_for('dashboard') }}" data-ajax-nav>Dashbord</a></li>
<li><a class="dropdown-item" href="{{ url_for('connections') }}">Verbindungen</a></li> <li><a class="dropdown-item" href="{{ url_for('connections') }}" data-ajax-nav>Verbindungen</a></li>
<li><a class="dropdown-item" href="{{ url_for('file_access') }}">Dateizugriffe</a></li> <li><a class="dropdown-item" href="{{ url_for('file_access') }}" data-ajax-nav>Dateizugriffe</a></li>
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}">Wiederholungen</a></li> <li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}" data-ajax-nav>Wiederholungen</a></li>
<li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a></li> <li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}" data-ajax-nav>Ordner auswerten</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -58,11 +60,65 @@
</div> </div>
{% endif %} {% endif %}
{% block content %}{% endblock %} <div id="page-content" data-page-id="{% block page_id %}{% endblock %}">
{% block content %}{% endblock %}
</div>
<!-- Global Audio Player in Footer -->
<footer>
<div class="audio-player-container" id="audioPlayerContainer">
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
<span class="icon-collapse" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5 12h14" />
</svg>
</span>
<span class="icon-expand" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14" />
</svg>
</span>
</button>
<div class="audio-player">
<audio id="globalAudio" prefetch="auto">
Your browser does not support the audio element.
</audio>
<div class="controls">
<button class="player-button icon-color">
</button>
<div class="slider">
<input type="range" class="timeline" max="100" value="0" step="0.1">
<div id="timeInfo" class="now-playing-info"></div>
</div>
<button class="sound-button icon-color" onclick="player.fileDownload()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2.25A9.75 9.75 0 1 1 2.25 12 9.75 9.75 0 0 1 12 2.25Zm0 5a.75.75 0 0 0-.75.75v4.19l-1.72-1.72a.75.75 0 1 0-1.06 1.06l3.03 3.03a.75.75 0 0 0 1.06 0l3.03-3.03a.75.75 0 1 0-1.06-1.06L12.75 12.2V8a.75.75 0 0 0-.75-.75ZM7.5 15.25a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 0-1.5Z" />
</svg>
</button>
</div>
</div>
<div id="nowPlayingInfo" class="now-playing-info">
keine Datei ausgewählt...
</div>
</div>
</footer>
</div> </div>
<script src="{{ url_for('static', filename='audioplayer.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='general.js') }}"></script> <script src="{{ url_for('static', filename='general.js') }}"></script>
<script src="{{ url_for('static', filename='page_registry.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='search.js') }}"></script>
<script src="{{ url_for('static', filename='messages.js') }}"></script>
<script src="{{ url_for('static', filename='calendar.js') }}"></script>
<script src="{{ url_for('static', filename='file_access.js') }}"></script>
<script src="{{ url_for('static', filename='connections.js') }}"></script>
<script src="{{ url_for('static', filename='dashboard.js') }}"></script>
<script src="{{ url_for('static', filename='folder_secret_config_editor.js') }}"></script>
<script src="{{ url_for('static', filename='search_db_analyzer.js') }}"></script>
<script src="{{ url_for('static', filename='page_router.js') }}"></script>
</body> </body>
</html> </html>

View File

@ -1,11 +1,11 @@
<!-- Calendar Section --> <!-- Calendar Section -->
<section id="calendar-section" class="tab-content"> <section id="calendar-section" class="tab-content">
<div class="container"> <div class="tab-section-shell">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
<h3 class="mb-0">Kalender</h3> <h3 class="mb-0">Kalender</h3>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<select id="calendar-location-filter" class="form-select form-select-sm"> <select id="calendar-location-filter" class="form-select form-select-sm">
<option value="">Ort (alle)</option> <option value="">alle</option>
</select> </select>
{% if admin_enabled %} {% if admin_enabled %}
<div class="btn-group" role="group"> <div class="btn-group" role="group">
@ -24,10 +24,12 @@
</div> </div>
</div> </div>
<div class="calendar-weekday-header"></div> <div class="tab-section-body">
<div class="calendar-weekday-header"></div>
<div id="calendar-days" class="calendar-grid"> <div id="calendar-days" class="calendar-grid">
<!-- Populated via inline script below --> <!-- Populated via inline script below -->
</div>
</div> </div>
</div> </div>
</section> </section>
@ -83,673 +85,3 @@
</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

@ -2,11 +2,13 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Recent Connections{% endblock %} {% block title %}Recent Connections{% endblock %}
{% block page_id %}connections{% endblock %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" data-page-head />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-page-head></script>
<style> <script src="https://cdn.socket.io/4.0.0/socket.io.min.js" data-page-head></script>
<style data-page-head>
/* page-specific styles */ /* page-specific styles */
.split-view { .split-view {
display: flex; display: flex;
@ -101,7 +103,10 @@
{% block content %} {% block content %}
<div class="page-content"> <div class="page-content">
<div class="section-header"> <div class="section-header">
<h2 style="margin: 0;">Verbindungen der letzten 10 Minuten (nur Audio)</h2> <h2 style="margin: 0;">
Verbindungen der letzten 10 Minuten (nur Audio)
<span class="text-muted" id="connectionsTotalHeader" style="font-size: 14px; margin-left: 8px;">0 Verbindungen</span>
</h2>
<p class="text-muted" style="margin: 4px 0 0 0;">Diese Ansicht listet ausschließlich Zugriffe auf Audio-Dateien.</p> <p class="text-muted" style="margin: 4px 0 0 0;">Diese Ansicht listet ausschließlich Zugriffe auf Audio-Dateien.</p>
<div class="stats"> <div class="stats">
<div class="stat-item"> <div class="stat-item">
@ -154,269 +159,4 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}{% endblock %}
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
<script>
// Initialize the map centered on Europe
const map = L.map('map').setView([50.0, 10.0], 5);
const refreshMapSize = () => setTimeout(() => map.invalidateSize(), 150);
refreshMapSize();
window.addEventListener('resize', refreshMapSize);
window.addEventListener('orientationchange', refreshMapSize);
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
// Store active dots with their data
const activeDots = new Map();
let totalConnections = 0;
// Custom pulsing circle marker
const PulsingMarker = L.CircleMarker.extend({
options: {
radius: 10,
fillColor: '#ff4444',
color: '#ff0000',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}
});
// WebSocket connection
const socket = io();
socket.on("connect", () => {
socket.emit("request_initial_data");
socket.emit("request_map_data");
});
// Map functions
function addOrUpdateDot(lat, lon, city, country, timestamp) {
const key = `${lat},${lon}`;
const now = Date.now();
if (activeDots.has(key)) {
const dot = activeDots.get(key);
dot.mass += 1;
dot.lastUpdate = now;
dot.connections.push({ city, country, timestamp });
const newRadius = 10 + Math.log(dot.mass) * 5;
dot.marker.setRadius(newRadius);
updatePopup(dot);
} else {
const marker = new PulsingMarker([lat, lon], {
radius: 10
}).addTo(map);
const dot = {
marker: marker,
mass: 1,
createdAt: now,
lastUpdate: now,
lat: lat,
lon: lon,
connections: [{ city, country, timestamp }]
};
activeDots.set(key, dot);
updatePopup(dot);
totalConnections++;
}
updateMapStats();
}
function updatePopup(dot) {
const location = dot.connections[0];
const content = `
<strong>${location.city}, ${location.country}</strong><br>
Connections: ${dot.mass}<br>
Latest: ${new Date(location.timestamp).toLocaleString()}
`;
dot.marker.bindPopup(content);
}
function updateMapStats() {
let mostRecent = null;
activeDots.forEach(dot => {
if (!mostRecent || dot.lastUpdate > mostRecent) {
mostRecent = dot.lastUpdate;
}
});
if (mostRecent) {
const timeAgo = getTimeAgo(mostRecent);
document.getElementById('last-connection').textContent = timeAgo;
}
}
function getTimeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
function formatBytes(bytes) {
if (!bytes && bytes !== 0) return "-";
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let num = bytes;
let unitIndex = 0;
while (num >= 1024 && unitIndex < units.length - 1) {
num /= 1024;
unitIndex++;
}
const rounded = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
return `${rounded} ${units[unitIndex]}`;
}
function toBool(val) {
if (val === true || val === 1) return true;
if (typeof val === 'string') {
const lower = val.toLowerCase();
return lower === 'true' || lower === '1' || lower === 'yes';
}
return false;
}
function animateDots() {
const now = Date.now();
const fadeTime = 10 * 60 * 1000; // 10 minutes
activeDots.forEach((dot, key) => {
const age = now - dot.lastUpdate;
if (age > fadeTime) {
map.removeLayer(dot.marker);
activeDots.delete(key);
} else {
const fadeProgress = age / fadeTime;
const baseRadius = 10 + Math.log(dot.mass) * 5;
const currentRadius = baseRadius * (1 - fadeProgress * 0.8);
dot.marker.setRadius(Math.max(currentRadius, 2));
const opacity = 1 - fadeProgress * 0.7;
dot.marker.setStyle({
fillOpacity: opacity * 0.8,
opacity: opacity
});
}
});
updateMapStats();
}
setInterval(animateDots, 100);
setInterval(() => {
if (activeDots.size > 0) {
updateMapStats();
}
}, 1000);
// Handle map data
socket.on('map_initial_data', function(data) {
console.log('Received map data:', data.connections.length, 'connections');
data.connections.forEach(conn => {
if (conn.lat && conn.lon) {
addOrUpdateDot(conn.lat, conn.lon, conn.city, conn.country, conn.timestamp);
}
});
});
socket.on('new_connection', function(data) {
console.log('New connection:', data);
if (data.lat && data.lon) {
addOrUpdateDot(data.lat, data.lon, data.city, data.country, data.timestamp);
}
});
// Table functions
function updateStats(data) {
document.getElementById("totalConnections").textContent = data.length;
const totalBytes = data.reduce((sum, rec) => {
const val = parseFloat(rec.filesize);
return sum + (isNaN(val) ? 0 : val);
}, 0);
document.getElementById("totalSize").textContent = formatBytes(totalBytes);
const cachedCount = data.reduce((sum, rec) => sum + (toBool(rec.cached) ? 1 : 0), 0);
const pct = data.length ? ((cachedCount / data.length) * 100).toFixed(0) : 0;
document.getElementById("cachedPercent").textContent = `${pct}%`;
}
function createRow(record, animate=true) {
const tr = document.createElement("tr");
tr.dataset.timestamp = record.timestamp;
if (animate) {
tr.classList.add("slide-in");
tr.addEventListener("animationend", () =>
tr.classList.remove("slide-in"), { once: true });
}
tr.innerHTML = `
<td>${record.timestamp}</td>
<td>${record.location}</td>
<td>${record.user_agent}</td>
<td>${record.full_path}</td>
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
<td>${record.mime_typ}</td>
<td>${record.cached}</td>
`;
return tr;
}
function updateTable(data) {
const tbody = document.getElementById("connectionsTableBody");
const existing = {};
Array.from(tbody.children).forEach(r => existing[r.dataset.timestamp] = r);
const frag = document.createDocumentFragment();
data.forEach(rec => {
let row = existing[rec.timestamp];
if (row) {
row.innerHTML = createRow(rec, false).innerHTML;
row.classList.remove("slide-out");
} else {
row = createRow(rec, true);
}
frag.appendChild(row);
});
tbody.innerHTML = "";
tbody.appendChild(frag);
}
function animateTableWithNewRow(data) {
const tbody = document.getElementById("connectionsTableBody");
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
const newRecs = data.filter(r => !currentTs.has(r.timestamp));
if (newRecs.length) {
const temp = createRow(newRecs[0], false);
temp.style.visibility = "hidden";
tbody.appendChild(temp);
const h = temp.getBoundingClientRect().height;
temp.remove();
tbody.style.transform = `translateY(${h}px)`;
setTimeout(() => {
updateTable(data);
tbody.style.transition = "none";
tbody.style.transform = "translateY(0)";
void tbody.offsetWidth;
tbody.style.transition = "transform 0.5s ease-out";
}, 500);
} else {
updateTable(data);
}
}
socket.on("recent_connections", data => {
updateStats(data);
animateTableWithNewRow(data);
});
</script>
{% endblock %}

View File

@ -3,11 +3,13 @@
{# page title #} {# page title #}
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block page_id %}dashboard{% endblock %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" data-page-head />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-page-head></script>
<style> <script src="https://cdn.jsdelivr.net/npm/chart.js" data-page-head></script>
<style data-page-head>
#dashboard-map { #dashboard-map {
width: 100%; width: 100%;
height: 420px; height: 420px;
@ -269,210 +271,16 @@
</div> </div>
</div> </div>
</div> </div>
<script type="application/json" id="dashboard-page-data">
{{ {
'map_data': map_data,
'distinct_device_data': distinct_device_data,
'timeframe_data': timeframe_data,
'user_agent_data': user_agent_data,
'folder_data': folder_data,
'timeframe': timeframe
} | tojson }}
</script>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}{% endblock %}
<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 {
const createLeafletMap = (mapElement) => {
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.isValid()) {
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);
return { map, refreshSize };
};
(function initDashboardMap() {
const mapElement = document.getElementById('dashboard-map');
if (!mapElement || typeof L === 'undefined') return;
createLeafletMap(mapElement);
const modalEl = document.getElementById('mapModal');
let modalMapInstance = null;
if (modalEl) {
modalEl.addEventListener('shown.bs.modal', () => {
const modalMapElement = document.getElementById('dashboard-map-modal');
if (!modalMapElement) return;
const modalBody = modalEl.querySelector('.modal-body');
const modalHeader = modalEl.querySelector('.modal-header');
if (modalBody && modalHeader) {
const availableHeight = window.innerHeight - modalHeader.offsetHeight;
modalBody.style.height = `${availableHeight}px`;
modalMapElement.style.height = '100%';
}
if (!modalMapInstance) {
const { map, refreshSize } = createLeafletMap(modalMapElement);
modalMapInstance = { map, refreshSize };
}
// Ensure proper sizing after modal animation completes
setTimeout(() => {
modalMapInstance.map.invalidateSize();
if (modalMapInstance.refreshSize) modalMapInstance.refreshSize();
}, 200);
});
}
})();
} 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 }};
const userAgentData = {{ user_agent_data|tojson }};
const folderData = {{ folder_data|tojson }};
// Remove the first (incomplete) bucket from the arrays
// distinctDeviceData = distinctDeviceData.slice(1);
// timeframeData = timeframeData.slice(1);
// Shift the labels to local time zone
const timeframe = "{{ timeframe }}"; // e.g., 'last24hours', '7days', '30days', or '365days'
const shiftedLabels = timeframeData.map((item) => {
if (timeframe === 'last24hours') {
const bucketDate = new Date(item.bucket);
const now = new Date();
const isCurrentHour =
bucketDate.getFullYear() === now.getFullYear() &&
bucketDate.getMonth() === now.getMonth() &&
bucketDate.getDate() === now.getDate() &&
bucketDate.getHours() === now.getHours();
const bucketEnd = isCurrentHour ? now : new Date(bucketDate.getTime() + 3600 * 1000);
return `${bucketDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${bucketEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
} else if (timeframe === '7days' || timeframe === '30days') {
const localDate = new Date(item.bucket);
return localDate.toLocaleDateString();
} else if (timeframe === '365days') {
const [year, month] = item.bucket.split('-');
const dateObj = new Date(year, month - 1, 1);
return dateObj.toLocaleString([], { month: 'short', year: 'numeric' });
} else {
return item.bucket;
}
});
// Distinct Device Chart
const ctxDistinctDevice = document.getElementById('distinctDeviceChart').getContext('2d');
new Chart(ctxDistinctDevice, {
type: 'bar',
data: {
labels: shiftedLabels,
datasets: [{
label: 'Device Count',
data: distinctDeviceData.map(item => item.count),
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Time Range' } },
y: {
title: { display: true, text: 'Device Count' },
beginAtZero: true,
ticks: { stepSize: 1 }
}
}
}
});
// Timeframe Breakdown Chart
const ctxTimeframe = document.getElementById('downloadTimeframeChart').getContext('2d');
new Chart(ctxTimeframe, {
type: 'bar',
data: {
labels: shiftedLabels,
datasets: [{
label: 'Download Count',
data: timeframeData.map(item => item.count),
borderWidth: 2
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Time Range' } },
y: {
title: { display: true, text: 'Download Count' },
beginAtZero: true,
ticks: { stepSize: 1 }
}
}
}
});
// User Agent Distribution Chart - Pie Chart
const ctxUserAgent = document.getElementById('userAgentChart').getContext('2d');
new Chart(ctxUserAgent, {
type: 'pie',
data: {
labels: userAgentData.map(item => item.device),
datasets: [{
data: userAgentData.map(item => item.count)
}]
},
options: { responsive: true }
});
// Folder Distribution Chart - Pie Chart
const ctxFolder = document.getElementById('folderChart').getContext('2d');
new Chart(ctxFolder, {
type: 'pie',
data: {
labels: folderData.map(item => item.folder),
datasets: [{
data: folderData.map(item => item.count)
}]
},
options: { responsive: true }
});
</script>
{% endblock %}

View File

@ -3,6 +3,7 @@
{# page title #} {# page title #}
{% block title %}Dateizugriffe{% endblock %} {% block title %}Dateizugriffe{% endblock %}
{% block page_id %}file_access{% endblock %}
{# page content #} {# page content #}
{% block content %} {% block content %}
@ -92,18 +93,30 @@
<div class="card-body"> <div class="card-body">
{% if top20_files %} {% if top20_files %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped"> <table class="table table-striped" id="file-access-table">
<thead> <thead>
<tr> <tr>
<th>Access Count</th> <th>Access Count</th>
<th>File Path</th> <th>File Path</th>
<th>Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for row in top20_files %} {% for row in top20_files %}
{% set parent_path = row.rel_path.rsplit('/', 1)[0] if '/' in row.rel_path else '' %}
<tr> <tr>
<td>{{ row.access_count }}</td> <td>{{ row.access_count }}</td>
<td>{{ row.rel_path }}</td> <td>{{ row.rel_path }}</td>
<td>
<div class="d-flex gap-2 file-access-actions">
<button class="btn btn-light btn-sm play-access-btn" data-path="{{ row.rel_path | e }}" title="Play" aria-label="Play">
<i class="bi bi-volume-up"></i>
</button>
<button class="btn btn-light btn-sm folder-open-btn" data-folder="{{ parent_path | e }}" data-file="{{ row.rel_path | e }}" title="Ordner öffnen" aria-label="Ordner öffnen">
<i class="bi bi-folder"></i>
</button>
</div>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -3,6 +3,7 @@
{# page title #} {# page title #}
{% block title %}Ordnerkonfiguration{% endblock %} {% block title %}Ordnerkonfiguration{% endblock %}
{% block page_id %}folder_secret_config{% endblock %}
{# page content #} {# page content #}
{% block content %} {% block content %}
@ -30,446 +31,8 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} <script type="application/json" id="folder-config-page-data">
{{ { 'alphabet': alphabet } | tojson }}
{% block scripts %}
<script>
const ALPHABET = {{ alphabet|tojson }};
let data = [];
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('.');
return (dd && mm && yyyy) ? `${yyyy}-${mm}-${dd}` : '';
}
// load from server
async function loadData() {
try {
const res = await fetch('/admin/folder_secret_config_editor/data');
data = await res.json();
render();
} catch (err) {
console.error(err);
}
}
// send delete/update actions
async function sendAction(payload) {
await fetch('/admin/folder_secret_config_editor/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
await loadData();
}
// delete record after confirmation
function deleteRec(secret) {
sendAction({ action: 'delete', secret });
}
// build all cards
function render() {
const cont = document.getElementById('records');
cont.innerHTML = '';
data.forEach(rec => {
const key = rec.secret;
const isEdit = editing.has(key);
const expired = (formatISO(rec.validity) && new Date(formatISO(rec.validity)) < new Date());
const cls = isEdit ? 'unlocked' : 'locked';
// outer card
const wrapper = document.createElement('div');
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}`;
// 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);
// 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 = false;
valInput.dataset.field = 'validity';
valDiv.appendChild(valInput);
body.appendChild(valDiv);
}
// folders
const folderHeader = document.createElement('h6');
folderHeader.textContent = 'Ordner';
body.appendChild(folderHeader);
rec.folders.forEach((f, i) => {
const fwrap = document.createElement('div');
fwrap.style = 'border:1px solid #ccc; padding:10px; margin-bottom:10px; border-radius:5px;';
// name
fwrap.appendChild(document.createTextNode("Ordnername: "));
const nameGroup = document.createElement('div');
nameGroup.className = 'input-group mb-2';
const nameInput = document.createElement('input');
nameInput.className = 'form-control';
nameInput.type = 'text';
nameInput.value = f.foldername;
nameInput.readOnly = !isEdit;
nameInput.dataset.field = `foldername-${i}`;
nameGroup.appendChild(nameInput);
if (isEdit) {
const remBtn = document.createElement('button');
remBtn.className = 'btn btn-outline-danger';
remBtn.type = 'button';
remBtn.textContent = 'entfernen';
remBtn.addEventListener('click', () => removeFolder(key, i));
nameGroup.appendChild(remBtn);
}
fwrap.appendChild(nameGroup);
// path
fwrap.appendChild(document.createTextNode("Ordnerpfad: "));
const pathInput = document.createElement('input');
pathInput.className = 'form-control mb-2';
pathInput.type = 'text';
pathInput.value = f.folderpath;
pathInput.readOnly = !isEdit;
pathInput.dataset.field = `folderpath-${i}`;
fwrap.appendChild(pathInput);
body.appendChild(fwrap);
});
if (isEdit) {
const addFld = document.createElement('button');
addFld.className = 'btn btn-sm btn-primary mb-2';
addFld.type = 'button';
addFld.textContent = 'Ordner hinzufügen';
addFld.addEventListener('click', () => addFolder(key));
body.appendChild(addFld);
}
// actions row
const actions = document.createElement('div');
if (!isEdit) {
const openButton = document.createElement('button');
openButton.className = 'btn btn-secondary btn-sm me-2';
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
openButton.textContent = 'Link öffnen';
actions.appendChild(openButton);
}
if (!isEdit) {
const openButton = document.createElement('button');
openButton.className = 'btn btn-secondary btn-sm me-2';
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
openButton.textContent = 'Link kopieren';
actions.appendChild(openButton);
}
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-sm me-2 delete-btn';
delBtn.type = 'button';
delBtn.textContent = 'löschen';
delBtn.dataset.secret = key;
actions.appendChild(delBtn);
const cloneBtn = document.createElement('button');
cloneBtn.className = 'btn btn-secondary btn-sm me-2';
cloneBtn.type = 'button';
cloneBtn.textContent = 'clonen';
cloneBtn.addEventListener('click', () => cloneRec(key));
actions.appendChild(cloneBtn);
if (isEdit || expired) {
const renewBtn = document.createElement('button');
renewBtn.className = 'btn btn-info btn-sm me-2';
renewBtn.type = 'button';
renewBtn.textContent = 'erneuern';
renewBtn.addEventListener('click', () => renewRec(key));
actions.appendChild(renewBtn);
}
if (isEdit) {
const saveBtn = document.createElement('button');
saveBtn.className = 'btn btn-success btn-sm me-2';
saveBtn.type = 'button';
saveBtn.textContent = 'speichern';
saveBtn.addEventListener('click', () => saveRec(key));
actions.appendChild(saveBtn);
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);
collapseDiv.appendChild(body);
wrapper.appendChild(collapseDiv);
cont.appendChild(wrapper);
});
}
// generate a unique secret
function generateSecret(existing) {
let s;
do {
s = Array.from({length:32}, () => ALPHABET[Math.floor(Math.random()*ALPHABET.length)]).join('');
} while (existing.includes(s));
return s;
}
// CRUD helpers
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);
const rec = JSON.parse(JSON.stringify(data[idx]));
const existing = data.map(r => r.secret);
rec.secret = generateSecret(existing);
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 35);
// Format as DD.MM.YYYY for validity input
const dd = String(futureDate.getDate()).padStart(2, '0');
const mm = String(futureDate.getMonth() + 1).padStart(2, '0');
const yyyy = futureDate.getFullYear();
rec.validity = `${dd}.${mm}.${yyyy}`;
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
const rec = data.find(r => r.secret === secret);
if (!rec) return;
// generate a fresh unique secret
const existing = data.map(r => r.secret);
const newSecret = generateSecret(existing);
// validity = today + 35 days, formatted as YYYY-MM-DD
const future = new Date();
future.setDate(future.getDate() + 35);
const yyyy = future.getFullYear();
const mm = String(future.getMonth() + 1).padStart(2, '0');
const dd = String(future.getDate()).padStart(2, '0');
const validity = `${yyyy}-${mm}-${dd}`;
// keep folders unchanged
const folders = rec.folders.map(f => ({
foldername: f.foldername,
folderpath: f.folderpath
}));
// persist via existing endpoint
await sendAction({
action: 'update',
oldSecret: secret,
newSecret,
validity,
folders
});
}
function addFolder(secret) {
data.find(r => r.secret === secret).folders.push({foldername:'', folderpath:''});
render();
}
function removeFolder(secret, i) {
data.find(r => r.secret === secret).folders.splice(i,1);
render();
}
async function saveRec(secret) {
const card = document.querySelector(`[data-secret="${secret}"]`);
const newSecret = card.querySelector('input[data-field="secret"]').value.trim();
const validity = card.querySelector('input[data-field="validity"]').value;
const rec = data.find(r => r.secret === secret);
const folders = rec.folders.map((_, i) => ({
foldername: card.querySelector(`input[data-field="foldername-${i}"]`).value.trim(),
folderpath: card.querySelector(`input[data-field="folderpath-${i}"]`).value.trim()
}));
if (!newSecret || data.some(r => r.secret !== secret && r.secret === newSecret)) {
return alert('Secret must be unique and non-empty');
}
if (!validity) {
return alert('Validity required');
}
if (folders.some(f => !f.foldername || !f.folderpath)) {
return alert('Folder entries cannot be empty');
}
await sendAction({
action: 'update',
oldSecret: secret,
newSecret,
validity,
folders
});
editing.delete(secret);
await loadData();
}
// modal handling for delete
document.addEventListener('click', e => {
if (e.target.matches('.delete-btn')) {
pendingDelete = e.target.dataset.secret;
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
modal.show();
}
});
document.querySelector('.confirm-delete').addEventListener('click', () => {
if (pendingDelete) deleteRec(pendingDelete);
pendingDelete = null;
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
modal.hide();
});
// init
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('add-btn').addEventListener('click', () => {
const existing = data.map(r => r.secret);
const newSecret = generateSecret(existing);
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();
});
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %}{% endblock %}

View File

@ -1,7 +1,7 @@
<!-- Messages Section --> <!-- Messages Section -->
<section id="messages-section" class="tab-content"> <section id="messages-section" class="tab-content">
<div class="container"> <div class="tab-section-shell">
<div class="d-flex justify-content-between align-items-center mb-3 messages-section-header"> <div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
<h3 class="mb-0">Nachrichten</h3> <h3 class="mb-0">Nachrichten</h3>
{% if admin_enabled %} {% if admin_enabled %}
<div> <div>
@ -20,7 +20,7 @@
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div id="messages-container" class="messages-container"> <div id="messages-container" class="messages-container tab-section-body">
<!-- Messages will be loaded here via JavaScript --> <!-- Messages will be loaded here via JavaScript -->
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@
{# page title #} {# page title #}
{% block title %}Meine Links{% endblock %} {% block title %}Meine Links{% endblock %}
{% block page_id %}mylinks{% endblock %}
{# pagespecific content #} {# pagespecific content #}
{% block content %} {% block content %}

View File

@ -3,6 +3,7 @@
{# page title #} {# page title #}
{% block title %}Dateizugriffe{% endblock %} {% block title %}Dateizugriffe{% endblock %}
{% block page_id %}permission{% endblock %}
{# page content #} {# page content #}
{% block content %} {% block content %}

View File

@ -1,10 +1,11 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Search-DB Analyse{% endblock %} {% block title %}Search-DB Analyse{% endblock %}
{% block page_id %}search_db_analyzer{% endblock %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<h2>Index auswerten</h2> <h2>Ordner auswerten</h2>
<p class="text-muted"> <p class="text-muted">
Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen. Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen.
</p> </p>
@ -27,236 +28,42 @@
<h4 class="mb-0">Ergebnis</h4> <h4 class="mb-0">Ergebnis</h4>
<span class="badge bg-secondary" id="totalCount"></span> <span class="badge bg-secondary" id="totalCount"></span>
</div> </div>
<div class="table-responsive"> <div class="row g-3">
<table class="table table-striped align-middle"> <div class="col-lg-7">
<thead> <div class="table-responsive">
<tr> <table class="table table-striped align-middle">
<th>Kategorie</th> <thead>
<th>Anzahl Dateien</th> <tr>
</tr> <th>Kategorie</th>
</thead> <th>Anzahl Dateien</th>
<tbody id="result-body"></tbody> </tr>
</table> </thead>
<tbody id="result-body"></tbody>
</table>
</div>
</div>
<div class="col-lg-5">
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th>Dateityp</th>
<th>Anzahl Dateien</th>
</tr>
</thead>
<tbody id="filetype-body"></tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} <script type="application/json" id="search-db-analyzer-data">
{{ {
{% block scripts %} 'folders_url': url_for('search_db_folders'),
{{ super() }} 'query_url': url_for('search_db_query')
<script> } | tojson }}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('analyzer-form');
const feedback = document.getElementById('analyzer-feedback');
const resultWrapper = document.getElementById('analyzer-result');
const resultBody = document.getElementById('result-body');
const totalCount = document.getElementById('totalCount');
const levelsContainer = document.getElementById('folder-levels');
const levelSelects = [];
const createSelect = (level, options) => {
const wrapper = document.createElement('div');
wrapper.className = 'folder-level';
const select = document.createElement('select');
select.className = 'form-select';
select.dataset.level = level;
select.required = level === 0;
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = level === 0 ? 'Bitte auswählen' : 'Optionaler Unterordner';
select.appendChild(placeholder);
options.forEach((opt) => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
select.appendChild(option);
});
select.addEventListener('change', () => {
removeLevelsFrom(level + 1);
updateControlButtons();
});
const controls = document.createElement('div');
controls.className = 'd-flex gap-2 align-items-center';
const btnGroup = document.createElement('div');
btnGroup.className = 'btn-group btn-group-sm';
const addBtn = document.createElement('button');
addBtn.type = 'button';
addBtn.className = 'btn btn-outline-secondary';
addBtn.textContent = '+';
addBtn.addEventListener('click', () => handleAddLevel());
btnGroup.appendChild(addBtn);
let removeBtn = null;
if (level > 0) {
removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-outline-danger';
removeBtn.textContent = '-';
removeBtn.addEventListener('click', () => {
removeLevelsFrom(level);
updateControlButtons();
});
btnGroup.appendChild(removeBtn);
}
controls.append(select, btnGroup);
wrapper.append(controls);
levelsContainer.appendChild(wrapper);
levelSelects.push({ wrapper, select, addBtn, removeBtn, btnGroup });
};
const removeLevelsFrom = (startIndex) => {
while (levelSelects.length > startIndex) {
const item = levelSelects.pop();
levelsContainer.removeChild(item.wrapper);
}
};
const currentPath = () => {
const parts = levelSelects
.map(({ select }) => select.value)
.filter((v) => v && v.trim() !== '');
return parts.join('/');
};
const fetchChildren = async (parentPath = '') => {
const params = parentPath ? `?parent=${encodeURIComponent(parentPath)}` : '';
const response = await fetch(`{{ url_for("search_db_folders") }}${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Ordner konnten nicht geladen werden.');
}
return data.children || [];
};
const updateControlButtons = () => {
if (!levelSelects.length) {
return;
}
levelSelects.forEach((item, idx) => {
const isLast = idx === levelSelects.length - 1;
if (item.btnGroup) {
item.btnGroup.classList.toggle('d-none', !isLast);
}
if (item.addBtn) {
item.addBtn.disabled = !item.select.value;
}
if (item.removeBtn) {
const hideRemove = !isLast || idx === 0;
item.removeBtn.classList.toggle('d-none', hideRemove);
item.removeBtn.disabled = hideRemove;
}
});
};
const initBaseLevel = async () => {
try {
const children = await fetchChildren('');
if (!children.length) {
feedback.textContent = 'Keine Ordner in der Datenbank gefunden.';
feedback.style.display = 'block';
return;
}
createSelect(0, children);
updateControlButtons();
} catch (error) {
feedback.textContent = error.message;
feedback.style.display = 'block';
}
};
const handleAddLevel = async () => {
feedback.style.display = 'none';
const path = currentPath();
if (!path) {
feedback.textContent = 'Bitte zuerst einen Hauptordner auswählen.';
feedback.style.display = 'block';
return;
}
try {
const children = await fetchChildren(path);
if (!children.length) {
feedback.textContent = 'Keine weiteren Unterordner vorhanden.';
feedback.style.display = 'block';
return;
}
createSelect(levelSelects.length, children);
updateControlButtons();
} catch (error) {
feedback.textContent = error.message;
feedback.style.display = 'block';
}
};
initBaseLevel();
form.addEventListener('submit', async (event) => {
event.preventDefault();
feedback.style.display = 'none';
resultWrapper.style.display = 'none';
const folderPath = currentPath();
if (!folderPath) {
feedback.textContent = 'Bitte einen Ordner auswählen.';
feedback.style.display = 'block';
return;
}
const submitButton = form.querySelector('button[type="submit"]');
submitButton.disabled = true;
submitButton.textContent = 'Wird geladen...';
try {
const response = await fetch('{{ url_for("search_db_query") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Abfrage fehlgeschlagen.');
}
resultBody.innerHTML = '';
if (data.categories && data.categories.length) {
data.categories.forEach((row) => {
const tr = document.createElement('tr');
const cat = document.createElement('td');
const cnt = document.createElement('td');
cat.textContent = row.category || 'Keine Kategorie';
cnt.textContent = row.count;
tr.append(cat, cnt);
resultBody.appendChild(tr);
});
} else {
const tr = document.createElement('tr');
const td = document.createElement('td');
td.colSpan = 2;
td.textContent = 'Keine Treffer gefunden.';
tr.appendChild(td);
resultBody.appendChild(tr);
}
totalCount.textContent = `${data.total || 0} Dateien`;
resultWrapper.style.display = 'block';
} catch (error) {
feedback.textContent = error.message;
feedback.style.display = 'block';
} finally {
submitButton.disabled = false;
submitButton.textContent = 'Abfrage starten';
}
});
});
</script> </script>
{% endblock %} {% endblock %}
{% block scripts %}{% endblock %}

View File

@ -3,6 +3,7 @@
{# page title #} {# page title #}
{% block title %}Analyse Wiederholungen{% endblock %} {% block title %}Analyse Wiederholungen{% endblock %}
{% block page_id %}songs_dashboard{% endblock %}
{# page content #} {# page content #}
{% block content %} {% block content %}
@ -157,4 +158,4 @@
</table> </table>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -139,21 +139,21 @@ def process_file(file_path, model, audio_input):
Transcribe the audio file into one markdown file. Transcribe the audio file into one markdown file.
If special case (German sermon in Russian or Russian-marked file), transcribe both in Russian and German into the same file. If special case (German sermon in Russian or Russian-marked file), transcribe both in Russian and German into the same file.
""" """
file_name = os.path.basename(file_path) # file_name = os.path.basename(file_path)
# Detect spoken language # # Detect spoken language
detected = detect_language(model, audio_input) # detected = detect_language(model, audio_input)
# Determine which languages to transcribe # # Determine which languages to transcribe
if (detected == 'ru' and 'predigt' in file_name.lower()) or \ # if (detected == 'ru' and 'predigt' in file_name.lower()) or \
(detected == 'de' and 'russisch' in file_name.lower()): # (detected == 'de' and 'russisch' in file_name.lower()):
langs = ['de', 'ru'] # langs = ['de', 'ru']
elif detected == 'en': # songs often mis-detected as English # elif detected == 'en': # songs often mis-detected as English
langs = ['de'] # langs = ['de']
elif detected in ('de', 'ru'): # elif detected in ('de', 'ru'):
langs = [detected] # langs = [detected]
else: # else:
langs = ['ru'] langs = ['de', 'ru', 'en']
# Collect segments for combined result # Collect segments for combined result
lang_collection = {} lang_collection = {}

View File

@ -129,13 +129,38 @@ def process_file(file_path, model, audio_input, language=None, postfix=None):
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) != 2: # Folder where your audio/video files are stored
print("Usage: python transcribe_all.py <file>") input_folder = "transcribe_single"
# Check if folder exists
if not os.path.isdir(input_folder):
print(f"Error: Folder '{input_folder}' not found.")
sys.exit(1) sys.exit(1)
file_name_path = sys.argv[1] # List all supported file types
supported_ext = (".mp3", ".wav", ".m4a", ".mp4", ".mov", ".flac", ".ogg")
print("Loading Whisper model...") files = [
model = whisper.load_model(model_name, device="cuda") os.path.join(input_folder, f)
audio = whisper.load_audio(file_name_path) for f in os.listdir(input_folder)
process_file(file_name_path, model, audio, "de") if f.lower().endswith(supported_ext)
]
if not files:
print(f"No audio/video files found in '{input_folder}'.")
sys.exit(1)
print(f"Found {len(files)} file(s) in '{input_folder}':")
for f in files:
print(f" - {f}")
print("\nLoading Whisper model...")
model = whisper.load_model(model_name, device="cuda") # or "cpu" if no GPU
# Process each file one by one
for file_path in files:
try:
audio = whisper.load_audio(file_path)
process_file(file_path, model, audio, "de") # or None to auto-detect language
except Exception as e:
print(f"Error processing {file_path}: {e}")