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:
path_accum = f"{path_accum}/{part}" if path_accum else 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})
return breadcrumbs

View File

@ -3,6 +3,7 @@ import re
import os
import sqlite3
from datetime import datetime, timedelta
import time
from typing import Optional
import auth
@ -12,6 +13,9 @@ CATEGORY_KEYWORDS = app_config['CATEGORY_KEYWORDS']
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
@ -110,6 +114,16 @@ def extract_structure_from_string(input_string):
return category, titel, name
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()
@ -169,4 +183,5 @@ def generate_top_list(category):
'file_type': 'music'
})
_TOPLIST_CACHE[cache_key] = {"ts": now_ts, "data": list(filelist)}
return filelist

View File

@ -4,6 +4,7 @@ import sqlite3
from datetime import datetime
from time import monotonic
import re
from typing import Optional
import helperfunctions as hf
SEARCH_DB_NAME = 'search.db'
@ -139,6 +140,20 @@ def build_transcript_index(transcript_dir: str, stats: dict):
return index
except FileNotFoundError:
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:
log_permission_error(transcript_dir, stats)
return None
@ -264,6 +279,14 @@ def updatefileindex():
except Exception:
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)
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 json
from datetime import datetime
from urllib.parse import quote
app = Flask(__name__)
@ -15,9 +16,11 @@ with open("app_config.json", 'r') as file:
FILETYPE_GROUPS = {
'audio': ('.mp3', '.wav', '.ogg', '.m4a', '.flac'),
'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_KEYS = set(FILETYPE_GROUPS.keys())
def searchcommand():
query = request.form.get("query", "").strip()
@ -88,7 +91,7 @@ def searchcommand():
include_other = 'other' in filetypes
# If not all groups selected, apply filter
if set(filetypes) != {'audio', 'video', 'image', 'other'}:
if set(filetypes) != (ALL_GROUP_KEYS | {'other'}):
clauses = []
if selected_groups:
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)
cursor.execute(sql, params)
raw_results = cursor.fetchall()
total_results = len(raw_results)
# Process results
results = []
@ -119,6 +123,8 @@ def searchcommand():
transcript.lower().count(w.lower()) for w in words
)
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
if record.get('performance_date'):
try:
@ -135,5 +141,5 @@ def searchcommand():
results.sort(key=lambda x: x.get(key, 0), reverse=True)
# Limit results
results = results[:100]
return jsonify(results=results)
results = results[:20]
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__))
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.
search_db = sqlite3.connect(f"file:{SEARCH_DB_PATH}?mode=ro", uri=True, check_same_thread=False)
search_db.row_factory = sqlite3.Row
@ -20,6 +46,32 @@ def _normalize_folder_path(folder_path: str) -> str:
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]:
"""
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()
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)
categories = [
{"category": row["category_label"], "count": row["file_count"]}
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():

View File

@ -59,6 +59,15 @@ main > * {
padding: 10px 20px;
}
/* Ensure modals appear above the sticky header */
.modal {
z-index: 3000;
}
.modal-backdrop {
z-index: 2990;
}
.site-header img.logo {
height: 54px;
margin-right: 6px;
@ -80,13 +89,22 @@ main > * {
padding-bottom: 220px;
}
.wrapper > .admin-nav,
.wrapper > .main-tabs {
.wrapper > .admin-nav {
flex: 0 0 auto;
}
.wrapper > main,
.wrapper > section {
.wrapper > #page-content {
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;
padding-top: 6px;
}
@ -98,7 +116,7 @@ main > * {
.container {
max-width: 1200px;
width: 95%;
margin-top: 12px;
}
.container a {
@ -110,16 +128,6 @@ main > * {
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 {
display: block;
@ -165,7 +173,7 @@ search {
gap: 8px;
flex-wrap: wrap;
padding: 10px 16px;
margin: 0 0 14px;
margin: 0;
}
.breadcrumb a {
@ -186,7 +194,6 @@ search {
color: var(--brand-ink);
border: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.9);
margin-bottom: 12px;
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
@ -226,6 +233,12 @@ search {
flex-shrink: 0;
}
.admin-nav .dropdown {
display: flex;
align-items: center;
flex-shrink: 0;
}
.admin-nav a:hover {
color: var(--brand-sky);
border-color: var(--border-color);
@ -240,6 +253,8 @@ search {
.admin-nav .dropdown-toggle {
color: var(--brand-ink);
padding: 8px 12px;
display: inline-flex;
align-items: center;
}
.admin-nav .dropdown-toggle::after {
@ -442,6 +457,83 @@ footer .audio-player-container {
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 */
#directory-controls {
@ -597,21 +689,26 @@ footer .audio-player-container {
/* Tab Navigation Styles */
.main-tabs {
display: flex;
justify-content: flex-start;
padding: 6px 18px 0;
gap: 0;
border-bottom: 1px solid var(--border-color);
flex-wrap: nowrap;
border-radius: 0;
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
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-y: hidden;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
box-sizing: border-box;
}
.tab-button {
@ -637,8 +734,7 @@ footer .audio-player-container {
}
.tab-button:hover {
color: var(--brand-ink);
background: rgba(255, 255, 255, 0.92);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(230, 230, 230, 0.85));
}
.tab-button.active {
@ -653,25 +749,33 @@ footer .audio-player-container {
margin-bottom: -1px;
}
/* Carded content shell */
.tab-content {
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 {
display: block;
}
/* Messages Section Styles */
.messages-section-header {
max-width: 900px;
/* Messages/Calendar shared structure */
.tab-section-header {
margin-top: 10px;
padding: 0;
}
.tab-section-body {
margin: 0 auto;
padding: 0 0 10px;
padding: 0 0 24px;
}
.messages-container {
max-width: 900px;
margin: 0 auto;
padding: 10px 0 24px;
}
.message-card {
@ -876,20 +980,13 @@ footer .audio-player-container {
}
/* Calendar Section */
#calendar-section .container {
max-width: none;
width: 100%;
padding-left: 20px;
padding-right: 20px;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0;
border: 1px solid var(--border-color);
border-top: 0;
border-radius: 0 0 10px 10px;
gap: 10px;
border: 0;
border-radius: 0;
overflow: visible;
}
@ -899,14 +996,46 @@ footer .audio-player-container {
.calendar-day {
background: #ffffff;
border-bottom: 1px solid var(--border-color);
padding: 12px 12px 6px;
border: 1px solid var(--border-color);
border-radius: 0;
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.1);
padding: 16px 12px 10px;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
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 {
@ -1207,3 +1336,8 @@ footer .audio-player-container {
background-color: rgba(246, 195, 68, 0.25);
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
const mainContainer = document.querySelector('main');
const searchContainer = document.querySelector('search');
const footer = document.querySelector('footer');
function getMainContainer() {
return document.querySelector('main');
}
console.log(mainContainer, searchContainer, footer);
function getSearchContainer() {
return document.querySelector('search');
}
function viewSearch() {
// Hide the main container and show the search container
const mainContainer = getMainContainer();
const searchContainer = getSearchContainer();
if (!mainContainer || !searchContainer) return;
mainContainer.style.display = 'none';
searchContainer.style.display = 'block';
}
function viewMain() {
// Hide the search container and show the main container
const mainContainer = getMainContainer();
const searchContainer = getSearchContainer();
if (!mainContainer || !searchContainer) return;
searchContainer.style.display = 'none';
mainContainer.style.display = 'block';
}
@ -300,6 +307,8 @@ function renderContent(data) {
currentMusicFiles.push({ path: file.path, index: idx, title: file.name.replace('.mp3', '') });
} else if (file.file_type === 'image') {
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}"` : '';
contentHTML += `<li class="file-item">
@ -478,6 +487,12 @@ document.querySelectorAll('.play-file').forEach(link => {
const { fileType, url: relUrl, index } = this.dataset;
const now = Date.now();
const lowerUrl = (relUrl || '').toLowerCase();
if (lowerUrl.endsWith('.sng')) {
openSngModal(relUrl);
return;
}
if (fileType === 'music') {
// 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) {
event.preventDefault();
const url = '/create_token/' + this.getAttribute('data-url');
console.log(url);
fetch(url)
.then(response => {
if (!response.ok) {
@ -581,7 +595,6 @@ document.querySelectorAll('.play-file').forEach(link => {
return response.text();
})
.then(data => {
console.log(data);
document.getElementById('transcriptContent').innerHTML = data;
document.getElementById('transcriptModal').style.display = 'block';
})
@ -593,33 +606,224 @@ document.querySelectorAll('.play-file').forEach(link => {
});
});
// Handle back/forward navigation.
window.addEventListener('popstate', function (event) {
const subpath = event.state ? event.state.subpath : '';
loadDirectory(subpath);
});
// Handle back/forward navigation (bind once).
if (!window.__appPopstateBound) {
window.addEventListener('popstate', function (event) {
const subpath = event.state ? event.state.subpath : '';
loadDirectory(subpath);
});
window.__appPopstateBound = true;
}
} // End of attachEventListeners function
// Modal close logic for transcript modal.
document.addEventListener('DOMContentLoaded', function () {
const closeBtn = document.querySelector('#transcriptModal .close');
closeBtn.addEventListener('click', function () {
document.getElementById('transcriptModal').style.display = 'none';
function escapeHtml(text) {
return (text || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&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) {
if (event.target == document.getElementById('transcriptModal')) {
document.getElementById('transcriptModal').style.display = 'none';
if (event.target === modal) {
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.
let initialSubpath = '';
const pendingFolderOpen = getPendingFolderOpen();
if (window.location.pathname.indexOf('/path/') === 0) {
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', () => {
reloadDirectory().then(() => {
// If we had a track playing, try to find it in the updated list.
if (currentTrackPath) {
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) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`);
if (nextLink) {
nextLink.click();
}
}
}).catch(error => {
console.error('Error during reload:', error);
});
});
if (!window.__appAudioEndedBound) {
const globalAudio = document.getElementById('globalAudio');
if (globalAudio) {
globalAudio.addEventListener('ended', () => {
reloadDirectory().then(() => {
// If we had a track playing, try to find it in the updated list.
if (currentTrackPath) {
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) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
const nextLink = document.querySelector(`.play-file[data-url=\"${nextFile.path}\"]`);
if (nextLink) {
nextLink.click();
}
}
}).catch(error => {
console.error('Error during reload:', error);
});
});
window.__appAudioEndedBound = true;
}
}
// document.addEventListener("DOMContentLoaded", function() {
// // Automatically reload every 5 minutes (300,000 milliseconds)
@ -701,4 +909,40 @@ function syncThemeColor() {
.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 {
constructor({
audioSelector = '#globalAudio',
@ -53,6 +55,11 @@ class SimpleAudioPlayer {
// Setup MediaSession handlers + heartbeat
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(position) || position < 0) return;
const playbackRate = this.audio.paused
? 0
: (Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1);
let playbackRate = Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1;
if (!playbackRate) playbackRate = 1;
try {
if ('playbackState' in navigator.mediaSession) {
@ -206,32 +212,96 @@ class SimpleAudioPlayer {
}
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) {
this.minBtn.setAttribute('aria-expanded', (!collapsed).toString());
this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen');
}
}
async fileDownload() {
const src = this.audio.currentSrc || this.audio.src;
if (!src) return;
_saveState() {
try {
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
const urlObj = new URL(src, window.location.href);
let subpath = urlObj.pathname;
subpath = subpath.slice('/media'.length);
_restoreState() {
let stateRaw;
try {
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
let tokenizedUrl;
try {
const resp = await fetch(`/create_dltoken${subpath}`);
const resp = await fetch(`/create_dltoken/${encodedSubpath}`);
if (!resp.ok) return;
tokenizedUrl = await resp.text();
tokenizedUrl = (await resp.text()).trim();
} catch {
return;
}
if (!tokenizedUrl) return;
// Build the URL with cache-buster
const downloadUrl = new URL(tokenizedUrl, window.location.href);
@ -243,7 +313,13 @@ class SimpleAudioPlayer {
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;
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)}`;
@ -277,7 +353,35 @@ class SimpleAudioPlayer {
this.audio.src = urlWithReq;
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
const parts = relUrl.split('/');
@ -293,6 +397,7 @@ class SimpleAudioPlayer {
artwork: [{ src:'/icon/logo-192x192.png', sizes:'192x192', type:'image/png' }]
});
}
this._saveState();
} catch (err) {
if (err.name !== 'AbortError') {
this.nowInfo.textContent = 'Error loading track';
@ -325,6 +430,7 @@ class SimpleAudioPlayer {
// Prev & Next
navigator.mediaSession.setActionHandler('previoustrack', wrap(() => {
if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return;
if (currentMusicIndex > 0) {
const prevFile = currentMusicFiles[currentMusicIndex - 1];
const prevLink = document.querySelector(`.play-file[data-url="${prevFile.path}"]`);
@ -334,6 +440,7 @@ class SimpleAudioPlayer {
}
}));
navigator.mediaSession.setActionHandler('nexttrack', wrap(() => {
if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return;
if (currentMusicIndex < currentMusicFiles.length - 1) {
const nextFile = currentMusicFiles[currentMusicIndex + 1];
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() {
const galleryModal = document.getElementById('gallery-modal');
if (!galleryModal || galleryModal.dataset.galleryBound) return;
initGallerySwipe();
initGalleryControls();
// Close modal when clicking outside the image.
const galleryModal = document.getElementById('gallery-modal');
galleryModal.addEventListener('click', function(e) {
if (e.target === galleryModal) {
closeGalleryModal();
}
});
galleryModal.dataset.galleryBound = '1';
}
// Initialize the gallery once the DOM content is loaded.
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', initGallery);
} else {
initGallery();
}
window.initGallery = initGallery;

View File

@ -5,11 +5,27 @@ let messageModal;
let currentEditingId = null;
// 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
const modalElement = document.getElementById('messageModal');
if (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
@ -146,7 +162,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
});
}
async function loadMessages() {
try {
@ -479,3 +495,6 @@ function insertLink(path, name, type) {
// Optionally close the browser after inserting
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 renderResults(data) {
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'];
if (data.results && data.results.length > 0) {
data.results.forEach(file => {
if (results.length > 0) {
results.forEach(file => {
const card = document.createElement('div');
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
const transcriptURL = '/transcript/' + parentFolder + '/Transkription/' + filenameWithoutExtension + '.md';
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 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';
const fileAction = isAudio
? `<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>`
: `<a class="btn btn-light download-btn" href="/media/${encodedRelPath}" download style="width:100%;"><i class="bi bi-download"></i> ${file.filename}</a>`;
let fileAction = '';
if (isSng) {
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 = `
<div class="card-body">
<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 class="card-text">Anzahl Downloads: ${file.hitcount}</p>
${ file.performance_date !== undefined ? `<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.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">${fulltextLabel}: ${file.transcript_hits} ${fulltextAction}</p>` : ``}
</div>
`;
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();
attachSearchFolderButtons();
attachSearchSngButtons();
attachSearchFulltextButtons();
} 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
document.getElementById('searchForm').addEventListener('submit', function(e) {
form.addEventListener('submit', function(e) {
e.preventDefault();
const query = document.getElementById('query').value.trim();
const includeTranscript = document.getElementById('includeTranscript').checked;
@ -203,7 +230,7 @@ document.addEventListener('DOMContentLoaded', function() {
// window.location.href = '/';
viewMain();
});
});
}
function attachSearchFolderButtons() {
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) {
const targetFolder = folderPath || '';
// Switch back to main view before loading folder
@ -241,4 +292,7 @@ function syncThemeColor() {
}
// 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' %}
{% block title %}{{ title_short }}{% endblock %}
{% block page_id %}app{% endblock %}
{% block head_extra %}
<meta property="og:title" content="{{ og_title }}">
<meta property="og:description" content="{{ og_description }}">
<meta property="og:image" content="/icon/logo-192x192.png">
<meta property="og:type" content="website">
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
<meta name="author" content="{{ title_short }}">
<link rel="icon" href="/icon/logo-192x192.png" type="image/png" sizes="192x192">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link rel="touch-icon" href="/icon/logo-192x192.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-status-bar-style" content="default">
<meta name="mobile-web-app-title" content="Gottesdienste">
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>const admin_enabled = {{ admin_enabled | tojson | safe }};</script>
<meta property="og:title" content="{{ og_title }}" data-page-head>
<meta property="og:description" content="{{ og_description }}" data-page-head>
<meta property="og:image" content="/icon/logo-192x192.png" data-page-head>
<meta property="og:type" content="website" data-page-head>
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft." data-page-head>
<meta name="author" content="{{ title_short }}" data-page-head>
<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') }}" data-page-head>
<link rel="touch-icon" href="/icon/logo-192x192.png" data-page-head>
<meta name="mobile-web-app-capable" content="yes" data-page-head>
<meta name="mobile-web-app-status-bar-style" content="default" data-page-head>
<meta name="mobile-web-app-title" content="Gottesdienste" data-page-head>
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}" data-page-head>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" data-page-head></script>
{% endblock %}
{% block content %}
<!-- Tab Navigation -->
{% if features %}
<nav class="main-tabs">
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
<div class="main-tabs-scroll">
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
</div>
</nav>
{% endif %}
@ -62,6 +63,11 @@
<span aria-hidden="true">&times;</span>
</button>
</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>
<!-- Toggle für Suchoptionen -->
@ -80,6 +86,20 @@
<!-- Suchoptionen einklappbar -->
<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 -->
<div class="mb-3">
<label class="form-label">Kategorie:</label>
@ -113,19 +133,6 @@
<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 -->
<div class="mb-3">
<label class="form-label">Dateityp:</label>
@ -142,6 +149,10 @@
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-image" value="image">
<label class="form-check-label" for="filetype-image">Bild</label>
</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">
<input class="form-check-input" type="checkbox" name="filetype" id="filetype-other" value="other">
<label class="form-check-label" for="filetype-other">Sonstige</label>
@ -152,14 +163,6 @@
<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 -->
<div class="row g-2 mb-3">
<div class="col-12 col-md-6">
@ -184,45 +187,6 @@
</div>
</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 -->
<div id="transcriptModal">
<div class="modal-content">
@ -253,11 +217,6 @@
{% endblock %}
{% 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>
if ('serviceWorker' in navigator) {
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 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='audioplayer.css') }}">
<script src="{{ url_for('static', filename='functions.js') }}"></script>
<script>const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};</script>
{% block head_extra %}{% endblock %}
</head>
<body>
<header class="site-header">
<a href="/">
<a href="/" data-ajax-nav>
<img src="/custom_logo/logoW.png" alt="Logo" class="logo">
</a>
<h1>{{ title_long }}</h1>
@ -35,22 +37,22 @@
<div class="admin-nav">
<div class="admin-nav-rail">
<div class="admin-nav-track">
<a href="{{ url_for('index') }}">App</a>
<a href="{{ url_for('index') }}" data-ajax-nav>App</a>
<span> | </span>
<a href="{{ url_for('mylinks') }}">Meine Links</a>
<a href="{{ url_for('mylinks') }}" data-ajax-nav>Meine Links</a>
<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>
<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">
Auswertungen
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}">Dashbord</a></li>
<li><a class="dropdown-item" href="{{ url_for('connections') }}">Verbindungen</a></li>
<li><a class="dropdown-item" href="{{ url_for('file_access') }}">Dateizugriffe</a></li>
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}">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('dashboard') }}" data-ajax-nav>Dashbord</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') }}" data-ajax-nav>Dateizugriffe</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') }}" data-ajax-nav>Ordner auswerten</a></li>
</ul>
</div>
</div>
@ -58,11 +60,65 @@
</div>
{% 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>
<script src="{{ url_for('static', filename='audioplayer.js') }}"></script>
{% block scripts %}{% endblock %}
<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='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>
</html>

View File

@ -1,11 +1,11 @@
<!-- Calendar Section -->
<section id="calendar-section" class="tab-content">
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="tab-section-shell">
<div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
<h3 class="mb-0">Kalender</h3>
<div class="d-flex align-items-center gap-2">
<select id="calendar-location-filter" class="form-select form-select-sm">
<option value="">Ort (alle)</option>
<option value="">alle</option>
</select>
{% if admin_enabled %}
<div class="btn-group" role="group">
@ -24,10 +24,12 @@
</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">
<!-- Populated via inline script below -->
<div id="calendar-days" class="calendar-grid">
<!-- Populated via inline script below -->
</div>
</div>
</div>
</section>
@ -83,673 +85,3 @@
</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' %}
{% block title %}Recent Connections{% endblock %}
{% block page_id %}connections{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
<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" data-page-head></script>
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js" data-page-head></script>
<style data-page-head>
/* page-specific styles */
.split-view {
display: flex;
@ -101,7 +103,10 @@
{% block content %}
<div class="page-content">
<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>
<div class="stats">
<div class="stat-item">
@ -154,269 +159,4 @@
</div>
{% endblock %}
{% block scripts %}
<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 %}
{% block scripts %}{% endblock %}

View File

@ -3,11 +3,13 @@
{# page title #}
{% block title %}Dashboard{% endblock %}
{% block page_id %}dashboard{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
<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" data-page-head></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js" data-page-head></script>
<style data-page-head>
#dashboard-map {
width: 100%;
height: 420px;
@ -269,210 +271,16 @@
</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 %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const rawMapData = {{ map_data|tojson|default('[]') }};
const mapData = Array.isArray(rawMapData) ? rawMapData : [];
// Leaflet map with aggregated downloads (fail-safe so charts still render)
try {
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 %}
{% block scripts %}{% endblock %}

View File

@ -3,6 +3,7 @@
{# page title #}
{% block title %}Dateizugriffe{% endblock %}
{% block page_id %}file_access{% endblock %}
{# page content #}
{% block content %}
@ -92,18 +93,30 @@
<div class="card-body">
{% if top20_files %}
<div class="table-responsive">
<table class="table table-striped">
<table class="table table-striped" id="file-access-table">
<thead>
<tr>
<th>Access Count</th>
<th>File Path</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for row in top20_files %}
{% set parent_path = row.rel_path.rsplit('/', 1)[0] if '/' in row.rel_path else '' %}
<tr>
<td>{{ row.access_count }}</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>
{% endfor %}
</tbody>

View File

@ -3,6 +3,7 @@
{# page title #}
{% block title %}Ordnerkonfiguration{% endblock %}
{% block page_id %}folder_secret_config{% endblock %}
{# page content #}
{% block content %}
@ -30,446 +31,8 @@
</div>
</div>
</div>
{% endblock %}
{% 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 type="application/json" id="folder-config-page-data">
{{ { 'alphabet': alphabet } | tojson }}
</script>
{% endblock %}
{% block scripts %}{% endblock %}

View File

@ -1,7 +1,7 @@
<!-- Messages Section -->
<section id="messages-section" class="tab-content">
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-3 messages-section-header">
<div class="tab-section-shell">
<div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
<h3 class="mb-0">Nachrichten</h3>
{% if admin_enabled %}
<div>
@ -20,7 +20,7 @@
</div>
{% 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 -->
</div>
</div>

View File

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

View File

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

View File

@ -1,10 +1,11 @@
{% extends 'base.html' %}
{% block title %}Search-DB Analyse{% endblock %}
{% block page_id %}search_db_analyzer{% endblock %}
{% block content %}
<div class="container">
<h2>Index auswerten</h2>
<h2>Ordner auswerten</h2>
<p class="text-muted">
Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen.
</p>
@ -27,236 +28,42 @@
<h4 class="mb-0">Ergebnis</h4>
<span class="badge bg-secondary" id="totalCount"></span>
</div>
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th>Kategorie</th>
<th>Anzahl Dateien</th>
</tr>
</thead>
<tbody id="result-body"></tbody>
</table>
<div class="row g-3">
<div class="col-lg-7">
<div class="table-responsive">
<table class="table table-striped align-middle">
<thead>
<tr>
<th>Kategorie</th>
<th>Anzahl Dateien</th>
</tr>
</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>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
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 type="application/json" id="search-db-analyzer-data">
{{ {
'folders_url': url_for('search_db_folders'),
'query_url': url_for('search_db_query')
} | tojson }}
</script>
{% endblock %}
{% block scripts %}{% endblock %}

View File

@ -3,6 +3,7 @@
{# page title #}
{% block title %}Analyse Wiederholungen{% endblock %}
{% block page_id %}songs_dashboard{% endblock %}
{# page content #}
{% block content %}
@ -157,4 +158,4 @@
</table>
</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.
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
detected = detect_language(model, audio_input)
# # Detect spoken language
# detected = detect_language(model, audio_input)
# Determine which languages to transcribe
if (detected == 'ru' and 'predigt' in file_name.lower()) or \
(detected == 'de' and 'russisch' in file_name.lower()):
langs = ['de', 'ru']
elif detected == 'en': # songs often mis-detected as English
langs = ['de']
elif detected in ('de', 'ru'):
langs = [detected]
else:
langs = ['ru']
# # Determine which languages to transcribe
# if (detected == 'ru' and 'predigt' in file_name.lower()) or \
# (detected == 'de' and 'russisch' in file_name.lower()):
# langs = ['de', 'ru']
# elif detected == 'en': # songs often mis-detected as English
# langs = ['de']
# elif detected in ('de', 'ru'):
# langs = [detected]
# else:
langs = ['de', 'ru', 'en']
# Collect segments for combined result
lang_collection = {}

View File

@ -129,13 +129,38 @@ def process_file(file_path, model, audio_input, language=None, postfix=None):
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python transcribe_all.py <file>")
# Folder where your audio/video files are stored
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)
file_name_path = sys.argv[1]
print("Loading Whisper model...")
model = whisper.load_model(model_name, device="cuda")
audio = whisper.load_audio(file_name_path)
process_file(file_name_path, model, audio, "de")
# List all supported file types
supported_ext = (".mp3", ".wav", ".m4a", ".mp4", ".mov", ".flac", ".ogg")
files = [
os.path.join(input_folder, f)
for f in os.listdir(input_folder)
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}")