diff --git a/app.py b/app.py index 04d0d96..11a549e 100755 --- a/app.py +++ b/app.py @@ -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 diff --git a/helperfunctions.py b/helperfunctions.py index a63dac9..4b35ff3 100644 --- a/helperfunctions.py +++ b/helperfunctions.py @@ -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 diff --git a/index_for_search.py b/index_for_search.py index 056b346..fe9400d 100755 --- a/index_for_search.py +++ b/index_for_search.py @@ -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) diff --git a/search.py b/search.py index 3ea090b..3be553e 100644 --- a/search.py +++ b/search.py @@ -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) diff --git a/search_db_analyzer.py b/search_db_analyzer.py index 6d87e3f..a2fbd40 100644 --- a/search_db_analyzer.py +++ b/search_db_analyzer.py @@ -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(): diff --git a/static/app.css b/static/app.css index f012257..c25e1d0 100644 --- a/static/app.css +++ b/static/app.css @@ -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; +} diff --git a/static/app.js b/static/app.js index 2f8ae09..8142c57 100644 --- a/static/app.js +++ b/static/app.js @@ -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 = ''; + } else if ((file.name || '').toLowerCase().endsWith('.sng')) { + symbol = ''; } const indexAttr = file.file_type === 'music' ? ` data-index="${currentMusicFiles.length - 1}"` : ''; contentHTML += `
  • @@ -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) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); +} + +function formatSngLine(line) { + let safe = escapeHtml(line); + safe = safe.replace(/<dfn>/gi, ''); + safe = safe.replace(/<\/dfn>/gi, ''); + safe = safe.replace(/<small>/gi, ''); + safe = safe.replace(/<\/small>/gi, ''); + 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 => `${escapeHtml(v)}`) + .join(''); + if (chips) { + metaRows.push(`
    ${escapeHtml(label)}
    ${chips}
    `); + } + return; + } + metaRows.push(`
    ${escapeHtml(label)}
    ${escapeHtml(value)}
    `); + }; + + pushMeta('Liederbuch', meta.Songbook); + pushMeta('ID', meta.ChurchSongID); + pushMeta('Stichworte', meta.Keywords, true); + pushMeta('Reihenfolge', meta.VerseOrder, true); + pushMeta('Sprache', meta.LangCount); + + let html = '
    '; + html += `

    ${escapeHtml(title)}

    `; + if (metaRows.length) { + html += `
    ${metaRows.join('')}
    `; + } + + sections.forEach(section => { + const heading = section.title ? `

    ${escapeHtml(section.title)}

    ` : ''; + const body = section.lines.map(l => formatSngLine(l)).join('
    '); + html += `
    ${heading}

    ${body}

    `; }); + + html += '
    '; + 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 = '

    Lade Liedtext…

    '; + 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 = '

    Der Liedtext konnte nicht geladen werden.

    '; + }); +} + +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(); + } +} diff --git a/static/audioplayer.js b/static/audioplayer.js index d13b3a1..6d1d750 100644 --- a/static/audioplayer.js +++ b/static/audioplayer.js @@ -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}"]`); diff --git a/static/calendar.js b/static/calendar.js new file mode 100644 index 0000000..ce9e09f --- /dev/null +++ b/static/calendar.js @@ -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 ? `${holidayName}` : ''; + + 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 = ` +
    +
    ${formatDayName.format(date)}
    +
    ${formatDate.format(date)}${holidayBadge}
    +
    +
    + + + + + + + + + + + + + +
    ZeitTitelOrt
    Keine Einträge
    +
    + `; + + daysContainer.appendChild(dayBlock); + } + } + + function resetDayBlock(block) { + const tbody = block.querySelector('.calendar-entries-list'); + if (!tbody) return; + block.classList.add('empty'); + tbody.innerHTML = ` + + Keine Einträge + + `; + } + + function clearCalendarEntries() { + document.querySelectorAll('.calendar-day').forEach(resetDayBlock); + } + + function escapeHtml(str) { + return (str || '').replace(/[&<>"']/g, (c) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[c])); + } + + function createEmptyRow() { + const row = document.createElement('tr'); + row.className = 'calendar-empty-row'; + row.innerHTML = `Keine Einträge`; + 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 = ''; + } + + 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 = ` + ${timeValue || '-'} + ${entry.title || ''} ${entry.details ? '' : ''} + ${entry.location || ''} + `; + + 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 ? ` +
    + + +
    + ` : ''; + const detailsText = entry.details ? escapeHtml(entry.details) : ''; + detailsRow.innerHTML = `
    ${detailsText}
    ${actionsHtml}`; + 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; diff --git a/static/connections.js b/static/connections.js new file mode 100644 index 0000000..9f260be --- /dev/null +++ b/static/connections.js @@ -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 = ` + ${location.city}, ${location.country}
    + Connections: ${dot.mass}
    + 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 = ` + ${record.timestamp} + ${record.location} + ${record.user_agent} + ${record.full_path} + ${record.filesize_human || record.filesize} + ${record.mime_typ} + ${record.cached} + `; + 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 + }); + } +})(); diff --git a/static/dashboard.js b/static/dashboard.js new file mode 100644 index 0000000..d3f0ec7 --- /dev/null +++ b/static/dashboard.js @@ -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(` + ${point.city}, ${point.country}
    + 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 + }); + } +})(); diff --git a/static/file_access.js b/static/file_access.js new file mode 100644 index 0000000..edd0f68 --- /dev/null +++ b/static/file_access.js @@ -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 = ''; + 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 }); + } +})(); diff --git a/static/folder_secret_config_editor.js b/static/folder_secret_config_editor.js new file mode 100644 index 0000000..bf2bef2 --- /dev/null +++ b/static/folder_secret_config_editor.js @@ -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 = `Ordner: ${folderNames}${expired ? ' ! abgelaufen !' : ''}`; + 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 + }); + } + +})(); diff --git a/static/gallery.js b/static/gallery.js index bf8a92d..878d40c 100644 --- a/static/gallery.js +++ b/static/gallery.js @@ -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; diff --git a/static/messages.js b/static/messages.js index 767648d..e4a766d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; diff --git a/static/page_registry.js b/static/page_registry.js new file mode 100644 index 0000000..a8844e5 --- /dev/null +++ b/static/page_registry.js @@ -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 + }; +})(); diff --git a/static/page_router.js b/static/page_router.js new file mode 100644 index 0000000..a4391b3 --- /dev/null +++ b/static/page_router.js @@ -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(); + } +})(); diff --git a/static/search.js b/static/search.js index bb716df..d385c96 100644 --- a/static/search.js +++ b/static/search.js @@ -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 = '
    Suchergebnisse:
    '; + 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 = `
    ${total} Treffer
    `; 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' + ? `` + : ``; card.className = 'card'; - const fileAction = isAudio - ? `` - : ` ${file.filename}`; + let fileAction = ''; + if (isSng) { + fileAction = ``; + } else if (isAudio) { + fileAction = ``; + } else { + fileAction = ` ${file.filename}`; + } card.innerHTML = `

    ${fileAction}

    Anzahl Downloads: ${file.hitcount}

    - ${ file.performance_date !== undefined ? `

    Datum: ${file.performance_date}

    ` : ``} - ${ file.transcript_hits !== undefined ? `

    Treffer im Transkript: ${file.transcript_hits}

    ` : ``} + ${ (file.performance_date !== undefined && file.performance_date !== null && String(file.performance_date).trim() !== '') ? `

    Datum: ${file.performance_date}

    ` : ``} + ${ file.transcript_hits !== undefined ? `

    ${fulltextLabel}: ${file.transcript_hits} ${fulltextAction}

    ` : ``}
    `; 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 = '

    No results found.

    '; + resultsDiv.innerHTML = `
    ${total} Treffer

    Keine Treffer gefunden.

    `; } } @@ -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; diff --git a/static/search_db_analyzer.js b/static/search_db_analyzer.js new file mode 100644 index 0000000..46d28ff --- /dev/null +++ b/static/search_db_analyzer.js @@ -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 }); + } +})(); diff --git a/templates/app.html b/templates/app.html index fb9a3bb..f7bba3e 100644 --- a/templates/app.html +++ b/templates/app.html @@ -1,33 +1,34 @@ {% extends 'base.html' %} {% block title %}{{ title_short }}{% endblock %} +{% block page_id %}app{% endblock %} {% block head_extra %} - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + {% endblock %} {% block content %} {% if features %} {% endif %} @@ -62,6 +63,11 @@ + +
    + + +
    @@ -80,6 +86,20 @@
    + + +
    + + +
    + +
    +
    @@ -113,19 +133,6 @@
    - -
    - - -
    - -
    -
    @@ -142,6 +149,10 @@
    +
    + + +
    @@ -152,14 +163,6 @@
    - -
    - - -
    - -
    -
    @@ -184,45 +187,6 @@
    - -
    -
    - -
    - -
    - -
    - -
    -
    - -
    -
    -
    - keine Datei ausgewählt... -
    -
    -
    -
    - diff --git a/templates/connections.html b/templates/connections.html index f7d9c71..0bfa4d0 100644 --- a/templates/connections.html +++ b/templates/connections.html @@ -2,11 +2,13 @@ {% extends 'base.html' %} {% block title %}Recent Connections{% endblock %} +{% block page_id %}connections{% endblock %} {% block head_extra %} - - -