Compare commits
4 Commits
7b001efb35
...
9b3d594f1d
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b3d594f1d | |||
| c855bfdbc1 | |||
| 6a27d47222 | |||
| 53b9fec7bd |
@ -108,6 +108,7 @@ def searchcommand():
|
||||
sql += " WHERE " + " AND ".join(conditions)
|
||||
cursor.execute(sql, params)
|
||||
raw_results = cursor.fetchall()
|
||||
total_results = len(raw_results)
|
||||
|
||||
# Process results
|
||||
results = []
|
||||
@ -135,5 +136,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)
|
||||
|
||||
@ -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():
|
||||
|
||||
137
static/app.css
137
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);
|
||||
@ -597,21 +604,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 +649,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 +664,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 +895,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 +911,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 +1251,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;
|
||||
}
|
||||
|
||||
163
static/app.js
163
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';
|
||||
}
|
||||
@ -572,7 +579,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 +587,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 +598,63 @@ 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 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 +691,32 @@ function reloadDirectory() {
|
||||
}
|
||||
|
||||
|
||||
document.getElementById('globalAudio').addEventListener('ended', () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
// 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 +740,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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,13 +212,61 @@ 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');
|
||||
}
|
||||
}
|
||||
|
||||
_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.
|
||||
}
|
||||
}
|
||||
|
||||
_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() {
|
||||
const src = this.audio.currentSrc || this.audio.src;
|
||||
if (!src) return;
|
||||
@ -243,7 +297,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 +337,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 +381,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 +414,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 +424,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}"]`);
|
||||
|
||||
685
static/calendar.js
Normal file
685
static/calendar.js
Normal file
@ -0,0 +1,685 @@
|
||||
const toLocalISO = (d) => {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const holidayCache = new Map();
|
||||
|
||||
function calculateEasterSunday(year) {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function getGermanHolidays(year) {
|
||||
if (holidayCache.has(year)) return holidayCache.get(year);
|
||||
|
||||
const holidays = new Map();
|
||||
const add = (month, day, name) => {
|
||||
holidays.set(toLocalISO(new Date(year, month - 1, day)), name);
|
||||
};
|
||||
|
||||
add(1, 1, 'Neujahr');
|
||||
add(5, 1, 'Tag der Arbeit');
|
||||
add(10, 3, 'Tag der Deutschen Einheit');
|
||||
add(12, 25, '1. Weihnachtstag');
|
||||
add(12, 26, '2. Weihnachtstag');
|
||||
|
||||
const easterSunday = calculateEasterSunday(year);
|
||||
const addOffset = (offset, name) => {
|
||||
const d = new Date(easterSunday);
|
||||
d.setDate(easterSunday.getDate() + offset);
|
||||
holidays.set(toLocalISO(d), name);
|
||||
};
|
||||
|
||||
addOffset(-2, 'Karfreitag');
|
||||
addOffset(0, 'Ostersonntag');
|
||||
addOffset(1, 'Ostermontag');
|
||||
addOffset(39, 'Christi Himmelfahrt');
|
||||
addOffset(49, 'Pfingstsonntag');
|
||||
addOffset(50, 'Pfingstmontag');
|
||||
|
||||
holidayCache.set(year, holidays);
|
||||
return holidays;
|
||||
}
|
||||
|
||||
function getHolidayName(date) {
|
||||
const holidays = getGermanHolidays(date.getFullYear());
|
||||
return holidays.get(toLocalISO(date)) || '';
|
||||
}
|
||||
|
||||
function isDesktopView() {
|
||||
return window.matchMedia && window.matchMedia('(min-width: 992px)').matches;
|
||||
}
|
||||
|
||||
function getCalendarRange() {
|
||||
const weeks = window._calendarIsAdmin ? 12 : 4;
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
if (isDesktopView()) {
|
||||
// shift back to Monday (0 = Sunday)
|
||||
const diffToMonday = (start.getDay() + 6) % 7;
|
||||
start.setDate(start.getDate() - diffToMonday);
|
||||
}
|
||||
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + (weeks * 7) - 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function renderWeekdayHeader(startDate) {
|
||||
const header = document.querySelector('.calendar-weekday-header');
|
||||
if (!header) return;
|
||||
header.innerHTML = '';
|
||||
const formatDayShort = new Intl.DateTimeFormat('de-DE', { weekday: 'short' });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = formatDayShort.format(d);
|
||||
header.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCalendarStructure(force = false) {
|
||||
const daysContainer = document.getElementById('calendar-days');
|
||||
if (!daysContainer || (daysContainer.childElementCount > 0 && !force)) return;
|
||||
|
||||
const isAdmin = typeof admin_enabled !== 'undefined' && admin_enabled;
|
||||
const calendarColSpan = 3;
|
||||
window._calendarColSpan = calendarColSpan;
|
||||
window._calendarIsAdmin = isAdmin;
|
||||
daysContainer.innerHTML = '';
|
||||
|
||||
const formatDayName = new Intl.DateTimeFormat('de-DE', { weekday: 'long' });
|
||||
const formatDate = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const { start, end } = getCalendarRange();
|
||||
const todayIso = toLocalISO(new Date());
|
||||
renderWeekdayHeader(start);
|
||||
const dayCount = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
for (let i = 0; i < dayCount; i++) {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + i);
|
||||
const isoDate = toLocalISO(date);
|
||||
const isSunday = date.getDay() === 0;
|
||||
const holidayName = getHolidayName(date);
|
||||
const holidayBadge = holidayName ? `<span class="calendar-holiday-badge">${holidayName}</span>` : '';
|
||||
|
||||
const dayBlock = document.createElement('div');
|
||||
dayBlock.className = 'calendar-day empty';
|
||||
dayBlock.dataset.date = isoDate;
|
||||
if (isoDate === toLocalISO(new Date())) {
|
||||
dayBlock.classList.add('calendar-day-today');
|
||||
}
|
||||
if (isSunday) {
|
||||
dayBlock.classList.add('calendar-day-sunday');
|
||||
}
|
||||
if (holidayName) {
|
||||
dayBlock.classList.add('calendar-day-holiday');
|
||||
}
|
||||
if (isDesktopView() && isoDate < todayIso) {
|
||||
dayBlock.classList.add('calendar-day-hidden');
|
||||
}
|
||||
|
||||
dayBlock.innerHTML = `
|
||||
<div class="calendar-day-header">
|
||||
<div class="calendar-day-name">${formatDayName.format(date)}</div>
|
||||
<div class="calendar-day-date">${formatDate.format(date)}${holidayBadge}</div>
|
||||
</div>
|
||||
<div class="table-responsive calendar-table-wrapper">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Zeit</th>
|
||||
<th scope="col">Titel</th>
|
||||
<th scope="col">Ort</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="calendar-entries-list">
|
||||
<tr class="calendar-empty-row">
|
||||
<td colspan="${calendarColSpan}" class="text-center text-muted">Keine Einträge</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
daysContainer.appendChild(dayBlock);
|
||||
}
|
||||
}
|
||||
|
||||
function resetDayBlock(block) {
|
||||
const tbody = block.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
block.classList.add('empty');
|
||||
tbody.innerHTML = `
|
||||
<tr class="calendar-empty-row">
|
||||
<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function clearCalendarEntries() {
|
||||
document.querySelectorAll('.calendar-day').forEach(resetDayBlock);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return (str || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function createEmptyRow() {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'calendar-empty-row';
|
||||
row.innerHTML = `<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function formatDisplayTime(timeStr) {
|
||||
if (!timeStr) return '';
|
||||
const match = String(timeStr).match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
|
||||
if (match) {
|
||||
const h = match[1].padStart(2, '0');
|
||||
const m = match[2];
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
// Location filter helpers
|
||||
let locationOptions = new Set();
|
||||
|
||||
function resetLocationFilterOptions() {
|
||||
locationOptions = new Set();
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Ort (alle)</option>';
|
||||
}
|
||||
|
||||
function addLocationOption(location) {
|
||||
if (!location) return;
|
||||
const normalized = location.trim();
|
||||
if (!normalized || locationOptions.has(normalized)) return;
|
||||
locationOptions.add(normalized);
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
if (!select) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = normalized;
|
||||
opt.textContent = normalized;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
function applyLocationFilter() {
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
const filterValue = select ? select.value : '';
|
||||
|
||||
document.querySelectorAll('.calendar-day').forEach(day => {
|
||||
const tbody = day.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
|
||||
// Remove any stale empty rows before recalculating
|
||||
tbody.querySelectorAll('.calendar-empty-row').forEach(r => r.remove());
|
||||
|
||||
let visibleCount = 0;
|
||||
const entryRows = tbody.querySelectorAll('tr.calendar-entry-row');
|
||||
entryRows.forEach(row => {
|
||||
const rowLoc = (row.dataset.location || '').trim();
|
||||
const match = !filterValue || rowLoc === filterValue;
|
||||
row.style.display = match ? '' : 'none';
|
||||
|
||||
const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row')
|
||||
? row.nextElementSibling
|
||||
: null;
|
||||
if (detailsRow) {
|
||||
const showDetails = match && detailsRow.dataset.expanded === 'true';
|
||||
detailsRow.style.display = showDetails ? '' : 'none';
|
||||
}
|
||||
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
|
||||
if (visibleCount === 0) {
|
||||
day.classList.add('empty');
|
||||
tbody.appendChild(createEmptyRow());
|
||||
} else {
|
||||
day.classList.remove('empty');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeCalendarEntryFromUI(id) {
|
||||
if (!id) return;
|
||||
const selector = `.calendar-entry-row[data-id="${id}"], .calendar-details-row[data-parent-id="${id}"]`;
|
||||
const rows = document.querySelectorAll(selector);
|
||||
rows.forEach(row => {
|
||||
const tbody = row.parentElement;
|
||||
const dayBlock = row.closest('.calendar-day');
|
||||
row.remove();
|
||||
const remainingEntries = tbody ? tbody.querySelectorAll('tr.calendar-entry-row').length : 0;
|
||||
if (remainingEntries === 0 && dayBlock) {
|
||||
resetDayBlock(dayBlock);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to insert entries sorted by time inside an existing day block
|
||||
function addCalendarEntry(entry) {
|
||||
if (!entry || !entry.date) return;
|
||||
const block = document.querySelector(`.calendar-day[data-date="${entry.date}"]`);
|
||||
if (!block) return;
|
||||
|
||||
const tbody = block.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
|
||||
const emptyRow = tbody.querySelector('.calendar-empty-row');
|
||||
if (emptyRow) emptyRow.remove();
|
||||
block.classList.remove('empty');
|
||||
|
||||
const row = document.createElement('tr');
|
||||
const timeValue = formatDisplayTime(entry.time || '');
|
||||
row.dataset.time = timeValue;
|
||||
row.dataset.id = entry.id || '';
|
||||
row.dataset.date = entry.date;
|
||||
row.dataset.title = entry.title || '';
|
||||
row.dataset.location = entry.location || '';
|
||||
row.dataset.details = entry.details || '';
|
||||
row.className = 'calendar-entry-row';
|
||||
row.innerHTML = `
|
||||
<td>${timeValue || '-'}</td>
|
||||
<td>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
|
||||
<td>${entry.location || ''}</td>
|
||||
`;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));
|
||||
const insertIndex = rows.findIndex(r => {
|
||||
const existingTime = r.dataset.time || '';
|
||||
return timeValue && (!existingTime || timeValue < existingTime);
|
||||
});
|
||||
|
||||
if (insertIndex === -1) {
|
||||
tbody.appendChild(row);
|
||||
} else {
|
||||
tbody.insertBefore(row, rows[insertIndex]);
|
||||
}
|
||||
const detailsRow = document.createElement('tr');
|
||||
detailsRow.className = 'calendar-details-row';
|
||||
detailsRow.dataset.parentId = entry.id || '';
|
||||
detailsRow.dataset.expanded = 'false';
|
||||
const actionsHtml = window._calendarIsAdmin ? `
|
||||
<div class="calendar-inline-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary calendar-edit" title="Bearbeiten">✎</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger calendar-delete" title="Löschen">🗑</button>
|
||||
</div>
|
||||
` : '';
|
||||
const detailsText = entry.details ? escapeHtml(entry.details) : '';
|
||||
detailsRow.innerHTML = `<td colspan="${window._calendarColSpan || 3}"><div class="calendar-details-text">${detailsText}</div>${actionsHtml}</td>`;
|
||||
detailsRow.style.display = 'none';
|
||||
row.insertAdjacentElement('afterend', detailsRow);
|
||||
applyLocationFilter();
|
||||
}
|
||||
|
||||
async function loadCalendarEntries() {
|
||||
const { start, end } = getCalendarRange();
|
||||
const todayIso = toLocalISO(start);
|
||||
const endIso = toLocalISO(end);
|
||||
|
||||
clearCalendarEntries();
|
||||
resetLocationFilterOptions();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`);
|
||||
if (!response.ok) throw new Error('Fehler beim Laden');
|
||||
const entries = await response.json();
|
||||
entries.forEach(entry => {
|
||||
addLocationOption(entry.location);
|
||||
addCalendarEntry(entry);
|
||||
});
|
||||
applyLocationFilter();
|
||||
} catch (err) {
|
||||
console.error('Kalender laden fehlgeschlagen', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendarEntry(entry) {
|
||||
const isUpdate = Boolean(entry.id);
|
||||
const url = isUpdate ? `/api/calendar/${entry.id}` : '/api/calendar';
|
||||
const method = isUpdate ? 'PUT' : 'POST';
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || 'Speichern fehlgeschlagen');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function createRecurringEntries(entry, repeatCount) {
|
||||
const count = Math.max(1, Math.min(52, repeatCount || 1));
|
||||
const baseDate = entry.date;
|
||||
if (!baseDate) return [];
|
||||
const base = new Date(baseDate);
|
||||
const savedEntries = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = new Date(base);
|
||||
next.setDate(base.getDate() + i * 7);
|
||||
const entryForWeek = { ...entry, date: toLocalISO(next) };
|
||||
const saved = await saveCalendarEntry(entryForWeek);
|
||||
savedEntries.push(saved);
|
||||
}
|
||||
return savedEntries;
|
||||
}
|
||||
|
||||
async function deleteCalendarEntry(id) {
|
||||
const response = await fetch(`/api/calendar/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || 'Löschen fehlgeschlagen');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expose for future dynamic usage
|
||||
window.addCalendarEntry = addCalendarEntry;
|
||||
window.loadCalendarEntries = loadCalendarEntries;
|
||||
window.deleteCalendarEntry = deleteCalendarEntry;
|
||||
window.removeCalendarEntryFromUI = removeCalendarEntryFromUI;
|
||||
window.applyLocationFilter = applyLocationFilter;
|
||||
window.exportCalendar = async function exportCalendar() {
|
||||
try {
|
||||
const res = await fetch('/api/calendar/export');
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'kalender.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Export fehlgeschlagen.');
|
||||
}
|
||||
};
|
||||
|
||||
window.importCalendar = async function importCalendar(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/calendar/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(msg || 'Import fehlgeschlagen');
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
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;
|
||||
315
static/connections.js
Normal file
315
static/connections.js
Normal file
@ -0,0 +1,315 @@
|
||||
(() => {
|
||||
let map = null;
|
||||
let socket = null;
|
||||
let activeDots = null;
|
||||
let animateInterval = null;
|
||||
let statsInterval = null;
|
||||
let refreshMapSize = null;
|
||||
|
||||
function initConnectionsPage() {
|
||||
const mapEl = document.getElementById('map');
|
||||
if (!mapEl || mapEl.dataset.bound) return;
|
||||
if (typeof L === 'undefined' || typeof io === 'undefined') return;
|
||||
|
||||
mapEl.dataset.bound = '1';
|
||||
activeDots = new Map();
|
||||
|
||||
// Initialize the map centered on Europe
|
||||
map = L.map('map').setView([50.0, 10.0], 5);
|
||||
refreshMapSize = () => setTimeout(() => map && map.invalidateSize(), 150);
|
||||
refreshMapSize();
|
||||
window.addEventListener('resize', refreshMapSize);
|
||||
window.addEventListener('orientationchange', refreshMapSize);
|
||||
|
||||
// Add OpenStreetMap tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
// Custom pulsing circle marker
|
||||
const PulsingMarker = L.CircleMarker.extend({
|
||||
options: {
|
||||
radius: 10,
|
||||
fillColor: '#ff4444',
|
||||
color: '#ff0000',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}
|
||||
});
|
||||
|
||||
function updatePopup(dot) {
|
||||
const location = dot.connections[0];
|
||||
const content = `
|
||||
<strong>${location.city}, ${location.country}</strong><br>
|
||||
Connections: ${dot.mass}<br>
|
||||
Latest: ${new Date(location.timestamp).toLocaleString()}
|
||||
`;
|
||||
dot.marker.bindPopup(content);
|
||||
}
|
||||
|
||||
function getTimeAgo(timestamp) {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function updateMapStats() {
|
||||
let mostRecent = null;
|
||||
activeDots.forEach(dot => {
|
||||
if (!mostRecent || dot.lastUpdate > mostRecent) {
|
||||
mostRecent = dot.lastUpdate;
|
||||
}
|
||||
});
|
||||
|
||||
if (mostRecent) {
|
||||
const timeAgo = getTimeAgo(mostRecent);
|
||||
const lastConnection = document.getElementById('last-connection');
|
||||
if (lastConnection) lastConnection.textContent = timeAgo;
|
||||
}
|
||||
}
|
||||
|
||||
function addOrUpdateDot(lat, lon, city, country, timestamp) {
|
||||
const key = `${lat},${lon}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (activeDots.has(key)) {
|
||||
const dot = activeDots.get(key);
|
||||
dot.mass += 1;
|
||||
dot.lastUpdate = now;
|
||||
dot.connections.push({ city, country, timestamp });
|
||||
|
||||
const newRadius = 10 + Math.log(dot.mass) * 5;
|
||||
dot.marker.setRadius(newRadius);
|
||||
updatePopup(dot);
|
||||
} else {
|
||||
const marker = new PulsingMarker([lat, lon], {
|
||||
radius: 10
|
||||
}).addTo(map);
|
||||
|
||||
const dot = {
|
||||
marker: marker,
|
||||
mass: 1,
|
||||
createdAt: now,
|
||||
lastUpdate: now,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
connections: [{ city, country, timestamp }]
|
||||
};
|
||||
|
||||
activeDots.set(key, dot);
|
||||
updatePopup(dot);
|
||||
}
|
||||
updateMapStats();
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes && bytes !== 0) return '-';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let num = bytes;
|
||||
let unitIndex = 0;
|
||||
while (num >= 1024 && unitIndex < units.length - 1) {
|
||||
num /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
const rounded = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
|
||||
return `${rounded} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function toBool(val) {
|
||||
if (val === true || val === 1) return true;
|
||||
if (typeof val === 'string') {
|
||||
const lower = val.toLowerCase();
|
||||
return lower === 'true' || lower === '1' || lower === 'yes';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function animateDots() {
|
||||
if (!map) return;
|
||||
const now = Date.now();
|
||||
const fadeTime = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
activeDots.forEach((dot, key) => {
|
||||
const age = now - dot.lastUpdate;
|
||||
|
||||
if (age > fadeTime) {
|
||||
map.removeLayer(dot.marker);
|
||||
activeDots.delete(key);
|
||||
} else {
|
||||
const fadeProgress = age / fadeTime;
|
||||
const baseRadius = 10 + Math.log(dot.mass) * 5;
|
||||
const currentRadius = baseRadius * (1 - fadeProgress * 0.8);
|
||||
dot.marker.setRadius(Math.max(currentRadius, 2));
|
||||
|
||||
const opacity = 1 - fadeProgress * 0.7;
|
||||
dot.marker.setStyle({
|
||||
fillOpacity: opacity * 0.8,
|
||||
opacity: opacity
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
updateMapStats();
|
||||
}
|
||||
|
||||
animateInterval = setInterval(animateDots, 100);
|
||||
statsInterval = setInterval(() => {
|
||||
if (activeDots.size > 0) {
|
||||
updateMapStats();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// WebSocket connection
|
||||
// Force polling to avoid websocket upgrade issues behind some proxies.
|
||||
socket = io({
|
||||
transports: ['polling'],
|
||||
upgrade: false
|
||||
});
|
||||
socket.on('connect', () => {
|
||||
socket.emit('request_initial_data');
|
||||
socket.emit('request_map_data');
|
||||
});
|
||||
|
||||
socket.on('map_initial_data', function(data) {
|
||||
data.connections.forEach(conn => {
|
||||
if (conn.lat && conn.lon) {
|
||||
addOrUpdateDot(conn.lat, conn.lon, conn.city, conn.country, conn.timestamp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('new_connection', function(data) {
|
||||
if (data.lat && data.lon) {
|
||||
addOrUpdateDot(data.lat, data.lon, data.city, data.country, data.timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
function updateStats(data) {
|
||||
const totalConnections = document.getElementById('totalConnections');
|
||||
if (totalConnections) totalConnections.textContent = data.length;
|
||||
const totalBytes = data.reduce((sum, rec) => {
|
||||
const val = parseFloat(rec.filesize);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
const totalSize = document.getElementById('totalSize');
|
||||
if (totalSize) totalSize.textContent = formatBytes(totalBytes);
|
||||
const cachedCount = data.reduce((sum, rec) => sum + (toBool(rec.cached) ? 1 : 0), 0);
|
||||
const pct = data.length ? ((cachedCount / data.length) * 100).toFixed(0) : 0;
|
||||
const cachedPercent = document.getElementById('cachedPercent');
|
||||
if (cachedPercent) cachedPercent.textContent = `${pct}%`;
|
||||
}
|
||||
|
||||
function createRow(record, animate = true) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.dataset.timestamp = record.timestamp;
|
||||
if (animate) {
|
||||
tr.classList.add('slide-in');
|
||||
tr.addEventListener('animationend', () =>
|
||||
tr.classList.remove('slide-in'), { once: true });
|
||||
}
|
||||
tr.innerHTML = `
|
||||
<td>${record.timestamp}</td>
|
||||
<td>${record.location}</td>
|
||||
<td>${record.user_agent}</td>
|
||||
<td>${record.full_path}</td>
|
||||
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
|
||||
<td>${record.mime_typ}</td>
|
||||
<td>${record.cached}</td>
|
||||
`;
|
||||
return tr;
|
||||
}
|
||||
|
||||
function updateTable(data) {
|
||||
const tbody = document.getElementById('connectionsTableBody');
|
||||
if (!tbody) return;
|
||||
const existing = {};
|
||||
Array.from(tbody.children).forEach(r => {
|
||||
existing[r.dataset.timestamp] = r;
|
||||
});
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
data.forEach(rec => {
|
||||
let row = existing[rec.timestamp];
|
||||
if (row) {
|
||||
row.innerHTML = createRow(rec, false).innerHTML;
|
||||
row.classList.remove('slide-out');
|
||||
} else {
|
||||
row = createRow(rec, true);
|
||||
}
|
||||
frag.appendChild(row);
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
tbody.appendChild(frag);
|
||||
}
|
||||
|
||||
function animateTableWithNewRow(data) {
|
||||
const tbody = document.getElementById('connectionsTableBody');
|
||||
if (!tbody) return;
|
||||
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
|
||||
const newRecs = data.filter(r => !currentTs.has(r.timestamp));
|
||||
|
||||
if (newRecs.length) {
|
||||
const temp = createRow(newRecs[0], false);
|
||||
temp.style.visibility = 'hidden';
|
||||
tbody.appendChild(temp);
|
||||
const h = temp.getBoundingClientRect().height;
|
||||
temp.remove();
|
||||
|
||||
tbody.style.transform = `translateY(${h}px)`;
|
||||
setTimeout(() => {
|
||||
updateTable(data);
|
||||
tbody.style.transition = 'none';
|
||||
tbody.style.transform = 'translateY(0)';
|
||||
void tbody.offsetWidth;
|
||||
tbody.style.transition = 'transform 0.5s ease-out';
|
||||
}, 500);
|
||||
} else {
|
||||
updateTable(data);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('recent_connections', data => {
|
||||
updateStats(data);
|
||||
animateTableWithNewRow(data);
|
||||
});
|
||||
}
|
||||
|
||||
function destroyConnectionsPage() {
|
||||
if (socket) {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
if (animateInterval) {
|
||||
clearInterval(animateInterval);
|
||||
animateInterval = null;
|
||||
}
|
||||
if (statsInterval) {
|
||||
clearInterval(statsInterval);
|
||||
statsInterval = null;
|
||||
}
|
||||
if (refreshMapSize) {
|
||||
window.removeEventListener('resize', refreshMapSize);
|
||||
window.removeEventListener('orientationchange', refreshMapSize);
|
||||
refreshMapSize = null;
|
||||
}
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
}
|
||||
activeDots = null;
|
||||
}
|
||||
|
||||
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
||||
window.PageRegistry.register('connections', {
|
||||
init: initConnectionsPage,
|
||||
destroy: destroyConnectionsPage
|
||||
});
|
||||
}
|
||||
})();
|
||||
263
static/dashboard.js
Normal file
263
static/dashboard.js
Normal file
@ -0,0 +1,263 @@
|
||||
(() => {
|
||||
let charts = [];
|
||||
let mainMap = null;
|
||||
let modalMap = null;
|
||||
let refreshHandlers = [];
|
||||
let modalShownHandler = null;
|
||||
|
||||
function parseDashboardData() {
|
||||
const script = document.getElementById('dashboard-page-data');
|
||||
if (!script) return null;
|
||||
try {
|
||||
return JSON.parse(script.textContent || '{}');
|
||||
} catch (err) {
|
||||
console.error('Failed to parse dashboard data', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createLeafletMap(mapElement, mapData) {
|
||||
const map = L.map(mapElement).setView([50, 10], 4);
|
||||
const bounds = L.latLngBounds();
|
||||
const defaultCenter = [50, 10];
|
||||
const defaultZoom = 4;
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
mapData.forEach(point => {
|
||||
if (point.lat === null || point.lon === null) return;
|
||||
const radius = Math.max(6, 4 + Math.log(point.count + 1) * 4);
|
||||
const marker = L.circleMarker([point.lat, point.lon], {
|
||||
radius,
|
||||
color: '#ff0000',
|
||||
fillColor: '#ff4444',
|
||||
fillOpacity: 0.75,
|
||||
weight: 2
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(`
|
||||
<strong>${point.city}, ${point.country}</strong><br>
|
||||
Downloads: ${point.count}
|
||||
`);
|
||||
bounds.extend([point.lat, point.lon]);
|
||||
});
|
||||
|
||||
if (mapData.length === 0) {
|
||||
const msg = document.createElement('div');
|
||||
msg.textContent = 'Keine Geo-Daten für den ausgewählten Zeitraum.';
|
||||
msg.className = 'text-muted small mt-3';
|
||||
mapElement.appendChild(msg);
|
||||
}
|
||||
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [24, 24] });
|
||||
} else {
|
||||
map.setView(defaultCenter, defaultZoom);
|
||||
}
|
||||
|
||||
const refreshSize = () => setTimeout(() => map.invalidateSize(), 150);
|
||||
refreshSize();
|
||||
window.addEventListener('resize', refreshSize);
|
||||
window.addEventListener('orientationchange', refreshSize);
|
||||
refreshHandlers.push(refreshSize);
|
||||
return { map, refreshSize };
|
||||
}
|
||||
|
||||
function initDashboardPage() {
|
||||
const mapElement = document.getElementById('dashboard-map');
|
||||
if (!mapElement || mapElement.dataset.bound) return;
|
||||
mapElement.dataset.bound = '1';
|
||||
|
||||
const data = parseDashboardData();
|
||||
if (!data) return;
|
||||
|
||||
const mapData = Array.isArray(data.map_data) ? data.map_data : [];
|
||||
const distinctDeviceData = Array.isArray(data.distinct_device_data) ? data.distinct_device_data : [];
|
||||
const timeframeData = Array.isArray(data.timeframe_data) ? data.timeframe_data : [];
|
||||
const userAgentData = Array.isArray(data.user_agent_data) ? data.user_agent_data : [];
|
||||
const folderData = Array.isArray(data.folder_data) ? data.folder_data : [];
|
||||
const timeframe = data.timeframe || '';
|
||||
|
||||
if (typeof L !== 'undefined') {
|
||||
try {
|
||||
mainMap = createLeafletMap(mapElement, mapData);
|
||||
|
||||
const modalEl = document.getElementById('mapModal');
|
||||
if (modalEl) {
|
||||
modalShownHandler = () => {
|
||||
const modalMapElement = document.getElementById('dashboard-map-modal');
|
||||
if (!modalMapElement) return;
|
||||
const modalBody = modalEl.querySelector('.modal-body');
|
||||
const modalHeader = modalEl.querySelector('.modal-header');
|
||||
if (modalBody && modalHeader) {
|
||||
const availableHeight = window.innerHeight - modalHeader.offsetHeight;
|
||||
modalBody.style.height = `${availableHeight}px`;
|
||||
modalMapElement.style.height = '100%';
|
||||
}
|
||||
if (!modalMap) {
|
||||
modalMap = createLeafletMap(modalMapElement, mapData);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (modalMap && modalMap.map) {
|
||||
modalMap.map.invalidateSize();
|
||||
if (modalMap.refreshSize) modalMap.refreshSize();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
modalEl.addEventListener('shown.bs.modal', modalShownHandler);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Dashboard map init failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof Chart === 'undefined') return;
|
||||
|
||||
const shiftedLabels = timeframeData.map((item) => {
|
||||
if (timeframe === 'last24hours') {
|
||||
const bucketDate = new Date(item.bucket);
|
||||
const now = new Date();
|
||||
const isCurrentHour =
|
||||
bucketDate.getFullYear() === now.getFullYear() &&
|
||||
bucketDate.getMonth() === now.getMonth() &&
|
||||
bucketDate.getDate() === now.getDate() &&
|
||||
bucketDate.getHours() === now.getHours();
|
||||
const bucketEnd = isCurrentHour ? now : new Date(bucketDate.getTime() + 3600 * 1000);
|
||||
return `${bucketDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${bucketEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
} else if (timeframe === '7days' || timeframe === '30days') {
|
||||
const localDate = new Date(item.bucket);
|
||||
return localDate.toLocaleDateString();
|
||||
} else if (timeframe === '365days') {
|
||||
const [year, month] = item.bucket.split('-');
|
||||
const dateObj = new Date(year, month - 1, 1);
|
||||
return dateObj.toLocaleString([], { month: 'short', year: 'numeric' });
|
||||
} else {
|
||||
return item.bucket;
|
||||
}
|
||||
});
|
||||
|
||||
const ctxDistinctDevice = document.getElementById('distinctDeviceChart');
|
||||
if (ctxDistinctDevice) {
|
||||
charts.push(new Chart(ctxDistinctDevice.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: shiftedLabels,
|
||||
datasets: [{
|
||||
label: 'Device Count',
|
||||
data: distinctDeviceData.map(item => item.count),
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'Time Range' } },
|
||||
y: {
|
||||
title: { display: true, text: 'Device Count' },
|
||||
beginAtZero: true,
|
||||
ticks: { maxTicksLimit: 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
const ctxTimeframe = document.getElementById('downloadTimeframeChart');
|
||||
if (ctxTimeframe) {
|
||||
charts.push(new Chart(ctxTimeframe.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: shiftedLabels,
|
||||
datasets: [{
|
||||
label: 'Download Count',
|
||||
data: timeframeData.map(item => item.count),
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'Time Range' } },
|
||||
y: {
|
||||
title: { display: true, text: 'Download Count' },
|
||||
beginAtZero: true,
|
||||
ticks: { maxTicksLimit: 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
const ctxUserAgent = document.getElementById('userAgentChart');
|
||||
if (ctxUserAgent) {
|
||||
charts.push(new Chart(ctxUserAgent.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: userAgentData.map(item => item.device),
|
||||
datasets: [{
|
||||
data: userAgentData.map(item => item.count)
|
||||
}]
|
||||
},
|
||||
options: { responsive: true }
|
||||
}));
|
||||
}
|
||||
|
||||
const ctxFolder = document.getElementById('folderChart');
|
||||
if (ctxFolder) {
|
||||
charts.push(new Chart(ctxFolder.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: folderData.map(item => item.folder),
|
||||
datasets: [{
|
||||
data: folderData.map(item => item.count)
|
||||
}]
|
||||
},
|
||||
options: { responsive: true }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function destroyDashboardPage() {
|
||||
charts.forEach(chart => {
|
||||
try {
|
||||
chart.destroy();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
charts = [];
|
||||
|
||||
if (mainMap && mainMap.map) {
|
||||
mainMap.map.remove();
|
||||
}
|
||||
if (modalMap && modalMap.map) {
|
||||
modalMap.map.remove();
|
||||
}
|
||||
mainMap = null;
|
||||
modalMap = null;
|
||||
|
||||
refreshHandlers.forEach(handler => {
|
||||
window.removeEventListener('resize', handler);
|
||||
window.removeEventListener('orientationchange', handler);
|
||||
});
|
||||
refreshHandlers = [];
|
||||
|
||||
const modalEl = document.getElementById('mapModal');
|
||||
if (modalEl && modalShownHandler) {
|
||||
modalEl.removeEventListener('shown.bs.modal', modalShownHandler);
|
||||
}
|
||||
modalShownHandler = null;
|
||||
}
|
||||
|
||||
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
||||
window.PageRegistry.register('dashboard', {
|
||||
init: initDashboardPage,
|
||||
destroy: destroyDashboardPage
|
||||
});
|
||||
}
|
||||
})();
|
||||
98
static/file_access.js
Normal file
98
static/file_access.js
Normal file
@ -0,0 +1,98 @@
|
||||
(() => {
|
||||
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
|
||||
|
||||
const safeDecode = (value) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePath = (path) => {
|
||||
if (!path) return '';
|
||||
return path
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
const encodePath = (path) => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized) return '';
|
||||
return normalized
|
||||
.split('/')
|
||||
.map(segment => encodeURIComponent(safeDecode(segment)))
|
||||
.join('/');
|
||||
};
|
||||
|
||||
const isAudio = (path) =>
|
||||
audioExts.some(ext => path.toLowerCase().endsWith(ext));
|
||||
|
||||
const highlightFile = (filePath) => {
|
||||
const normalized = normalizePath(filePath);
|
||||
const target = document.querySelector(`.play-file[data-url="${normalized}"]`);
|
||||
if (target) {
|
||||
target.classList.add('search-highlight');
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
};
|
||||
|
||||
const openFolder = (folderPath, filePath) => {
|
||||
const normalizedFolder = normalizePath(folderPath);
|
||||
const normalizedFile = normalizePath(filePath);
|
||||
const payload = { folder: normalizedFolder || '', file: normalizedFile || '' };
|
||||
sessionStorage.setItem('pendingFolderOpen', JSON.stringify(payload));
|
||||
|
||||
const url = normalizedFolder ? `/path/${encodePath(normalizedFolder)}` : '/';
|
||||
if (window.PageRouter && typeof window.PageRouter.loadPage === 'function') {
|
||||
window.PageRouter.loadPage(url, { push: true, scroll: true });
|
||||
return;
|
||||
}
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const initFileAccess = () => {
|
||||
const table = document.getElementById('file-access-table');
|
||||
if (!table || table.dataset.bound) return;
|
||||
table.dataset.bound = '1';
|
||||
|
||||
table.querySelectorAll('.play-access-btn').forEach(btn => {
|
||||
const path = normalizePath(btn.dataset.path || '');
|
||||
if (!path) return;
|
||||
|
||||
if (!isAudio(path)) {
|
||||
btn.innerHTML = '<i class="bi bi-download"></i>';
|
||||
btn.setAttribute('title', 'Download');
|
||||
btn.setAttribute('aria-label', 'Download');
|
||||
btn.addEventListener('click', () => {
|
||||
window.location.href = `/media/${encodePath(path)}`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const activePlayer = (typeof player !== 'undefined' && player) ? player : window.player;
|
||||
if (activePlayer && typeof activePlayer.loadTrack === 'function') {
|
||||
activePlayer.loadTrack(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
table.querySelectorAll('.folder-open-btn').forEach(btn => {
|
||||
const folder = normalizePath(btn.dataset.folder || '');
|
||||
const file = normalizePath(btn.dataset.file || '');
|
||||
btn.addEventListener('click', () => {
|
||||
// If we're already on the app page, load directly.
|
||||
if (window.PageRegistry?.getCurrent?.() === 'app' && typeof loadDirectory === 'function') {
|
||||
loadDirectory(folder).then(() => highlightFile(file));
|
||||
} else {
|
||||
openFolder(folder, file);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
||||
window.PageRegistry.register('file_access', { init: initFileAccess });
|
||||
}
|
||||
})();
|
||||
503
static/folder_secret_config_editor.js
Normal file
503
static/folder_secret_config_editor.js
Normal file
@ -0,0 +1,503 @@
|
||||
(() => {
|
||||
|
||||
let ALPHABET = [];
|
||||
let data = [];
|
||||
let editing = new Set();
|
||||
let pendingDelete = null;
|
||||
|
||||
// QR Code generation function using backend
|
||||
async function generateQRCode(secret, imgElement) {
|
||||
try {
|
||||
const response = await fetch(`/admin/generate_qr/${encodeURIComponent(secret)}`);
|
||||
const data = await response.json();
|
||||
imgElement.src = `data:image/png;base64,${data.qr_code}`;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// helper to format DD.MM.YYYY → YYYY-MM-DD
|
||||
function formatISO(d) {
|
||||
const [dd, mm, yyyy] = d.split('.');
|
||||
return (dd && mm && yyyy) ? `${yyyy}-${mm}-${dd}` : '';
|
||||
}
|
||||
|
||||
// load from server
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await fetch('/admin/folder_secret_config_editor/data');
|
||||
data = await res.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// send delete/update actions
|
||||
async function sendAction(payload) {
|
||||
await fetch('/admin/folder_secret_config_editor/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
await loadData();
|
||||
}
|
||||
|
||||
// delete record after confirmation
|
||||
function deleteRec(secret) {
|
||||
sendAction({ action: 'delete', secret });
|
||||
}
|
||||
|
||||
// build all cards
|
||||
function render() {
|
||||
const cont = document.getElementById('records');
|
||||
cont.innerHTML = '';
|
||||
|
||||
data.forEach(rec => {
|
||||
const key = rec.secret;
|
||||
const isEdit = editing.has(key);
|
||||
const expired = (formatISO(rec.validity) && new Date(formatISO(rec.validity)) < new Date());
|
||||
const cls = isEdit ? 'unlocked' : 'locked';
|
||||
|
||||
// outer card
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'card mb-3';
|
||||
wrapper.dataset.secret = key;
|
||||
|
||||
// Card header for collapse toggle
|
||||
const cardHeader = document.createElement('div');
|
||||
cardHeader.className = 'card-header';
|
||||
cardHeader.style.cursor = 'pointer';
|
||||
const collapseId = `collapse-${key}`;
|
||||
cardHeader.setAttribute('data-bs-toggle', 'collapse');
|
||||
cardHeader.setAttribute('data-bs-target', `#${collapseId}`);
|
||||
cardHeader.setAttribute('aria-expanded', 'false');
|
||||
cardHeader.setAttribute('aria-controls', collapseId);
|
||||
|
||||
const headerTitle = document.createElement('div');
|
||||
headerTitle.className = 'd-flex justify-content-between align-items-center';
|
||||
const folderNames = rec.folders.map(f => f.foldername).join(', ');
|
||||
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
|
||||
cardHeader.appendChild(headerTitle);
|
||||
wrapper.appendChild(cardHeader);
|
||||
|
||||
// Collapsible body
|
||||
const collapseDiv = document.createElement('div');
|
||||
collapseDiv.className = 'collapse';
|
||||
collapseDiv.id = collapseId;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = `card-body ${cls}`;
|
||||
|
||||
// Create row for two-column layout (only if not editing)
|
||||
if (!isEdit) {
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'row mb-3';
|
||||
|
||||
// Left column for secret and validity
|
||||
const leftCol = document.createElement('div');
|
||||
leftCol.className = 'col-md-8';
|
||||
|
||||
// secret input
|
||||
const secDiv = document.createElement('div');
|
||||
secDiv.className = 'mb-2';
|
||||
secDiv.innerHTML = `Secret: `;
|
||||
const secInput = document.createElement('input');
|
||||
secInput.className = 'form-control';
|
||||
secInput.type = 'text';
|
||||
secInput.value = rec.secret;
|
||||
secInput.readOnly = true;
|
||||
secInput.dataset.field = 'secret';
|
||||
secDiv.appendChild(secInput);
|
||||
leftCol.appendChild(secDiv);
|
||||
|
||||
// validity input
|
||||
const valDiv = document.createElement('div');
|
||||
valDiv.className = 'mb-2';
|
||||
valDiv.innerHTML = `Gültig bis: `;
|
||||
const valInput = document.createElement('input');
|
||||
valInput.className = 'form-control';
|
||||
valInput.type = 'date';
|
||||
valInput.value = formatISO(rec.validity);
|
||||
valInput.readOnly = true;
|
||||
valInput.dataset.field = 'validity';
|
||||
valDiv.appendChild(valInput);
|
||||
leftCol.appendChild(valDiv);
|
||||
|
||||
topRow.appendChild(leftCol);
|
||||
|
||||
// Right column for QR code
|
||||
const rightCol = document.createElement('div');
|
||||
rightCol.className = 'col-md-4 text-center';
|
||||
|
||||
const qrImg = document.createElement('img');
|
||||
qrImg.className = 'qr-code';
|
||||
qrImg.style.maxWidth = '200px';
|
||||
qrImg.style.width = '100%';
|
||||
qrImg.style.border = '1px solid #ddd';
|
||||
qrImg.style.padding = '10px';
|
||||
qrImg.style.borderRadius = '5px';
|
||||
qrImg.alt = 'QR Code';
|
||||
generateQRCode(key, qrImg);
|
||||
rightCol.appendChild(qrImg);
|
||||
|
||||
topRow.appendChild(rightCol);
|
||||
body.appendChild(topRow);
|
||||
} else {
|
||||
// When editing, show fields vertically without QR code
|
||||
// secret input
|
||||
const secDiv = document.createElement('div');
|
||||
secDiv.className = 'mb-2';
|
||||
secDiv.innerHTML = `Secret: `;
|
||||
const secInput = document.createElement('input');
|
||||
secInput.className = 'form-control';
|
||||
secInput.type = 'text';
|
||||
secInput.value = rec.secret;
|
||||
secInput.readOnly = false;
|
||||
secInput.dataset.field = 'secret';
|
||||
secDiv.appendChild(secInput);
|
||||
body.appendChild(secDiv);
|
||||
|
||||
// validity input
|
||||
const valDiv = document.createElement('div');
|
||||
valDiv.className = 'mb-2';
|
||||
valDiv.innerHTML = `Gültig bis: `;
|
||||
const valInput = document.createElement('input');
|
||||
valInput.className = 'form-control';
|
||||
valInput.type = 'date';
|
||||
valInput.value = formatISO(rec.validity);
|
||||
valInput.readOnly = false;
|
||||
valInput.dataset.field = 'validity';
|
||||
valDiv.appendChild(valInput);
|
||||
body.appendChild(valDiv);
|
||||
}
|
||||
|
||||
// folders
|
||||
const folderHeader = document.createElement('h6');
|
||||
folderHeader.textContent = 'Ordner';
|
||||
body.appendChild(folderHeader);
|
||||
|
||||
rec.folders.forEach((f, i) => {
|
||||
const fwrap = document.createElement('div');
|
||||
fwrap.style = 'border:1px solid #ccc; padding:10px; margin-bottom:10px; border-radius:5px;';
|
||||
|
||||
// name
|
||||
fwrap.appendChild(document.createTextNode("Ordnername: "));
|
||||
const nameGroup = document.createElement('div');
|
||||
nameGroup.className = 'input-group mb-2';
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'form-control';
|
||||
nameInput.type = 'text';
|
||||
nameInput.value = f.foldername;
|
||||
nameInput.readOnly = !isEdit;
|
||||
nameInput.dataset.field = `foldername-${i}`;
|
||||
nameGroup.appendChild(nameInput);
|
||||
|
||||
if (isEdit) {
|
||||
const remBtn = document.createElement('button');
|
||||
remBtn.className = 'btn btn-outline-danger';
|
||||
remBtn.type = 'button';
|
||||
remBtn.textContent = 'entfernen';
|
||||
remBtn.addEventListener('click', () => removeFolder(key, i));
|
||||
nameGroup.appendChild(remBtn);
|
||||
}
|
||||
fwrap.appendChild(nameGroup);
|
||||
|
||||
// path
|
||||
fwrap.appendChild(document.createTextNode("Ordnerpfad: "));
|
||||
const pathInput = document.createElement('input');
|
||||
pathInput.className = 'form-control mb-2';
|
||||
pathInput.type = 'text';
|
||||
pathInput.value = f.folderpath;
|
||||
pathInput.readOnly = !isEdit;
|
||||
pathInput.dataset.field = `folderpath-${i}`;
|
||||
fwrap.appendChild(pathInput);
|
||||
|
||||
body.appendChild(fwrap);
|
||||
});
|
||||
|
||||
if (isEdit) {
|
||||
const addFld = document.createElement('button');
|
||||
addFld.className = 'btn btn-sm btn-primary mb-2';
|
||||
addFld.type = 'button';
|
||||
addFld.textContent = 'Ordner hinzufügen';
|
||||
addFld.addEventListener('click', () => addFolder(key));
|
||||
body.appendChild(addFld);
|
||||
}
|
||||
|
||||
// actions row
|
||||
const actions = document.createElement('div');
|
||||
|
||||
if (!isEdit) {
|
||||
const openButton = document.createElement('button');
|
||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
||||
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
|
||||
openButton.textContent = 'Link öffnen';
|
||||
actions.appendChild(openButton);
|
||||
}
|
||||
|
||||
if (!isEdit) {
|
||||
const openButton = document.createElement('button');
|
||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
||||
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
|
||||
openButton.textContent = 'Link kopieren';
|
||||
actions.appendChild(openButton);
|
||||
}
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'btn btn-danger btn-sm me-2 delete-btn';
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = 'löschen';
|
||||
delBtn.dataset.secret = key;
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
const cloneBtn = document.createElement('button');
|
||||
cloneBtn.className = 'btn btn-secondary btn-sm me-2';
|
||||
cloneBtn.type = 'button';
|
||||
cloneBtn.textContent = 'clonen';
|
||||
cloneBtn.addEventListener('click', () => cloneRec(key));
|
||||
actions.appendChild(cloneBtn);
|
||||
|
||||
if (isEdit || expired) {
|
||||
const renewBtn = document.createElement('button');
|
||||
renewBtn.className = 'btn btn-info btn-sm me-2';
|
||||
renewBtn.type = 'button';
|
||||
renewBtn.textContent = 'erneuern';
|
||||
renewBtn.addEventListener('click', () => renewRec(key));
|
||||
actions.appendChild(renewBtn);
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn btn-success btn-sm me-2';
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.textContent = 'speichern';
|
||||
saveBtn.addEventListener('click', () => saveRec(key));
|
||||
actions.appendChild(saveBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn btn-secondary btn-sm';
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.textContent = 'abbrechen';
|
||||
cancelBtn.addEventListener('click', () => cancelEdit(key));
|
||||
actions.appendChild(cancelBtn);
|
||||
}
|
||||
|
||||
body.appendChild(actions);
|
||||
collapseDiv.appendChild(body);
|
||||
wrapper.appendChild(collapseDiv);
|
||||
cont.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
// generate a unique secret
|
||||
function generateSecret(existing) {
|
||||
let s;
|
||||
do {
|
||||
s = Array.from({length:32}, () => ALPHABET[Math.floor(Math.random()*ALPHABET.length)]).join('');
|
||||
} while (existing.includes(s));
|
||||
return s;
|
||||
}
|
||||
|
||||
// CRUD helpers
|
||||
function cancelEdit(secret) {
|
||||
editing.delete(secret);
|
||||
// If this was a new record that was never saved, remove it
|
||||
const originalExists = data.some(r => r.secret === secret && !editing.has(secret));
|
||||
loadData(); // Reload to discard changes
|
||||
}
|
||||
function cloneRec(secret) {
|
||||
const idx = data.findIndex(r => r.secret === secret);
|
||||
const rec = JSON.parse(JSON.stringify(data[idx]));
|
||||
const existing = data.map(r => r.secret);
|
||||
rec.secret = generateSecret(existing);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 35);
|
||||
// Format as DD.MM.YYYY for validity input
|
||||
const dd = String(futureDate.getDate()).padStart(2, '0');
|
||||
const mm = String(futureDate.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = futureDate.getFullYear();
|
||||
rec.validity = `${dd}.${mm}.${yyyy}`;
|
||||
data.splice(idx+1, 0, rec);
|
||||
editing.add(rec.secret);
|
||||
render();
|
||||
// Auto-expand the cloned item
|
||||
setTimeout(() => {
|
||||
const collapseEl = document.getElementById(`collapse-${rec.secret}`);
|
||||
if (collapseEl) {
|
||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
||||
bsCollapse.show();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
async function renewRec(secret) {
|
||||
// find current record
|
||||
const rec = data.find(r => r.secret === secret);
|
||||
if (!rec) return;
|
||||
|
||||
// generate a fresh unique secret
|
||||
const existing = data.map(r => r.secret);
|
||||
const newSecret = generateSecret(existing);
|
||||
|
||||
// validity = today + 35 days, formatted as YYYY-MM-DD
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 35);
|
||||
const yyyy = future.getFullYear();
|
||||
const mm = String(future.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(future.getDate()).padStart(2, '0');
|
||||
const validity = `${yyyy}-${mm}-${dd}`;
|
||||
|
||||
// keep folders unchanged
|
||||
const folders = rec.folders.map(f => ({
|
||||
foldername: f.foldername,
|
||||
folderpath: f.folderpath
|
||||
}));
|
||||
|
||||
// persist via existing endpoint
|
||||
await sendAction({
|
||||
action: 'update',
|
||||
oldSecret: secret,
|
||||
newSecret,
|
||||
validity,
|
||||
folders
|
||||
});
|
||||
}
|
||||
function addFolder(secret) {
|
||||
data.find(r => r.secret === secret).folders.push({foldername:'', folderpath:''});
|
||||
render();
|
||||
}
|
||||
function removeFolder(secret, i) {
|
||||
data.find(r => r.secret === secret).folders.splice(i,1);
|
||||
render();
|
||||
}
|
||||
|
||||
async function saveRec(secret) {
|
||||
const card = document.querySelector(`[data-secret="${secret}"]`);
|
||||
const newSecret = card.querySelector('input[data-field="secret"]').value.trim();
|
||||
const validity = card.querySelector('input[data-field="validity"]').value;
|
||||
const rec = data.find(r => r.secret === secret);
|
||||
const folders = rec.folders.map((_, i) => ({
|
||||
foldername: card.querySelector(`input[data-field="foldername-${i}"]`).value.trim(),
|
||||
folderpath: card.querySelector(`input[data-field="folderpath-${i}"]`).value.trim()
|
||||
}));
|
||||
|
||||
if (!newSecret || data.some(r => r.secret !== secret && r.secret === newSecret)) {
|
||||
return alert('Secret must be unique and non-empty');
|
||||
}
|
||||
if (!validity) {
|
||||
return alert('Validity required');
|
||||
}
|
||||
if (folders.some(f => !f.foldername || !f.folderpath)) {
|
||||
return alert('Folder entries cannot be empty');
|
||||
}
|
||||
|
||||
await sendAction({
|
||||
action: 'update',
|
||||
oldSecret: secret,
|
||||
newSecret,
|
||||
validity,
|
||||
folders
|
||||
});
|
||||
editing.delete(secret);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
|
||||
let addBtnHandler = null;
|
||||
let deleteClickHandler = null;
|
||||
let confirmDeleteHandler = null;
|
||||
|
||||
function bindHandlers() {
|
||||
const root = document.getElementById('records');
|
||||
if (!root || root.dataset.bound) return false;
|
||||
root.dataset.bound = '1';
|
||||
|
||||
const dataScript = document.getElementById('folder-config-page-data');
|
||||
if (dataScript) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataScript.textContent || '{}');
|
||||
if (Array.isArray(parsed.alphabet)) {
|
||||
ALPHABET = parsed.alphabet;
|
||||
} else if (typeof parsed.alphabet === 'string') {
|
||||
ALPHABET = parsed.alphabet.split('');
|
||||
} else {
|
||||
ALPHABET = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse folder config data', err);
|
||||
}
|
||||
}
|
||||
|
||||
addBtnHandler = () => {
|
||||
const existing = data.map(r => r.secret);
|
||||
const newSecret = generateSecret(existing);
|
||||
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
|
||||
editing.add(newSecret);
|
||||
render();
|
||||
setTimeout(() => {
|
||||
const collapseEl = document.getElementById(`collapse-${newSecret}`);
|
||||
if (collapseEl) {
|
||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
||||
bsCollapse.show();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const addBtn = document.getElementById('add-btn');
|
||||
if (addBtn) addBtn.addEventListener('click', addBtnHandler);
|
||||
|
||||
deleteClickHandler = (e) => {
|
||||
if (e.target.matches('.delete-btn')) {
|
||||
pendingDelete = e.target.dataset.secret;
|
||||
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
|
||||
modal.show();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', deleteClickHandler);
|
||||
|
||||
confirmDeleteHandler = () => {
|
||||
if (pendingDelete) deleteRec(pendingDelete);
|
||||
pendingDelete = null;
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
|
||||
if (modal) modal.hide();
|
||||
};
|
||||
const confirmBtn = document.querySelector('.confirm-delete');
|
||||
if (confirmBtn) confirmBtn.addEventListener('click', confirmDeleteHandler);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function unbindHandlers() {
|
||||
const addBtn = document.getElementById('add-btn');
|
||||
if (addBtn && addBtnHandler) addBtn.removeEventListener('click', addBtnHandler);
|
||||
addBtnHandler = null;
|
||||
|
||||
if (deleteClickHandler) document.removeEventListener('click', deleteClickHandler);
|
||||
deleteClickHandler = null;
|
||||
|
||||
const confirmBtn = document.querySelector('.confirm-delete');
|
||||
if (confirmBtn && confirmDeleteHandler) confirmBtn.removeEventListener('click', confirmDeleteHandler);
|
||||
confirmDeleteHandler = null;
|
||||
}
|
||||
|
||||
function initFolderConfigPage() {
|
||||
data = [];
|
||||
editing = new Set();
|
||||
pendingDelete = null;
|
||||
|
||||
if (!bindHandlers()) return;
|
||||
loadData();
|
||||
}
|
||||
|
||||
function destroyFolderConfigPage() {
|
||||
unbindHandlers();
|
||||
}
|
||||
|
||||
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
||||
window.PageRegistry.register('folder_secret_config', {
|
||||
init: initFolderConfigPage,
|
||||
destroy: destroyFolderConfigPage
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
@ -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;
|
||||
|
||||
@ -5,11 +5,27 @@ let messageModal;
|
||||
let currentEditingId = null;
|
||||
|
||||
// Initialize messages on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function initMessages() {
|
||||
const container = document.getElementById('messages-container');
|
||||
if (!container || container.dataset.bound) return;
|
||||
container.dataset.bound = '1';
|
||||
// Initialize Bootstrap modal
|
||||
const modalElement = document.getElementById('messageModal');
|
||||
if (modalElement) {
|
||||
messageModal = new bootstrap.Modal(modalElement);
|
||||
if (!modalElement.dataset.focusGuard) {
|
||||
modalElement.addEventListener('hide.bs.modal', () => {
|
||||
const active = document.activeElement;
|
||||
if (active && modalElement.contains(active)) {
|
||||
active.blur();
|
||||
// Ensure focus isn't left on an element being hidden
|
||||
requestAnimationFrame(() => {
|
||||
if (document.body) document.body.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
modalElement.dataset.focusGuard = '1';
|
||||
}
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
@ -146,7 +162,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
try {
|
||||
@ -479,3 +495,6 @@ function insertLink(path, name, type) {
|
||||
// Optionally close the browser after inserting
|
||||
fileBrowserCollapse.hide();
|
||||
}
|
||||
|
||||
|
||||
window.initMessages = initMessages;
|
||||
|
||||
37
static/page_registry.js
Normal file
37
static/page_registry.js
Normal file
@ -0,0 +1,37 @@
|
||||
(() => {
|
||||
const registry = new Map();
|
||||
let currentId = null;
|
||||
|
||||
async function destroyCurrent(ctx) {
|
||||
if (!currentId) return;
|
||||
const handlers = registry.get(currentId);
|
||||
if (handlers && typeof handlers.destroy === 'function') {
|
||||
await handlers.destroy(ctx || {});
|
||||
}
|
||||
}
|
||||
|
||||
async function initPage(id, ctx) {
|
||||
currentId = id || null;
|
||||
if (!currentId) return;
|
||||
const handlers = registry.get(currentId);
|
||||
if (handlers && typeof handlers.init === 'function') {
|
||||
await handlers.init(ctx || {});
|
||||
}
|
||||
}
|
||||
|
||||
function register(id, handlers) {
|
||||
if (!id) return;
|
||||
registry.set(id, handlers || {});
|
||||
}
|
||||
|
||||
function getCurrent() {
|
||||
return currentId;
|
||||
}
|
||||
|
||||
window.PageRegistry = {
|
||||
register,
|
||||
destroyCurrent,
|
||||
initPage,
|
||||
getCurrent
|
||||
};
|
||||
})();
|
||||
135
static/page_router.js
Normal file
135
static/page_router.js
Normal file
@ -0,0 +1,135 @@
|
||||
(() => {
|
||||
const pageContent = document.getElementById('page-content');
|
||||
if (!pageContent) return;
|
||||
|
||||
const isModifiedEvent = (event) =>
|
||||
event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
|
||||
|
||||
const isSameOrigin = (url) => {
|
||||
try {
|
||||
const target = new URL(url, window.location.href);
|
||||
return target.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const cloneWithAttributes = (node) => {
|
||||
const clone = document.createElement(node.tagName.toLowerCase());
|
||||
Array.from(node.attributes).forEach(attr => {
|
||||
clone.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
if (node.tagName === 'SCRIPT' && node.textContent) {
|
||||
clone.textContent = node.textContent;
|
||||
}
|
||||
if (node.tagName === 'STYLE' && node.textContent) {
|
||||
clone.textContent = node.textContent;
|
||||
}
|
||||
return clone;
|
||||
};
|
||||
|
||||
const replaceHeadExtras = async (doc) => {
|
||||
const oldNodes = Array.from(document.head.querySelectorAll('[data-page-head]'));
|
||||
oldNodes.forEach(node => node.remove());
|
||||
|
||||
const newNodes = Array.from(doc.head.querySelectorAll('[data-page-head]'));
|
||||
const loadPromises = [];
|
||||
|
||||
newNodes.forEach(node => {
|
||||
const clone = cloneWithAttributes(node);
|
||||
if (clone.tagName === 'SCRIPT' && clone.src) {
|
||||
loadPromises.push(new Promise(resolve => {
|
||||
clone.onload = resolve;
|
||||
clone.onerror = resolve;
|
||||
}));
|
||||
}
|
||||
document.head.appendChild(clone);
|
||||
});
|
||||
|
||||
if (loadPromises.length) {
|
||||
await Promise.all(loadPromises);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPage = async (url, { push = true, scroll = true } = {}) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'X-Requested-With': 'PageRouter' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const nextContent = doc.getElementById('page-content');
|
||||
if (!nextContent) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPageId = nextContent.dataset.pageId || '';
|
||||
const currentId = window.PageRegistry?.getCurrent?.() || pageContent.dataset.pageId || '';
|
||||
if (window.PageRegistry?.destroyCurrent) {
|
||||
await window.PageRegistry.destroyCurrent({ from: currentId, to: nextPageId, url });
|
||||
}
|
||||
|
||||
document.title = doc.title;
|
||||
await replaceHeadExtras(doc);
|
||||
|
||||
pageContent.innerHTML = nextContent.innerHTML;
|
||||
pageContent.dataset.pageId = nextPageId;
|
||||
|
||||
if (push) {
|
||||
history.pushState({ url }, '', url);
|
||||
}
|
||||
|
||||
if (scroll) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
if (window.PageRegistry?.initPage) {
|
||||
await window.PageRegistry.initPage(nextPageId, { url, doc });
|
||||
}
|
||||
} catch (err) {
|
||||
window.location.href = url;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const link = event.target.closest('a[data-ajax-nav]');
|
||||
if (!link) return;
|
||||
if (isModifiedEvent(event)) return;
|
||||
if (link.target && link.target !== '_self') return;
|
||||
if (link.hasAttribute('download')) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || href.startsWith('#')) return;
|
||||
if (!isSameOrigin(href)) return;
|
||||
|
||||
event.preventDefault();
|
||||
loadPage(href, { push: true, scroll: true });
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', (event) => {
|
||||
const url = (event.state && event.state.url) || window.location.href;
|
||||
loadPage(url, { push: false, scroll: false });
|
||||
});
|
||||
|
||||
const boot = () => {
|
||||
const initialId = pageContent.dataset.pageId || '';
|
||||
if (window.PageRegistry?.initPage) {
|
||||
window.PageRegistry.initPage(initialId, { url: window.location.href, doc: document });
|
||||
}
|
||||
};
|
||||
|
||||
window.PageRouter = { loadPage };
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@ -1,14 +1,21 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function initSearch() {
|
||||
const form = document.getElementById('searchForm');
|
||||
if (!form || form.dataset.bound) return;
|
||||
form.dataset.bound = '1';
|
||||
|
||||
// Function to render search results from a response object
|
||||
function renderResults(data) {
|
||||
const resultsDiv = document.getElementById('results');
|
||||
resultsDiv.innerHTML = '<h5>Suchergebnisse:</h5>';
|
||||
const results = Array.isArray(data.results) ? data.results : [];
|
||||
const total = Number.isFinite(data.total) ? data.total : results.length;
|
||||
const shown = results.length;
|
||||
const remaining = Math.max(0, total - shown);
|
||||
resultsDiv.innerHTML = `<h5>${total} Treffer</h5>`;
|
||||
|
||||
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
data.results.forEach(file => {
|
||||
if (results.length > 0) {
|
||||
results.forEach(file => {
|
||||
const card = document.createElement('div');
|
||||
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
|
||||
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
||||
@ -32,10 +39,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
`;
|
||||
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();
|
||||
} else {
|
||||
resultsDiv.innerHTML = '<p>No results found.</p>';
|
||||
resultsDiv.innerHTML = `<h5>${total} Treffer</h5><p>Keine Treffer gefunden.</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,7 +101,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 +216,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// window.location.href = '/';
|
||||
viewMain();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachSearchFolderButtons() {
|
||||
document.querySelectorAll('.folder-open-btn').forEach(btn => {
|
||||
@ -241,4 +254,7 @@ function syncThemeColor() {
|
||||
}
|
||||
|
||||
// sync once on load
|
||||
document.addEventListener('DOMContentLoaded', syncThemeColor);
|
||||
// syncThemeColor handled by app init.
|
||||
|
||||
|
||||
window.initSearch = initSearch;
|
||||
|
||||
264
static/search_db_analyzer.js
Normal file
264
static/search_db_analyzer.js
Normal file
@ -0,0 +1,264 @@
|
||||
(() => {
|
||||
function initSearchDbAnalyzer() {
|
||||
const form = document.getElementById('analyzer-form');
|
||||
if (!form || form.dataset.bound) return;
|
||||
form.dataset.bound = '1';
|
||||
|
||||
const dataScript = document.getElementById('search-db-analyzer-data');
|
||||
let foldersUrl = '';
|
||||
let queryUrl = '';
|
||||
if (dataScript) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataScript.textContent || '{}');
|
||||
foldersUrl = parsed.folders_url || '';
|
||||
queryUrl = parsed.query_url || '';
|
||||
} catch (err) {
|
||||
console.error('Failed to parse analyzer data', err);
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = document.getElementById('analyzer-feedback');
|
||||
const resultWrapper = document.getElementById('analyzer-result');
|
||||
const resultBody = document.getElementById('result-body');
|
||||
const filetypeBody = document.getElementById('filetype-body');
|
||||
const totalCount = document.getElementById('totalCount');
|
||||
const levelsContainer = document.getElementById('folder-levels');
|
||||
|
||||
const levelSelects = [];
|
||||
|
||||
const createSelect = (level, options) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'folder-level';
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select';
|
||||
select.dataset.level = level;
|
||||
select.required = level === 0;
|
||||
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = level === 0 ? 'Bitte auswählen' : 'Optionaler Unterordner';
|
||||
select.appendChild(placeholder);
|
||||
|
||||
options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt;
|
||||
option.textContent = opt;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
removeLevelsFrom(level + 1);
|
||||
updateControlButtons();
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'd-flex gap-2 align-items-center';
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'btn-group btn-group-sm';
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-outline-secondary';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.addEventListener('click', () => handleAddLevel());
|
||||
btnGroup.appendChild(addBtn);
|
||||
|
||||
let removeBtn = null;
|
||||
if (level > 0) {
|
||||
removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-outline-danger';
|
||||
removeBtn.textContent = '-';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
removeLevelsFrom(level);
|
||||
updateControlButtons();
|
||||
});
|
||||
btnGroup.appendChild(removeBtn);
|
||||
}
|
||||
|
||||
controls.append(select, btnGroup);
|
||||
wrapper.append(controls);
|
||||
levelsContainer.appendChild(wrapper);
|
||||
levelSelects.push({ wrapper, select, addBtn, removeBtn, btnGroup });
|
||||
};
|
||||
|
||||
const removeLevelsFrom = (startIndex) => {
|
||||
while (levelSelects.length > startIndex) {
|
||||
const item = levelSelects.pop();
|
||||
levelsContainer.removeChild(item.wrapper);
|
||||
}
|
||||
};
|
||||
|
||||
const currentPath = () => {
|
||||
const parts = levelSelects
|
||||
.map(({ select }) => select.value)
|
||||
.filter((v) => v && v.trim() !== '');
|
||||
return parts.join('/');
|
||||
};
|
||||
|
||||
const fetchChildren = async (parentPath = '') => {
|
||||
if (!foldersUrl) throw new Error('Ordner-Endpoint fehlt.');
|
||||
const params = parentPath ? `?parent=${encodeURIComponent(parentPath)}` : '';
|
||||
const response = await fetch(`${foldersUrl}${params}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Ordner konnten nicht geladen werden.');
|
||||
}
|
||||
return data.children || [];
|
||||
};
|
||||
|
||||
const updateControlButtons = () => {
|
||||
if (!levelSelects.length) {
|
||||
return;
|
||||
}
|
||||
levelSelects.forEach((item, idx) => {
|
||||
const isLast = idx === levelSelects.length - 1;
|
||||
if (item.btnGroup) {
|
||||
item.btnGroup.classList.toggle('d-none', !isLast);
|
||||
}
|
||||
if (item.addBtn) {
|
||||
item.addBtn.disabled = !item.select.value;
|
||||
}
|
||||
if (item.removeBtn) {
|
||||
const hideRemove = !isLast || idx === 0;
|
||||
item.removeBtn.classList.toggle('d-none', hideRemove);
|
||||
item.removeBtn.disabled = hideRemove;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initBaseLevel = async () => {
|
||||
try {
|
||||
const children = await fetchChildren('');
|
||||
if (!children.length) {
|
||||
feedback.textContent = 'Keine Ordner in der Datenbank gefunden.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
createSelect(0, children);
|
||||
updateControlButtons();
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLevel = async () => {
|
||||
feedback.style.display = 'none';
|
||||
const path = currentPath();
|
||||
if (!path) {
|
||||
feedback.textContent = 'Bitte zuerst einen Hauptordner auswählen.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const children = await fetchChildren(path);
|
||||
if (!children.length) {
|
||||
feedback.textContent = 'Keine weiteren Unterordner vorhanden.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
createSelect(levelSelects.length, children);
|
||||
updateControlButtons();
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
initBaseLevel();
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
feedback.style.display = 'none';
|
||||
resultWrapper.style.display = 'none';
|
||||
|
||||
const folderPath = currentPath();
|
||||
|
||||
if (!folderPath) {
|
||||
feedback.textContent = 'Bitte einen Ordner auswählen.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Wird geladen...';
|
||||
|
||||
try {
|
||||
if (!queryUrl) throw new Error('Abfrage-Endpoint fehlt.');
|
||||
const response = await fetch(queryUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Abfrage fehlgeschlagen.');
|
||||
}
|
||||
|
||||
resultBody.innerHTML = '';
|
||||
if (filetypeBody) {
|
||||
filetypeBody.innerHTML = '';
|
||||
}
|
||||
if (data.categories && data.categories.length) {
|
||||
data.categories.forEach((row) => {
|
||||
const tr = document.createElement('tr');
|
||||
const cat = document.createElement('td');
|
||||
const cnt = document.createElement('td');
|
||||
|
||||
cat.textContent = row.category || 'Keine Kategorie';
|
||||
cnt.textContent = row.count;
|
||||
|
||||
tr.append(cat, cnt);
|
||||
resultBody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.textContent = 'Keine Treffer gefunden.';
|
||||
tr.appendChild(td);
|
||||
resultBody.appendChild(tr);
|
||||
}
|
||||
|
||||
if (filetypeBody) {
|
||||
if (data.filetypes && data.filetypes.length) {
|
||||
data.filetypes.forEach((row) => {
|
||||
const tr = document.createElement('tr');
|
||||
const type = document.createElement('td');
|
||||
const cnt = document.createElement('td');
|
||||
|
||||
type.textContent = row.type || 'Misc';
|
||||
cnt.textContent = row.count;
|
||||
|
||||
tr.append(type, cnt);
|
||||
filetypeBody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.textContent = 'Keine Datei-Typen gefunden.';
|
||||
tr.appendChild(td);
|
||||
filetypeBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
totalCount.textContent = `${data.total || 0} Dateien`;
|
||||
resultWrapper.style.display = 'block';
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Abfrage starten';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
||||
window.PageRegistry.register('search_db_analyzer', { init: initSearchDbAnalyzer });
|
||||
}
|
||||
})();
|
||||
@ -1,33 +1,34 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ title_short }}{% endblock %}
|
||||
{% block page_id %}app{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<meta property="og:title" content="{{ og_title }}">
|
||||
<meta property="og:description" content="{{ og_description }}">
|
||||
<meta property="og:image" content="/icon/logo-192x192.png">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
|
||||
<meta name="author" content="{{ title_short }}">
|
||||
<link rel="icon" href="/icon/logo-192x192.png" type="image/png" sizes="192x192">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<link rel="touch-icon" href="/icon/logo-192x192.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="mobile-web-app-title" content="Gottesdienste">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>const admin_enabled = {{ admin_enabled | tojson | safe }};</script>
|
||||
<meta property="og:title" content="{{ og_title }}" data-page-head>
|
||||
<meta property="og:description" content="{{ og_description }}" data-page-head>
|
||||
<meta property="og:image" content="/icon/logo-192x192.png" data-page-head>
|
||||
<meta property="og:type" content="website" data-page-head>
|
||||
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft." data-page-head>
|
||||
<meta name="author" content="{{ title_short }}" data-page-head>
|
||||
<link rel="icon" href="/icon/logo-192x192.png" type="image/png" sizes="192x192" data-page-head>
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}" data-page-head>
|
||||
<link rel="touch-icon" href="/icon/logo-192x192.png" data-page-head>
|
||||
<meta name="mobile-web-app-capable" content="yes" data-page-head>
|
||||
<meta name="mobile-web-app-status-bar-style" content="default" data-page-head>
|
||||
<meta name="mobile-web-app-title" content="Gottesdienste" data-page-head>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}" data-page-head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" data-page-head></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Tab Navigation -->
|
||||
{% if features %}
|
||||
<nav class="main-tabs">
|
||||
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
|
||||
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
|
||||
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
|
||||
<div class="main-tabs-scroll">
|
||||
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
|
||||
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
|
||||
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
@ -184,45 +185,6 @@
|
||||
</div>
|
||||
</search>
|
||||
|
||||
<!-- Global Audio Player in Footer -->
|
||||
<footer>
|
||||
<div class="audio-player-container" id="audioPlayerContainer">
|
||||
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
|
||||
<span class="icon-collapse" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="icon-expand" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="audio-player">
|
||||
<audio id="globalAudio" prefetch="auto">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div class="controls">
|
||||
<button class="player-button icon-color">
|
||||
</button>
|
||||
<div class="slider">
|
||||
<input type="range" class="timeline" max="100" value="0" step="0.1">
|
||||
<div id="timeInfo" class="now-playing-info"></div>
|
||||
</div>
|
||||
<button class="sound-button icon-color" onclick="player.fileDownload()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 2.25A9.75 9.75 0 1 1 2.25 12 9.75 9.75 0 0 1 12 2.25Zm0 5a.75.75 0 0 0-.75.75v4.19l-1.72-1.72a.75.75 0 1 0-1.06 1.06l3.03 3.03a.75.75 0 0 0 1.06 0l3.03-3.03a.75.75 0 1 0-1.06-1.06L12.75 12.2V8a.75.75 0 0 0-.75-.75ZM7.5 15.25a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 0-1.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="nowPlayingInfo" class="now-playing-info">
|
||||
keine Datei ausgewählt...
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Transcript Modal -->
|
||||
<div id="transcriptModal">
|
||||
<div class="modal-content">
|
||||
@ -253,11 +215,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='audioplayer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='gallery.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='messages.js') }}"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
|
||||
@ -18,13 +18,15 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
|
||||
<script src="{{ url_for('static', filename='functions.js') }}"></script>
|
||||
<script>const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};</script>
|
||||
|
||||
{% block head_extra %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a href="/">
|
||||
<a href="/" data-ajax-nav>
|
||||
<img src="/custom_logo/logoW.png" alt="Logo" class="logo">
|
||||
</a>
|
||||
<h1>{{ title_long }}</h1>
|
||||
@ -35,22 +37,22 @@
|
||||
<div class="admin-nav">
|
||||
<div class="admin-nav-rail">
|
||||
<div class="admin-nav-track">
|
||||
<a href="{{ url_for('index') }}">App</a>
|
||||
<a href="{{ url_for('index') }}" data-ajax-nav>App</a>
|
||||
<span> | </span>
|
||||
<a href="{{ url_for('mylinks') }}">Meine Links</a>
|
||||
<a href="{{ url_for('mylinks') }}" data-ajax-nav>Meine Links</a>
|
||||
<span> | </span>
|
||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" data-ajax-nav>Ordnerkonfiguration</a>
|
||||
<span> | </span>
|
||||
<div class="dropdown dropend d-inline-block">
|
||||
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
|
||||
Auswertungen
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}">Dashbord</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('connections') }}">Verbindungen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('file_access') }}">Dateizugriffe</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}">Wiederholungen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}" data-ajax-nav>Dashbord</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('connections') }}" data-ajax-nav>Verbindungen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('file_access') }}" data-ajax-nav>Dateizugriffe</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}" data-ajax-nav>Wiederholungen</a></li>
|
||||
<li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}" data-ajax-nav>Ordner auswerten</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,11 +60,65 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
<div id="page-content" data-page-id="{% block page_id %}{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Global Audio Player in Footer -->
|
||||
<footer>
|
||||
<div class="audio-player-container" id="audioPlayerContainer">
|
||||
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
|
||||
<span class="icon-collapse" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="icon-expand" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="audio-player">
|
||||
<audio id="globalAudio" prefetch="auto">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<div class="controls">
|
||||
<button class="player-button icon-color">
|
||||
</button>
|
||||
<div class="slider">
|
||||
<input type="range" class="timeline" max="100" value="0" step="0.1">
|
||||
<div id="timeInfo" class="now-playing-info"></div>
|
||||
</div>
|
||||
<button class="sound-button icon-color" onclick="player.fileDownload()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12 2.25A9.75 9.75 0 1 1 2.25 12 9.75 9.75 0 0 1 12 2.25Zm0 5a.75.75 0 0 0-.75.75v4.19l-1.72-1.72a.75.75 0 1 0-1.06 1.06l3.03 3.03a.75.75 0 0 0 1.06 0l3.03-3.03a.75.75 0 1 0-1.06-1.06L12.75 12.2V8a.75.75 0 0 0-.75-.75ZM7.5 15.25a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 0-1.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="nowPlayingInfo" class="now-playing-info">
|
||||
keine Datei ausgewählt...
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='audioplayer.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='general.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='page_registry.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='gallery.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='messages.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='calendar.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='file_access.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='connections.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='folder_secret_config_editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='search_db_analyzer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='page_router.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
<!-- Calendar Section -->
|
||||
<section id="calendar-section" class="tab-content">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="tab-section-shell">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
|
||||
<h3 class="mb-0">Kalender</h3>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select id="calendar-location-filter" class="form-select form-select-sm">
|
||||
<option value="">Ort (alle)</option>
|
||||
<option value="">alle</option>
|
||||
</select>
|
||||
{% if admin_enabled %}
|
||||
<div class="btn-group" role="group">
|
||||
@ -24,10 +24,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-weekday-header"></div>
|
||||
<div class="tab-section-body">
|
||||
<div class="calendar-weekday-header"></div>
|
||||
|
||||
<div id="calendar-days" class="calendar-grid">
|
||||
<!-- Populated via inline script below -->
|
||||
<div id="calendar-days" class="calendar-grid">
|
||||
<!-- Populated via inline script below -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -83,673 +85,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const toLocalISO = (d) => {
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const holidayCache = new Map();
|
||||
|
||||
function calculateEasterSunday(year) {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function getGermanHolidays(year) {
|
||||
if (holidayCache.has(year)) return holidayCache.get(year);
|
||||
|
||||
const holidays = new Map();
|
||||
const add = (month, day, name) => {
|
||||
holidays.set(toLocalISO(new Date(year, month - 1, day)), name);
|
||||
};
|
||||
|
||||
add(1, 1, 'Neujahr');
|
||||
add(5, 1, 'Tag der Arbeit');
|
||||
add(10, 3, 'Tag der Deutschen Einheit');
|
||||
add(12, 25, '1. Weihnachtstag');
|
||||
add(12, 26, '2. Weihnachtstag');
|
||||
|
||||
const easterSunday = calculateEasterSunday(year);
|
||||
const addOffset = (offset, name) => {
|
||||
const d = new Date(easterSunday);
|
||||
d.setDate(easterSunday.getDate() + offset);
|
||||
holidays.set(toLocalISO(d), name);
|
||||
};
|
||||
|
||||
addOffset(-2, 'Karfreitag');
|
||||
addOffset(0, 'Ostersonntag');
|
||||
addOffset(1, 'Ostermontag');
|
||||
addOffset(39, 'Christi Himmelfahrt');
|
||||
addOffset(49, 'Pfingstsonntag');
|
||||
addOffset(50, 'Pfingstmontag');
|
||||
|
||||
holidayCache.set(year, holidays);
|
||||
return holidays;
|
||||
}
|
||||
|
||||
function getHolidayName(date) {
|
||||
const holidays = getGermanHolidays(date.getFullYear());
|
||||
return holidays.get(toLocalISO(date)) || '';
|
||||
}
|
||||
|
||||
function isDesktopView() {
|
||||
return window.matchMedia && window.matchMedia('(min-width: 992px)').matches;
|
||||
}
|
||||
|
||||
function getCalendarRange() {
|
||||
const weeks = window._calendarIsAdmin ? 12 : 4;
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
if (isDesktopView()) {
|
||||
// shift back to Monday (0 = Sunday)
|
||||
const diffToMonday = (start.getDay() + 6) % 7;
|
||||
start.setDate(start.getDate() - diffToMonday);
|
||||
}
|
||||
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + (weeks * 7) - 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function renderWeekdayHeader(startDate) {
|
||||
const header = document.querySelector('.calendar-weekday-header');
|
||||
if (!header) return;
|
||||
header.innerHTML = '';
|
||||
const formatDayShort = new Intl.DateTimeFormat('de-DE', { weekday: 'short' });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const div = document.createElement('div');
|
||||
div.textContent = formatDayShort.format(d);
|
||||
header.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCalendarStructure(force = false) {
|
||||
const daysContainer = document.getElementById('calendar-days');
|
||||
if (!daysContainer || (daysContainer.childElementCount > 0 && !force)) return;
|
||||
|
||||
const isAdmin = typeof admin_enabled !== 'undefined' && admin_enabled;
|
||||
const calendarColSpan = 3;
|
||||
window._calendarColSpan = calendarColSpan;
|
||||
window._calendarIsAdmin = isAdmin;
|
||||
daysContainer.innerHTML = '';
|
||||
|
||||
const formatDayName = new Intl.DateTimeFormat('de-DE', { weekday: 'long' });
|
||||
const formatDate = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const { start, end } = getCalendarRange();
|
||||
const todayIso = toLocalISO(new Date());
|
||||
renderWeekdayHeader(start);
|
||||
const dayCount = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
for (let i = 0; i < dayCount; i++) {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + i);
|
||||
const isoDate = toLocalISO(date);
|
||||
const isSunday = date.getDay() === 0;
|
||||
const holidayName = getHolidayName(date);
|
||||
const holidayBadge = holidayName ? `<span class="calendar-holiday-badge">${holidayName}</span>` : '';
|
||||
|
||||
const dayBlock = document.createElement('div');
|
||||
dayBlock.className = 'calendar-day empty';
|
||||
dayBlock.dataset.date = isoDate;
|
||||
if (isoDate === toLocalISO(new Date())) {
|
||||
dayBlock.classList.add('calendar-day-today');
|
||||
}
|
||||
if (isSunday) {
|
||||
dayBlock.classList.add('calendar-day-sunday');
|
||||
}
|
||||
if (holidayName) {
|
||||
dayBlock.classList.add('calendar-day-holiday');
|
||||
}
|
||||
if (isDesktopView() && isoDate < todayIso) {
|
||||
dayBlock.classList.add('calendar-day-hidden');
|
||||
}
|
||||
|
||||
dayBlock.innerHTML = `
|
||||
<div class="calendar-day-header">
|
||||
<div class="calendar-day-name">${formatDayName.format(date)}</div>
|
||||
<div class="calendar-day-date">${formatDate.format(date)}${holidayBadge}</div>
|
||||
</div>
|
||||
<div class="table-responsive calendar-table-wrapper">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Zeit</th>
|
||||
<th scope="col">Titel</th>
|
||||
<th scope="col">Ort</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="calendar-entries-list">
|
||||
<tr class="calendar-empty-row">
|
||||
<td colspan="${calendarColSpan}" class="text-center text-muted">Keine Einträge</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
daysContainer.appendChild(dayBlock);
|
||||
}
|
||||
}
|
||||
|
||||
function resetDayBlock(block) {
|
||||
const tbody = block.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
block.classList.add('empty');
|
||||
tbody.innerHTML = `
|
||||
<tr class="calendar-empty-row">
|
||||
<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function clearCalendarEntries() {
|
||||
document.querySelectorAll('.calendar-day').forEach(resetDayBlock);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return (str || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function createEmptyRow() {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'calendar-empty-row';
|
||||
row.innerHTML = `<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>`;
|
||||
return row;
|
||||
}
|
||||
|
||||
function formatDisplayTime(timeStr) {
|
||||
if (!timeStr) return '';
|
||||
const match = String(timeStr).match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/);
|
||||
if (match) {
|
||||
const h = match[1].padStart(2, '0');
|
||||
const m = match[2];
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
return timeStr;
|
||||
}
|
||||
|
||||
// Location filter helpers
|
||||
let locationOptions = new Set();
|
||||
|
||||
function resetLocationFilterOptions() {
|
||||
locationOptions = new Set();
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
if (!select) return;
|
||||
select.innerHTML = '<option value="">Ort (alle)</option>';
|
||||
}
|
||||
|
||||
function addLocationOption(location) {
|
||||
if (!location) return;
|
||||
const normalized = location.trim();
|
||||
if (!normalized || locationOptions.has(normalized)) return;
|
||||
locationOptions.add(normalized);
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
if (!select) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = normalized;
|
||||
opt.textContent = normalized;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
function applyLocationFilter() {
|
||||
const select = document.getElementById('calendar-location-filter');
|
||||
const filterValue = select ? select.value : '';
|
||||
|
||||
document.querySelectorAll('.calendar-day').forEach(day => {
|
||||
const tbody = day.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
|
||||
// Remove any stale empty rows before recalculating
|
||||
tbody.querySelectorAll('.calendar-empty-row').forEach(r => r.remove());
|
||||
|
||||
let visibleCount = 0;
|
||||
const entryRows = tbody.querySelectorAll('tr.calendar-entry-row');
|
||||
entryRows.forEach(row => {
|
||||
const rowLoc = (row.dataset.location || '').trim();
|
||||
const match = !filterValue || rowLoc === filterValue;
|
||||
row.style.display = match ? '' : 'none';
|
||||
|
||||
const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row')
|
||||
? row.nextElementSibling
|
||||
: null;
|
||||
if (detailsRow) {
|
||||
const showDetails = match && detailsRow.dataset.expanded === 'true';
|
||||
detailsRow.style.display = showDetails ? '' : 'none';
|
||||
}
|
||||
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
|
||||
if (visibleCount === 0) {
|
||||
day.classList.add('empty');
|
||||
tbody.appendChild(createEmptyRow());
|
||||
} else {
|
||||
day.classList.remove('empty');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeCalendarEntryFromUI(id) {
|
||||
if (!id) return;
|
||||
const selector = `.calendar-entry-row[data-id="${id}"], .calendar-details-row[data-parent-id="${id}"]`;
|
||||
const rows = document.querySelectorAll(selector);
|
||||
rows.forEach(row => {
|
||||
const tbody = row.parentElement;
|
||||
const dayBlock = row.closest('.calendar-day');
|
||||
row.remove();
|
||||
const remainingEntries = tbody ? tbody.querySelectorAll('tr.calendar-entry-row').length : 0;
|
||||
if (remainingEntries === 0 && dayBlock) {
|
||||
resetDayBlock(dayBlock);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to insert entries sorted by time inside an existing day block
|
||||
function addCalendarEntry(entry) {
|
||||
if (!entry || !entry.date) return;
|
||||
const block = document.querySelector(`.calendar-day[data-date="${entry.date}"]`);
|
||||
if (!block) return;
|
||||
|
||||
const tbody = block.querySelector('.calendar-entries-list');
|
||||
if (!tbody) return;
|
||||
|
||||
const emptyRow = tbody.querySelector('.calendar-empty-row');
|
||||
if (emptyRow) emptyRow.remove();
|
||||
block.classList.remove('empty');
|
||||
|
||||
const row = document.createElement('tr');
|
||||
const timeValue = formatDisplayTime(entry.time || '');
|
||||
row.dataset.time = timeValue;
|
||||
row.dataset.id = entry.id || '';
|
||||
row.dataset.date = entry.date;
|
||||
row.dataset.title = entry.title || '';
|
||||
row.dataset.location = entry.location || '';
|
||||
row.dataset.details = entry.details || '';
|
||||
row.className = 'calendar-entry-row';
|
||||
row.innerHTML = `
|
||||
<td>${timeValue || '-'}</td>
|
||||
<td>${entry.title || ''} ${entry.details ? '<span class="details-indicator" title="Details vorhanden">ⓘ</span>' : ''}</td>
|
||||
<td>${entry.location || ''}</td>
|
||||
`;
|
||||
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.calendar-entry-row'));
|
||||
const insertIndex = rows.findIndex(r => {
|
||||
const existingTime = r.dataset.time || '';
|
||||
return timeValue && (!existingTime || timeValue < existingTime);
|
||||
});
|
||||
|
||||
if (insertIndex === -1) {
|
||||
tbody.appendChild(row);
|
||||
} else {
|
||||
tbody.insertBefore(row, rows[insertIndex]);
|
||||
}
|
||||
const detailsRow = document.createElement('tr');
|
||||
detailsRow.className = 'calendar-details-row';
|
||||
detailsRow.dataset.parentId = entry.id || '';
|
||||
detailsRow.dataset.expanded = 'false';
|
||||
const actionsHtml = window._calendarIsAdmin ? `
|
||||
<div class="calendar-inline-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary calendar-edit" title="Bearbeiten">✎</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger calendar-delete" title="Löschen">🗑</button>
|
||||
</div>
|
||||
` : '';
|
||||
const detailsText = entry.details ? escapeHtml(entry.details) : '';
|
||||
detailsRow.innerHTML = `<td colspan="${window._calendarColSpan || 3}"><div class="calendar-details-text">${detailsText}</div>${actionsHtml}</td>`;
|
||||
detailsRow.style.display = 'none';
|
||||
row.insertAdjacentElement('afterend', detailsRow);
|
||||
applyLocationFilter();
|
||||
}
|
||||
|
||||
async function loadCalendarEntries() {
|
||||
const { start, end } = getCalendarRange();
|
||||
const todayIso = toLocalISO(start);
|
||||
const endIso = toLocalISO(end);
|
||||
|
||||
clearCalendarEntries();
|
||||
resetLocationFilterOptions();
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/calendar?start=${todayIso}&end=${endIso}`);
|
||||
if (!response.ok) throw new Error('Fehler beim Laden');
|
||||
const entries = await response.json();
|
||||
entries.forEach(entry => {
|
||||
addLocationOption(entry.location);
|
||||
addCalendarEntry(entry);
|
||||
});
|
||||
applyLocationFilter();
|
||||
} catch (err) {
|
||||
console.error('Kalender laden fehlgeschlagen', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendarEntry(entry) {
|
||||
const isUpdate = Boolean(entry.id);
|
||||
const url = isUpdate ? `/api/calendar/${entry.id}` : '/api/calendar';
|
||||
const method = isUpdate ? 'PUT' : 'POST';
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || 'Speichern fehlgeschlagen');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function createRecurringEntries(entry, repeatCount) {
|
||||
const count = Math.max(1, Math.min(52, repeatCount || 1));
|
||||
const baseDate = entry.date;
|
||||
if (!baseDate) return [];
|
||||
const base = new Date(baseDate);
|
||||
const savedEntries = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const next = new Date(base);
|
||||
next.setDate(base.getDate() + i * 7);
|
||||
const entryForWeek = { ...entry, date: toLocalISO(next) };
|
||||
const saved = await saveCalendarEntry(entryForWeek);
|
||||
savedEntries.push(saved);
|
||||
}
|
||||
return savedEntries;
|
||||
}
|
||||
|
||||
async function deleteCalendarEntry(id) {
|
||||
const response = await fetch(`/api/calendar/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || 'Löschen fehlgeschlagen');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Expose for future dynamic usage
|
||||
window.addCalendarEntry = addCalendarEntry;
|
||||
window.loadCalendarEntries = loadCalendarEntries;
|
||||
window.deleteCalendarEntry = deleteCalendarEntry;
|
||||
window.removeCalendarEntryFromUI = removeCalendarEntryFromUI;
|
||||
window.applyLocationFilter = applyLocationFilter;
|
||||
window.exportCalendar = async function exportCalendar() {
|
||||
try {
|
||||
const res = await fetch('/api/calendar/export');
|
||||
if (!res.ok) throw new Error('Export fehlgeschlagen');
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'kalender.xlsx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Export fehlgeschlagen.');
|
||||
}
|
||||
};
|
||||
|
||||
window.importCalendar = async function importCalendar(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const res = await fetch('/api/calendar/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(msg || 'Import fehlgeschlagen');
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const addBtn = document.getElementById('add-calendar-entry-btn');
|
||||
const modalEl = document.getElementById('calendarModal');
|
||||
const saveBtn = document.getElementById('saveCalendarEntryBtn');
|
||||
const form = document.getElementById('calendarForm');
|
||||
const daysContainer = document.getElementById('calendar-days');
|
||||
const locationFilter = document.getElementById('calendar-location-filter');
|
||||
const repeatCountInput = document.getElementById('calendarRepeatCount');
|
||||
const recurrenceRow = document.getElementById('calendarRecurrenceRow');
|
||||
const calendarModal = modalEl && window.bootstrap ? new bootstrap.Modal(modalEl) : null;
|
||||
if (calendarModal) {
|
||||
window.calendarModal = calendarModal;
|
||||
}
|
||||
let calendarHasLoaded = false;
|
||||
|
||||
buildCalendarStructure();
|
||||
|
||||
let lastViewportIsDesktop = isDesktopView();
|
||||
let resizeDebounce;
|
||||
const handleViewportChange = () => {
|
||||
const nowDesktop = isDesktopView();
|
||||
if (nowDesktop === lastViewportIsDesktop) return;
|
||||
lastViewportIsDesktop = nowDesktop;
|
||||
buildCalendarStructure(true);
|
||||
if (calendarHasLoaded) {
|
||||
loadCalendarEntries();
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', () => {
|
||||
if (resizeDebounce) clearTimeout(resizeDebounce);
|
||||
resizeDebounce = setTimeout(handleViewportChange, 150);
|
||||
});
|
||||
|
||||
const setRecurrenceMode = (isEditing) => {
|
||||
if (!recurrenceRow) return;
|
||||
recurrenceRow.style.display = isEditing ? 'none' : '';
|
||||
if (!isEditing && repeatCountInput) {
|
||||
repeatCountInput.value = '1';
|
||||
}
|
||||
};
|
||||
|
||||
if (addBtn && calendarModal) {
|
||||
addBtn.addEventListener('click', () => {
|
||||
if (form) form.reset();
|
||||
|
||||
const today = new Date();
|
||||
const dateInput = document.getElementById('calendarDate');
|
||||
if (dateInput) dateInput.value = toLocalISO(today);
|
||||
|
||||
const idInput = document.getElementById('calendarEntryId');
|
||||
if (idInput) idInput.value = '';
|
||||
|
||||
setRecurrenceMode(false);
|
||||
calendarModal.show();
|
||||
});
|
||||
}
|
||||
|
||||
if (saveBtn && calendarModal) {
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!form || !form.reportValidity()) return;
|
||||
|
||||
const entry = {
|
||||
id: document.getElementById('calendarEntryId')?.value || '',
|
||||
date: document.getElementById('calendarDate')?.value,
|
||||
time: document.getElementById('calendarTime')?.value,
|
||||
title: document.getElementById('calendarTitle')?.value,
|
||||
location: document.getElementById('calendarLocation')?.value,
|
||||
details: document.getElementById('calendarDetails')?.value
|
||||
};
|
||||
const isEditing = Boolean(entry.id);
|
||||
const repeatCount = (!isEditing && window._calendarIsAdmin && repeatCountInput)
|
||||
? Math.max(1, Math.min(52, parseInt(repeatCountInput.value, 10) || 1))
|
||||
: 1;
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
removeCalendarEntryFromUI(entry.id);
|
||||
const saved = await saveCalendarEntry(entry);
|
||||
addLocationOption(saved.location);
|
||||
addCalendarEntry(saved);
|
||||
} else {
|
||||
const savedEntries = await createRecurringEntries(entry, repeatCount);
|
||||
savedEntries.forEach(saved => {
|
||||
addLocationOption(saved.location);
|
||||
addCalendarEntry(saved);
|
||||
});
|
||||
}
|
||||
calendarModal.hide();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Speichern fehlgeschlagen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (window._calendarIsAdmin) {
|
||||
const exportBtn = document.getElementById('export-calendar-btn');
|
||||
const importBtn = document.getElementById('import-calendar-btn');
|
||||
const importInput = document.getElementById('import-calendar-input');
|
||||
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
window.exportCalendar();
|
||||
});
|
||||
}
|
||||
|
||||
if (importBtn && importInput) {
|
||||
importBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
importInput.value = '';
|
||||
importInput.click();
|
||||
});
|
||||
|
||||
importInput.addEventListener('change', async () => {
|
||||
if (!importInput.files || importInput.files.length === 0) return;
|
||||
const file = importInput.files[0];
|
||||
try {
|
||||
await importCalendar(file);
|
||||
loadCalendarEntries();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Import fehlgeschlagen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (daysContainer && typeof admin_enabled !== 'undefined' && admin_enabled) {
|
||||
daysContainer.addEventListener('click', async (e) => {
|
||||
const editBtn = e.target.closest('.calendar-edit');
|
||||
const deleteBtn = e.target.closest('.calendar-delete');
|
||||
if (!editBtn && !deleteBtn) return;
|
||||
|
||||
const row = e.target.closest('tr');
|
||||
if (!row) return;
|
||||
|
||||
let entryRow = row;
|
||||
if (row.classList.contains('calendar-details-row') && row.previousElementSibling && row.previousElementSibling.classList.contains('calendar-entry-row')) {
|
||||
entryRow = row.previousElementSibling;
|
||||
}
|
||||
|
||||
const entry = {
|
||||
id: entryRow.dataset.id,
|
||||
date: entryRow.dataset.date,
|
||||
time: entryRow.dataset.time,
|
||||
title: entryRow.dataset.title,
|
||||
location: entryRow.dataset.location,
|
||||
details: entryRow.dataset.details
|
||||
};
|
||||
|
||||
if (editBtn) {
|
||||
if (form) form.reset();
|
||||
document.getElementById('calendarEntryId').value = entry.id || '';
|
||||
document.getElementById('calendarDate').value = entry.date || '';
|
||||
document.getElementById('calendarTime').value = entry.time || '';
|
||||
document.getElementById('calendarTitle').value = entry.title || '';
|
||||
document.getElementById('calendarLocation').value = entry.location || '';
|
||||
document.getElementById('calendarDetails').value = entry.details || '';
|
||||
setRecurrenceMode(true);
|
||||
if (calendarModal) calendarModal.show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteBtn) {
|
||||
if (!entry.id) return;
|
||||
const confirmDelete = window.confirm('Diesen Eintrag löschen?');
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await deleteCalendarEntry(entry.id);
|
||||
removeCalendarEntryFromUI(entry.id);
|
||||
applyLocationFilter();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Löschen fehlgeschlagen.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
tabButtons.forEach(btn => {
|
||||
if (btn.getAttribute('data-tab') === 'calendar') {
|
||||
btn.addEventListener('click', () => {
|
||||
if (!calendarHasLoaded) {
|
||||
calendarHasLoaded = true;
|
||||
loadCalendarEntries();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Immediately load calendar entries for users landing directly on the calendar tab (non-admins may not switch tabs)
|
||||
if (!calendarHasLoaded) {
|
||||
calendarHasLoaded = true;
|
||||
loadCalendarEntries();
|
||||
}
|
||||
|
||||
if (locationFilter) {
|
||||
locationFilter.addEventListener('change', () => applyLocationFilter());
|
||||
}
|
||||
|
||||
// Show/hide details inline on row click (any user)
|
||||
if (daysContainer) {
|
||||
daysContainer.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.calendar-edit') || e.target.closest('.calendar-delete')) return;
|
||||
const row = e.target.closest('tr.calendar-entry-row');
|
||||
if (!row) return;
|
||||
const detailsRow = row.nextElementSibling && row.nextElementSibling.classList.contains('calendar-details-row')
|
||||
? row.nextElementSibling
|
||||
: null;
|
||||
if (!detailsRow) return;
|
||||
|
||||
const isExpanded = detailsRow.dataset.expanded === 'true';
|
||||
const shouldShow = !isExpanded;
|
||||
detailsRow.dataset.expanded = shouldShow ? 'true' : 'false';
|
||||
|
||||
// Respect current filter when toggling visibility
|
||||
const filterValue = (document.getElementById('calendar-location-filter')?.value || '').trim();
|
||||
const rowLoc = (row.dataset.location || '').trim();
|
||||
const match = !filterValue || rowLoc === filterValue;
|
||||
detailsRow.style.display = (shouldShow && match) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Recent Connections{% endblock %}
|
||||
{% block page_id %}connections{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" data-page-head />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-page-head></script>
|
||||
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js" data-page-head></script>
|
||||
<style data-page-head>
|
||||
/* page-specific styles */
|
||||
.split-view {
|
||||
display: flex;
|
||||
@ -154,269 +156,4 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
|
||||
<script>
|
||||
// Initialize the map centered on Europe
|
||||
const map = L.map('map').setView([50.0, 10.0], 5);
|
||||
const refreshMapSize = () => setTimeout(() => map.invalidateSize(), 150);
|
||||
refreshMapSize();
|
||||
window.addEventListener('resize', refreshMapSize);
|
||||
window.addEventListener('orientationchange', refreshMapSize);
|
||||
|
||||
// Add OpenStreetMap tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
// Store active dots with their data
|
||||
const activeDots = new Map();
|
||||
let totalConnections = 0;
|
||||
|
||||
// Custom pulsing circle marker
|
||||
const PulsingMarker = L.CircleMarker.extend({
|
||||
options: {
|
||||
radius: 10,
|
||||
fillColor: '#ff4444',
|
||||
color: '#ff0000',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket connection
|
||||
const socket = io();
|
||||
socket.on("connect", () => {
|
||||
socket.emit("request_initial_data");
|
||||
socket.emit("request_map_data");
|
||||
});
|
||||
|
||||
// Map functions
|
||||
function addOrUpdateDot(lat, lon, city, country, timestamp) {
|
||||
const key = `${lat},${lon}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (activeDots.has(key)) {
|
||||
const dot = activeDots.get(key);
|
||||
dot.mass += 1;
|
||||
dot.lastUpdate = now;
|
||||
dot.connections.push({ city, country, timestamp });
|
||||
|
||||
const newRadius = 10 + Math.log(dot.mass) * 5;
|
||||
dot.marker.setRadius(newRadius);
|
||||
updatePopup(dot);
|
||||
} else {
|
||||
const marker = new PulsingMarker([lat, lon], {
|
||||
radius: 10
|
||||
}).addTo(map);
|
||||
|
||||
const dot = {
|
||||
marker: marker,
|
||||
mass: 1,
|
||||
createdAt: now,
|
||||
lastUpdate: now,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
connections: [{ city, country, timestamp }]
|
||||
};
|
||||
|
||||
activeDots.set(key, dot);
|
||||
updatePopup(dot);
|
||||
totalConnections++;
|
||||
}
|
||||
updateMapStats();
|
||||
}
|
||||
|
||||
function updatePopup(dot) {
|
||||
const location = dot.connections[0];
|
||||
const content = `
|
||||
<strong>${location.city}, ${location.country}</strong><br>
|
||||
Connections: ${dot.mass}<br>
|
||||
Latest: ${new Date(location.timestamp).toLocaleString()}
|
||||
`;
|
||||
dot.marker.bindPopup(content);
|
||||
}
|
||||
|
||||
function updateMapStats() {
|
||||
let mostRecent = null;
|
||||
activeDots.forEach(dot => {
|
||||
if (!mostRecent || dot.lastUpdate > mostRecent) {
|
||||
mostRecent = dot.lastUpdate;
|
||||
}
|
||||
});
|
||||
|
||||
if (mostRecent) {
|
||||
const timeAgo = getTimeAgo(mostRecent);
|
||||
document.getElementById('last-connection').textContent = timeAgo;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeAgo(timestamp) {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes && bytes !== 0) return "-";
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let num = bytes;
|
||||
let unitIndex = 0;
|
||||
while (num >= 1024 && unitIndex < units.length - 1) {
|
||||
num /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
const rounded = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
|
||||
return `${rounded} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function toBool(val) {
|
||||
if (val === true || val === 1) return true;
|
||||
if (typeof val === 'string') {
|
||||
const lower = val.toLowerCase();
|
||||
return lower === 'true' || lower === '1' || lower === 'yes';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function animateDots() {
|
||||
const now = Date.now();
|
||||
const fadeTime = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
activeDots.forEach((dot, key) => {
|
||||
const age = now - dot.lastUpdate;
|
||||
|
||||
if (age > fadeTime) {
|
||||
map.removeLayer(dot.marker);
|
||||
activeDots.delete(key);
|
||||
} else {
|
||||
const fadeProgress = age / fadeTime;
|
||||
const baseRadius = 10 + Math.log(dot.mass) * 5;
|
||||
const currentRadius = baseRadius * (1 - fadeProgress * 0.8);
|
||||
dot.marker.setRadius(Math.max(currentRadius, 2));
|
||||
|
||||
const opacity = 1 - fadeProgress * 0.7;
|
||||
dot.marker.setStyle({
|
||||
fillOpacity: opacity * 0.8,
|
||||
opacity: opacity
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
updateMapStats();
|
||||
}
|
||||
|
||||
setInterval(animateDots, 100);
|
||||
setInterval(() => {
|
||||
if (activeDots.size > 0) {
|
||||
updateMapStats();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Handle map data
|
||||
socket.on('map_initial_data', function(data) {
|
||||
console.log('Received map data:', data.connections.length, 'connections');
|
||||
data.connections.forEach(conn => {
|
||||
if (conn.lat && conn.lon) {
|
||||
addOrUpdateDot(conn.lat, conn.lon, conn.city, conn.country, conn.timestamp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('new_connection', function(data) {
|
||||
console.log('New connection:', data);
|
||||
if (data.lat && data.lon) {
|
||||
addOrUpdateDot(data.lat, data.lon, data.city, data.country, data.timestamp);
|
||||
}
|
||||
});
|
||||
|
||||
// Table functions
|
||||
function updateStats(data) {
|
||||
document.getElementById("totalConnections").textContent = data.length;
|
||||
const totalBytes = data.reduce((sum, rec) => {
|
||||
const val = parseFloat(rec.filesize);
|
||||
return sum + (isNaN(val) ? 0 : val);
|
||||
}, 0);
|
||||
document.getElementById("totalSize").textContent = formatBytes(totalBytes);
|
||||
const cachedCount = data.reduce((sum, rec) => sum + (toBool(rec.cached) ? 1 : 0), 0);
|
||||
const pct = data.length ? ((cachedCount / data.length) * 100).toFixed(0) : 0;
|
||||
document.getElementById("cachedPercent").textContent = `${pct}%`;
|
||||
}
|
||||
|
||||
function createRow(record, animate=true) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.timestamp = record.timestamp;
|
||||
if (animate) {
|
||||
tr.classList.add("slide-in");
|
||||
tr.addEventListener("animationend", () =>
|
||||
tr.classList.remove("slide-in"), { once: true });
|
||||
}
|
||||
tr.innerHTML = `
|
||||
<td>${record.timestamp}</td>
|
||||
<td>${record.location}</td>
|
||||
<td>${record.user_agent}</td>
|
||||
<td>${record.full_path}</td>
|
||||
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
|
||||
<td>${record.mime_typ}</td>
|
||||
<td>${record.cached}</td>
|
||||
`;
|
||||
return tr;
|
||||
}
|
||||
|
||||
function updateTable(data) {
|
||||
const tbody = document.getElementById("connectionsTableBody");
|
||||
const existing = {};
|
||||
Array.from(tbody.children).forEach(r => existing[r.dataset.timestamp] = r);
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
data.forEach(rec => {
|
||||
let row = existing[rec.timestamp];
|
||||
if (row) {
|
||||
row.innerHTML = createRow(rec, false).innerHTML;
|
||||
row.classList.remove("slide-out");
|
||||
} else {
|
||||
row = createRow(rec, true);
|
||||
}
|
||||
frag.appendChild(row);
|
||||
});
|
||||
|
||||
tbody.innerHTML = "";
|
||||
tbody.appendChild(frag);
|
||||
}
|
||||
|
||||
function animateTableWithNewRow(data) {
|
||||
const tbody = document.getElementById("connectionsTableBody");
|
||||
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
|
||||
const newRecs = data.filter(r => !currentTs.has(r.timestamp));
|
||||
|
||||
if (newRecs.length) {
|
||||
const temp = createRow(newRecs[0], false);
|
||||
temp.style.visibility = "hidden";
|
||||
tbody.appendChild(temp);
|
||||
const h = temp.getBoundingClientRect().height;
|
||||
temp.remove();
|
||||
|
||||
tbody.style.transform = `translateY(${h}px)`;
|
||||
setTimeout(() => {
|
||||
updateTable(data);
|
||||
tbody.style.transition = "none";
|
||||
tbody.style.transform = "translateY(0)";
|
||||
void tbody.offsetWidth;
|
||||
tbody.style.transition = "transform 0.5s ease-out";
|
||||
}, 500);
|
||||
} else {
|
||||
updateTable(data);
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("recent_connections", data => {
|
||||
updateStats(data);
|
||||
animateTableWithNewRow(data);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@ -3,11 +3,13 @@
|
||||
|
||||
{# page title #}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block page_id %}dashboard{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" data-page-head />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-page-head></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js" data-page-head></script>
|
||||
<style data-page-head>
|
||||
#dashboard-map {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
@ -269,210 +271,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="dashboard-page-data">
|
||||
{{ {
|
||||
'map_data': map_data,
|
||||
'distinct_device_data': distinct_device_data,
|
||||
'timeframe_data': timeframe_data,
|
||||
'user_agent_data': user_agent_data,
|
||||
'folder_data': folder_data,
|
||||
'timeframe': timeframe
|
||||
} | tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
const rawMapData = {{ map_data|tojson|default('[]') }};
|
||||
const mapData = Array.isArray(rawMapData) ? rawMapData : [];
|
||||
|
||||
// Leaflet map with aggregated downloads (fail-safe so charts still render)
|
||||
try {
|
||||
const createLeafletMap = (mapElement) => {
|
||||
const map = L.map(mapElement).setView([50, 10], 4);
|
||||
const bounds = L.latLngBounds();
|
||||
const defaultCenter = [50, 10];
|
||||
const defaultZoom = 4;
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
mapData.forEach(point => {
|
||||
if (point.lat === null || point.lon === null) return;
|
||||
const radius = Math.max(6, 4 + Math.log(point.count + 1) * 4);
|
||||
const marker = L.circleMarker([point.lat, point.lon], {
|
||||
radius,
|
||||
color: '#ff0000',
|
||||
fillColor: '#ff4444',
|
||||
fillOpacity: 0.75,
|
||||
weight: 2
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(`
|
||||
<strong>${point.city}, ${point.country}</strong><br>
|
||||
Downloads: ${point.count}
|
||||
`);
|
||||
bounds.extend([point.lat, point.lon]);
|
||||
});
|
||||
|
||||
if (mapData.length === 0) {
|
||||
const msg = document.createElement('div');
|
||||
msg.textContent = 'Keine Geo-Daten für den ausgewählten Zeitraum.';
|
||||
msg.className = 'text-muted small mt-3';
|
||||
mapElement.appendChild(msg);
|
||||
}
|
||||
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [24, 24] });
|
||||
} else {
|
||||
map.setView(defaultCenter, defaultZoom);
|
||||
}
|
||||
|
||||
const refreshSize = () => setTimeout(() => map.invalidateSize(), 150);
|
||||
refreshSize();
|
||||
window.addEventListener('resize', refreshSize);
|
||||
window.addEventListener('orientationchange', refreshSize);
|
||||
return { map, refreshSize };
|
||||
};
|
||||
|
||||
(function initDashboardMap() {
|
||||
const mapElement = document.getElementById('dashboard-map');
|
||||
if (!mapElement || typeof L === 'undefined') return;
|
||||
createLeafletMap(mapElement);
|
||||
|
||||
const modalEl = document.getElementById('mapModal');
|
||||
let modalMapInstance = null;
|
||||
if (modalEl) {
|
||||
modalEl.addEventListener('shown.bs.modal', () => {
|
||||
const modalMapElement = document.getElementById('dashboard-map-modal');
|
||||
if (!modalMapElement) return;
|
||||
const modalBody = modalEl.querySelector('.modal-body');
|
||||
const modalHeader = modalEl.querySelector('.modal-header');
|
||||
if (modalBody && modalHeader) {
|
||||
const availableHeight = window.innerHeight - modalHeader.offsetHeight;
|
||||
modalBody.style.height = `${availableHeight}px`;
|
||||
modalMapElement.style.height = '100%';
|
||||
}
|
||||
if (!modalMapInstance) {
|
||||
const { map, refreshSize } = createLeafletMap(modalMapElement);
|
||||
modalMapInstance = { map, refreshSize };
|
||||
}
|
||||
// Ensure proper sizing after modal animation completes
|
||||
setTimeout(() => {
|
||||
modalMapInstance.map.invalidateSize();
|
||||
if (modalMapInstance.refreshSize) modalMapInstance.refreshSize();
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
})();
|
||||
} catch (err) {
|
||||
console.error('Dashboard map init failed:', err);
|
||||
}
|
||||
|
||||
// Data passed from the backend as JSON
|
||||
let distinctDeviceData = {{ distinct_device_data|tojson }};
|
||||
let timeframeData = {{ timeframe_data|tojson }};
|
||||
const userAgentData = {{ user_agent_data|tojson }};
|
||||
const folderData = {{ folder_data|tojson }};
|
||||
|
||||
// Remove the first (incomplete) bucket from the arrays
|
||||
// distinctDeviceData = distinctDeviceData.slice(1);
|
||||
// timeframeData = timeframeData.slice(1);
|
||||
|
||||
// Shift the labels to local time zone
|
||||
const timeframe = "{{ timeframe }}"; // e.g., 'last24hours', '7days', '30days', or '365days'
|
||||
const shiftedLabels = timeframeData.map((item) => {
|
||||
if (timeframe === 'last24hours') {
|
||||
const bucketDate = new Date(item.bucket);
|
||||
const now = new Date();
|
||||
const isCurrentHour =
|
||||
bucketDate.getFullYear() === now.getFullYear() &&
|
||||
bucketDate.getMonth() === now.getMonth() &&
|
||||
bucketDate.getDate() === now.getDate() &&
|
||||
bucketDate.getHours() === now.getHours();
|
||||
const bucketEnd = isCurrentHour ? now : new Date(bucketDate.getTime() + 3600 * 1000);
|
||||
return `${bucketDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${bucketEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
} else if (timeframe === '7days' || timeframe === '30days') {
|
||||
const localDate = new Date(item.bucket);
|
||||
return localDate.toLocaleDateString();
|
||||
} else if (timeframe === '365days') {
|
||||
const [year, month] = item.bucket.split('-');
|
||||
const dateObj = new Date(year, month - 1, 1);
|
||||
return dateObj.toLocaleString([], { month: 'short', year: 'numeric' });
|
||||
} else {
|
||||
return item.bucket;
|
||||
}
|
||||
});
|
||||
|
||||
// Distinct Device Chart
|
||||
const ctxDistinctDevice = document.getElementById('distinctDeviceChart').getContext('2d');
|
||||
new Chart(ctxDistinctDevice, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: shiftedLabels,
|
||||
datasets: [{
|
||||
label: 'Device Count',
|
||||
data: distinctDeviceData.map(item => item.count),
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'Time Range' } },
|
||||
y: {
|
||||
title: { display: true, text: 'Device Count' },
|
||||
beginAtZero: true,
|
||||
ticks: { stepSize: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Timeframe Breakdown Chart
|
||||
const ctxTimeframe = document.getElementById('downloadTimeframeChart').getContext('2d');
|
||||
new Chart(ctxTimeframe, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: shiftedLabels,
|
||||
datasets: [{
|
||||
label: 'Download Count',
|
||||
data: timeframeData.map(item => item.count),
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'Time Range' } },
|
||||
y: {
|
||||
title: { display: true, text: 'Download Count' },
|
||||
beginAtZero: true,
|
||||
ticks: { stepSize: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// User Agent Distribution Chart - Pie Chart
|
||||
const ctxUserAgent = document.getElementById('userAgentChart').getContext('2d');
|
||||
new Chart(ctxUserAgent, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: userAgentData.map(item => item.device),
|
||||
datasets: [{
|
||||
data: userAgentData.map(item => item.count)
|
||||
}]
|
||||
},
|
||||
options: { responsive: true }
|
||||
});
|
||||
|
||||
// Folder Distribution Chart - Pie Chart
|
||||
const ctxFolder = document.getElementById('folderChart').getContext('2d');
|
||||
new Chart(ctxFolder, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: folderData.map(item => item.folder),
|
||||
datasets: [{
|
||||
data: folderData.map(item => item.count)
|
||||
}]
|
||||
},
|
||||
options: { responsive: true }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
{# page title #}
|
||||
{% block title %}Dateizugriffe{% endblock %}
|
||||
{% block page_id %}file_access{% endblock %}
|
||||
|
||||
{# page content #}
|
||||
{% block content %}
|
||||
@ -92,18 +93,30 @@
|
||||
<div class="card-body">
|
||||
{% if top20_files %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-striped" id="file-access-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Access Count</th>
|
||||
<th>File Path</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in top20_files %}
|
||||
{% set parent_path = row.rel_path.rsplit('/', 1)[0] if '/' in row.rel_path else '' %}
|
||||
<tr>
|
||||
<td>{{ row.access_count }}</td>
|
||||
<td>{{ row.rel_path }}</td>
|
||||
<td>
|
||||
<div class="d-flex gap-2 file-access-actions">
|
||||
<button class="btn btn-light btn-sm play-access-btn" data-path="{{ row.rel_path | e }}" title="Play" aria-label="Play">
|
||||
<i class="bi bi-volume-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-light btn-sm folder-open-btn" data-folder="{{ parent_path | e }}" data-file="{{ row.rel_path | e }}" title="Ordner öffnen" aria-label="Ordner öffnen">
|
||||
<i class="bi bi-folder"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
{# page title #}
|
||||
{% block title %}Ordnerkonfiguration{% endblock %}
|
||||
{% block page_id %}folder_secret_config{% endblock %}
|
||||
|
||||
{# page content #}
|
||||
{% block content %}
|
||||
@ -30,446 +31,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const ALPHABET = {{ alphabet|tojson }};
|
||||
let data = [];
|
||||
let editing = new Set();
|
||||
let pendingDelete = null;
|
||||
|
||||
// QR Code generation function using backend
|
||||
async function generateQRCode(secret, imgElement) {
|
||||
try {
|
||||
const response = await fetch(`/admin/generate_qr/${encodeURIComponent(secret)}`);
|
||||
const data = await response.json();
|
||||
imgElement.src = `data:image/png;base64,${data.qr_code}`;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// helper to format DD.MM.YYYY → YYYY-MM-DD
|
||||
function formatISO(d) {
|
||||
const [dd, mm, yyyy] = d.split('.');
|
||||
return (dd && mm && yyyy) ? `${yyyy}-${mm}-${dd}` : '';
|
||||
}
|
||||
|
||||
// load from server
|
||||
async function loadData() {
|
||||
try {
|
||||
const res = await fetch('/admin/folder_secret_config_editor/data');
|
||||
data = await res.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// send delete/update actions
|
||||
async function sendAction(payload) {
|
||||
await fetch('/admin/folder_secret_config_editor/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
await loadData();
|
||||
}
|
||||
|
||||
// delete record after confirmation
|
||||
function deleteRec(secret) {
|
||||
sendAction({ action: 'delete', secret });
|
||||
}
|
||||
|
||||
// build all cards
|
||||
function render() {
|
||||
const cont = document.getElementById('records');
|
||||
cont.innerHTML = '';
|
||||
|
||||
data.forEach(rec => {
|
||||
const key = rec.secret;
|
||||
const isEdit = editing.has(key);
|
||||
const expired = (formatISO(rec.validity) && new Date(formatISO(rec.validity)) < new Date());
|
||||
const cls = isEdit ? 'unlocked' : 'locked';
|
||||
|
||||
// outer card
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'card mb-3';
|
||||
wrapper.dataset.secret = key;
|
||||
|
||||
// Card header for collapse toggle
|
||||
const cardHeader = document.createElement('div');
|
||||
cardHeader.className = 'card-header';
|
||||
cardHeader.style.cursor = 'pointer';
|
||||
const collapseId = `collapse-${key}`;
|
||||
cardHeader.setAttribute('data-bs-toggle', 'collapse');
|
||||
cardHeader.setAttribute('data-bs-target', `#${collapseId}`);
|
||||
cardHeader.setAttribute('aria-expanded', 'false');
|
||||
cardHeader.setAttribute('aria-controls', collapseId);
|
||||
|
||||
const headerTitle = document.createElement('div');
|
||||
headerTitle.className = 'd-flex justify-content-between align-items-center';
|
||||
const folderNames = rec.folders.map(f => f.foldername).join(', ');
|
||||
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
|
||||
cardHeader.appendChild(headerTitle);
|
||||
wrapper.appendChild(cardHeader);
|
||||
|
||||
// Collapsible body
|
||||
const collapseDiv = document.createElement('div');
|
||||
collapseDiv.className = 'collapse';
|
||||
collapseDiv.id = collapseId;
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = `card-body ${cls}`;
|
||||
|
||||
// Create row for two-column layout (only if not editing)
|
||||
if (!isEdit) {
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'row mb-3';
|
||||
|
||||
// Left column for secret and validity
|
||||
const leftCol = document.createElement('div');
|
||||
leftCol.className = 'col-md-8';
|
||||
|
||||
// secret input
|
||||
const secDiv = document.createElement('div');
|
||||
secDiv.className = 'mb-2';
|
||||
secDiv.innerHTML = `Secret: `;
|
||||
const secInput = document.createElement('input');
|
||||
secInput.className = 'form-control';
|
||||
secInput.type = 'text';
|
||||
secInput.value = rec.secret;
|
||||
secInput.readOnly = true;
|
||||
secInput.dataset.field = 'secret';
|
||||
secDiv.appendChild(secInput);
|
||||
leftCol.appendChild(secDiv);
|
||||
|
||||
// validity input
|
||||
const valDiv = document.createElement('div');
|
||||
valDiv.className = 'mb-2';
|
||||
valDiv.innerHTML = `Gültig bis: `;
|
||||
const valInput = document.createElement('input');
|
||||
valInput.className = 'form-control';
|
||||
valInput.type = 'date';
|
||||
valInput.value = formatISO(rec.validity);
|
||||
valInput.readOnly = true;
|
||||
valInput.dataset.field = 'validity';
|
||||
valDiv.appendChild(valInput);
|
||||
leftCol.appendChild(valDiv);
|
||||
|
||||
topRow.appendChild(leftCol);
|
||||
|
||||
// Right column for QR code
|
||||
const rightCol = document.createElement('div');
|
||||
rightCol.className = 'col-md-4 text-center';
|
||||
|
||||
const qrImg = document.createElement('img');
|
||||
qrImg.className = 'qr-code';
|
||||
qrImg.style.maxWidth = '200px';
|
||||
qrImg.style.width = '100%';
|
||||
qrImg.style.border = '1px solid #ddd';
|
||||
qrImg.style.padding = '10px';
|
||||
qrImg.style.borderRadius = '5px';
|
||||
qrImg.alt = 'QR Code';
|
||||
generateQRCode(key, qrImg);
|
||||
rightCol.appendChild(qrImg);
|
||||
|
||||
topRow.appendChild(rightCol);
|
||||
body.appendChild(topRow);
|
||||
} else {
|
||||
// When editing, show fields vertically without QR code
|
||||
// secret input
|
||||
const secDiv = document.createElement('div');
|
||||
secDiv.className = 'mb-2';
|
||||
secDiv.innerHTML = `Secret: `;
|
||||
const secInput = document.createElement('input');
|
||||
secInput.className = 'form-control';
|
||||
secInput.type = 'text';
|
||||
secInput.value = rec.secret;
|
||||
secInput.readOnly = false;
|
||||
secInput.dataset.field = 'secret';
|
||||
secDiv.appendChild(secInput);
|
||||
body.appendChild(secDiv);
|
||||
|
||||
// validity input
|
||||
const valDiv = document.createElement('div');
|
||||
valDiv.className = 'mb-2';
|
||||
valDiv.innerHTML = `Gültig bis: `;
|
||||
const valInput = document.createElement('input');
|
||||
valInput.className = 'form-control';
|
||||
valInput.type = 'date';
|
||||
valInput.value = formatISO(rec.validity);
|
||||
valInput.readOnly = false;
|
||||
valInput.dataset.field = 'validity';
|
||||
valDiv.appendChild(valInput);
|
||||
body.appendChild(valDiv);
|
||||
}
|
||||
|
||||
// folders
|
||||
const folderHeader = document.createElement('h6');
|
||||
folderHeader.textContent = 'Ordner';
|
||||
body.appendChild(folderHeader);
|
||||
|
||||
rec.folders.forEach((f, i) => {
|
||||
const fwrap = document.createElement('div');
|
||||
fwrap.style = 'border:1px solid #ccc; padding:10px; margin-bottom:10px; border-radius:5px;';
|
||||
|
||||
// name
|
||||
fwrap.appendChild(document.createTextNode("Ordnername: "));
|
||||
const nameGroup = document.createElement('div');
|
||||
nameGroup.className = 'input-group mb-2';
|
||||
const nameInput = document.createElement('input');
|
||||
nameInput.className = 'form-control';
|
||||
nameInput.type = 'text';
|
||||
nameInput.value = f.foldername;
|
||||
nameInput.readOnly = !isEdit;
|
||||
nameInput.dataset.field = `foldername-${i}`;
|
||||
nameGroup.appendChild(nameInput);
|
||||
|
||||
if (isEdit) {
|
||||
const remBtn = document.createElement('button');
|
||||
remBtn.className = 'btn btn-outline-danger';
|
||||
remBtn.type = 'button';
|
||||
remBtn.textContent = 'entfernen';
|
||||
remBtn.addEventListener('click', () => removeFolder(key, i));
|
||||
nameGroup.appendChild(remBtn);
|
||||
}
|
||||
fwrap.appendChild(nameGroup);
|
||||
|
||||
// path
|
||||
fwrap.appendChild(document.createTextNode("Ordnerpfad: "));
|
||||
const pathInput = document.createElement('input');
|
||||
pathInput.className = 'form-control mb-2';
|
||||
pathInput.type = 'text';
|
||||
pathInput.value = f.folderpath;
|
||||
pathInput.readOnly = !isEdit;
|
||||
pathInput.dataset.field = `folderpath-${i}`;
|
||||
fwrap.appendChild(pathInput);
|
||||
|
||||
body.appendChild(fwrap);
|
||||
});
|
||||
|
||||
if (isEdit) {
|
||||
const addFld = document.createElement('button');
|
||||
addFld.className = 'btn btn-sm btn-primary mb-2';
|
||||
addFld.type = 'button';
|
||||
addFld.textContent = 'Ordner hinzufügen';
|
||||
addFld.addEventListener('click', () => addFolder(key));
|
||||
body.appendChild(addFld);
|
||||
}
|
||||
|
||||
// actions row
|
||||
const actions = document.createElement('div');
|
||||
|
||||
if (!isEdit) {
|
||||
const openButton = document.createElement('button');
|
||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
||||
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
|
||||
openButton.textContent = 'Link öffnen';
|
||||
actions.appendChild(openButton);
|
||||
}
|
||||
|
||||
if (!isEdit) {
|
||||
const openButton = document.createElement('button');
|
||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
||||
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
|
||||
openButton.textContent = 'Link kopieren';
|
||||
actions.appendChild(openButton);
|
||||
}
|
||||
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'btn btn-danger btn-sm me-2 delete-btn';
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = 'löschen';
|
||||
delBtn.dataset.secret = key;
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
const cloneBtn = document.createElement('button');
|
||||
cloneBtn.className = 'btn btn-secondary btn-sm me-2';
|
||||
cloneBtn.type = 'button';
|
||||
cloneBtn.textContent = 'clonen';
|
||||
cloneBtn.addEventListener('click', () => cloneRec(key));
|
||||
actions.appendChild(cloneBtn);
|
||||
|
||||
if (isEdit || expired) {
|
||||
const renewBtn = document.createElement('button');
|
||||
renewBtn.className = 'btn btn-info btn-sm me-2';
|
||||
renewBtn.type = 'button';
|
||||
renewBtn.textContent = 'erneuern';
|
||||
renewBtn.addEventListener('click', () => renewRec(key));
|
||||
actions.appendChild(renewBtn);
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn btn-success btn-sm me-2';
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.textContent = 'speichern';
|
||||
saveBtn.addEventListener('click', () => saveRec(key));
|
||||
actions.appendChild(saveBtn);
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.className = 'btn btn-secondary btn-sm';
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.textContent = 'abbrechen';
|
||||
cancelBtn.addEventListener('click', () => cancelEdit(key));
|
||||
actions.appendChild(cancelBtn);
|
||||
}
|
||||
|
||||
body.appendChild(actions);
|
||||
collapseDiv.appendChild(body);
|
||||
wrapper.appendChild(collapseDiv);
|
||||
cont.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
// generate a unique secret
|
||||
function generateSecret(existing) {
|
||||
let s;
|
||||
do {
|
||||
s = Array.from({length:32}, () => ALPHABET[Math.floor(Math.random()*ALPHABET.length)]).join('');
|
||||
} while (existing.includes(s));
|
||||
return s;
|
||||
}
|
||||
|
||||
// CRUD helpers
|
||||
function cancelEdit(secret) {
|
||||
editing.delete(secret);
|
||||
// If this was a new record that was never saved, remove it
|
||||
const originalExists = data.some(r => r.secret === secret && !editing.has(secret));
|
||||
loadData(); // Reload to discard changes
|
||||
}
|
||||
function cloneRec(secret) {
|
||||
const idx = data.findIndex(r => r.secret === secret);
|
||||
const rec = JSON.parse(JSON.stringify(data[idx]));
|
||||
const existing = data.map(r => r.secret);
|
||||
rec.secret = generateSecret(existing);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 35);
|
||||
// Format as DD.MM.YYYY for validity input
|
||||
const dd = String(futureDate.getDate()).padStart(2, '0');
|
||||
const mm = String(futureDate.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = futureDate.getFullYear();
|
||||
rec.validity = `${dd}.${mm}.${yyyy}`;
|
||||
data.splice(idx+1, 0, rec);
|
||||
editing.add(rec.secret);
|
||||
render();
|
||||
// Auto-expand the cloned item
|
||||
setTimeout(() => {
|
||||
const collapseEl = document.getElementById(`collapse-${rec.secret}`);
|
||||
if (collapseEl) {
|
||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
||||
bsCollapse.show();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
async function renewRec(secret) {
|
||||
// find current record
|
||||
const rec = data.find(r => r.secret === secret);
|
||||
if (!rec) return;
|
||||
|
||||
// generate a fresh unique secret
|
||||
const existing = data.map(r => r.secret);
|
||||
const newSecret = generateSecret(existing);
|
||||
|
||||
// validity = today + 35 days, formatted as YYYY-MM-DD
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + 35);
|
||||
const yyyy = future.getFullYear();
|
||||
const mm = String(future.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(future.getDate()).padStart(2, '0');
|
||||
const validity = `${yyyy}-${mm}-${dd}`;
|
||||
|
||||
// keep folders unchanged
|
||||
const folders = rec.folders.map(f => ({
|
||||
foldername: f.foldername,
|
||||
folderpath: f.folderpath
|
||||
}));
|
||||
|
||||
// persist via existing endpoint
|
||||
await sendAction({
|
||||
action: 'update',
|
||||
oldSecret: secret,
|
||||
newSecret,
|
||||
validity,
|
||||
folders
|
||||
});
|
||||
}
|
||||
function addFolder(secret) {
|
||||
data.find(r => r.secret === secret).folders.push({foldername:'', folderpath:''});
|
||||
render();
|
||||
}
|
||||
function removeFolder(secret, i) {
|
||||
data.find(r => r.secret === secret).folders.splice(i,1);
|
||||
render();
|
||||
}
|
||||
|
||||
async function saveRec(secret) {
|
||||
const card = document.querySelector(`[data-secret="${secret}"]`);
|
||||
const newSecret = card.querySelector('input[data-field="secret"]').value.trim();
|
||||
const validity = card.querySelector('input[data-field="validity"]').value;
|
||||
const rec = data.find(r => r.secret === secret);
|
||||
const folders = rec.folders.map((_, i) => ({
|
||||
foldername: card.querySelector(`input[data-field="foldername-${i}"]`).value.trim(),
|
||||
folderpath: card.querySelector(`input[data-field="folderpath-${i}"]`).value.trim()
|
||||
}));
|
||||
|
||||
if (!newSecret || data.some(r => r.secret !== secret && r.secret === newSecret)) {
|
||||
return alert('Secret must be unique and non-empty');
|
||||
}
|
||||
if (!validity) {
|
||||
return alert('Validity required');
|
||||
}
|
||||
if (folders.some(f => !f.foldername || !f.folderpath)) {
|
||||
return alert('Folder entries cannot be empty');
|
||||
}
|
||||
|
||||
await sendAction({
|
||||
action: 'update',
|
||||
oldSecret: secret,
|
||||
newSecret,
|
||||
validity,
|
||||
folders
|
||||
});
|
||||
editing.delete(secret);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
// modal handling for delete
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.matches('.delete-btn')) {
|
||||
pendingDelete = e.target.dataset.secret;
|
||||
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
document.querySelector('.confirm-delete').addEventListener('click', () => {
|
||||
if (pendingDelete) deleteRec(pendingDelete);
|
||||
pendingDelete = null;
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
|
||||
modal.hide();
|
||||
});
|
||||
|
||||
// init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('add-btn').addEventListener('click', () => {
|
||||
const existing = data.map(r => r.secret);
|
||||
const newSecret = generateSecret(existing);
|
||||
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
|
||||
editing.add(newSecret);
|
||||
render();
|
||||
// Auto-expand the new item
|
||||
setTimeout(() => {
|
||||
const collapseEl = document.getElementById(`collapse-${newSecret}`);
|
||||
if (collapseEl) {
|
||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
||||
bsCollapse.show();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
loadData();
|
||||
});
|
||||
<script type="application/json" id="folder-config-page-data">
|
||||
{{ { 'alphabet': alphabet } | tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<!-- Messages Section -->
|
||||
<section id="messages-section" class="tab-content">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 messages-section-header">
|
||||
<div class="tab-section-shell">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
|
||||
<h3 class="mb-0">Nachrichten</h3>
|
||||
{% if admin_enabled %}
|
||||
<div>
|
||||
@ -20,7 +20,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div id="messages-container" class="messages-container">
|
||||
<div id="messages-container" class="messages-container tab-section-body">
|
||||
<!-- Messages will be loaded here via JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
{# page title #}
|
||||
{% block title %}Meine Links{% endblock %}
|
||||
{% block page_id %}mylinks{% endblock %}
|
||||
|
||||
{# page‐specific content #}
|
||||
{% block content %}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
{# page title #}
|
||||
{% block title %}Dateizugriffe{% endblock %}
|
||||
{% block page_id %}permission{% endblock %}
|
||||
|
||||
{# page content #}
|
||||
{% block content %}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Search-DB Analyse{% endblock %}
|
||||
{% block page_id %}search_db_analyzer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<h2>Index auswerten</h2>
|
||||
<h2>Ordner auswerten</h2>
|
||||
<p class="text-muted">
|
||||
Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen.
|
||||
</p>
|
||||
@ -27,236 +28,42 @@
|
||||
<h4 class="mb-0">Ergebnis</h4>
|
||||
<span class="badge bg-secondary" id="totalCount"></span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Anzahl Dateien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="result-body"></tbody>
|
||||
</table>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-7">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Anzahl Dateien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="result-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateityp</th>
|
||||
<th>Anzahl Dateien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="filetype-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('analyzer-form');
|
||||
const feedback = document.getElementById('analyzer-feedback');
|
||||
const resultWrapper = document.getElementById('analyzer-result');
|
||||
const resultBody = document.getElementById('result-body');
|
||||
const totalCount = document.getElementById('totalCount');
|
||||
const levelsContainer = document.getElementById('folder-levels');
|
||||
|
||||
const levelSelects = [];
|
||||
|
||||
const createSelect = (level, options) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'folder-level';
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-select';
|
||||
select.dataset.level = level;
|
||||
select.required = level === 0;
|
||||
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.textContent = level === 0 ? 'Bitte auswählen' : 'Optionaler Unterordner';
|
||||
select.appendChild(placeholder);
|
||||
|
||||
options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = opt;
|
||||
option.textContent = opt;
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
removeLevelsFrom(level + 1);
|
||||
updateControlButtons();
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'd-flex gap-2 align-items-center';
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'btn-group btn-group-sm';
|
||||
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-outline-secondary';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.addEventListener('click', () => handleAddLevel());
|
||||
btnGroup.appendChild(addBtn);
|
||||
|
||||
let removeBtn = null;
|
||||
if (level > 0) {
|
||||
removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.className = 'btn btn-outline-danger';
|
||||
removeBtn.textContent = '-';
|
||||
removeBtn.addEventListener('click', () => {
|
||||
removeLevelsFrom(level);
|
||||
updateControlButtons();
|
||||
});
|
||||
btnGroup.appendChild(removeBtn);
|
||||
}
|
||||
|
||||
controls.append(select, btnGroup);
|
||||
wrapper.append(controls);
|
||||
levelsContainer.appendChild(wrapper);
|
||||
levelSelects.push({ wrapper, select, addBtn, removeBtn, btnGroup });
|
||||
};
|
||||
|
||||
const removeLevelsFrom = (startIndex) => {
|
||||
while (levelSelects.length > startIndex) {
|
||||
const item = levelSelects.pop();
|
||||
levelsContainer.removeChild(item.wrapper);
|
||||
}
|
||||
};
|
||||
|
||||
const currentPath = () => {
|
||||
const parts = levelSelects
|
||||
.map(({ select }) => select.value)
|
||||
.filter((v) => v && v.trim() !== '');
|
||||
return parts.join('/');
|
||||
};
|
||||
|
||||
const fetchChildren = async (parentPath = '') => {
|
||||
const params = parentPath ? `?parent=${encodeURIComponent(parentPath)}` : '';
|
||||
const response = await fetch(`{{ url_for("search_db_folders") }}${params}`);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Ordner konnten nicht geladen werden.');
|
||||
}
|
||||
return data.children || [];
|
||||
};
|
||||
|
||||
const updateControlButtons = () => {
|
||||
if (!levelSelects.length) {
|
||||
return;
|
||||
}
|
||||
levelSelects.forEach((item, idx) => {
|
||||
const isLast = idx === levelSelects.length - 1;
|
||||
if (item.btnGroup) {
|
||||
item.btnGroup.classList.toggle('d-none', !isLast);
|
||||
}
|
||||
if (item.addBtn) {
|
||||
item.addBtn.disabled = !item.select.value;
|
||||
}
|
||||
if (item.removeBtn) {
|
||||
const hideRemove = !isLast || idx === 0;
|
||||
item.removeBtn.classList.toggle('d-none', hideRemove);
|
||||
item.removeBtn.disabled = hideRemove;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initBaseLevel = async () => {
|
||||
try {
|
||||
const children = await fetchChildren('');
|
||||
if (!children.length) {
|
||||
feedback.textContent = 'Keine Ordner in der Datenbank gefunden.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
createSelect(0, children);
|
||||
updateControlButtons();
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLevel = async () => {
|
||||
feedback.style.display = 'none';
|
||||
const path = currentPath();
|
||||
if (!path) {
|
||||
feedback.textContent = 'Bitte zuerst einen Hauptordner auswählen.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const children = await fetchChildren(path);
|
||||
if (!children.length) {
|
||||
feedback.textContent = 'Keine weiteren Unterordner vorhanden.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
createSelect(levelSelects.length, children);
|
||||
updateControlButtons();
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
initBaseLevel();
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
feedback.style.display = 'none';
|
||||
resultWrapper.style.display = 'none';
|
||||
|
||||
const folderPath = currentPath();
|
||||
|
||||
if (!folderPath) {
|
||||
feedback.textContent = 'Bitte einen Ordner auswählen.';
|
||||
feedback.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = true;
|
||||
submitButton.textContent = 'Wird geladen...';
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ url_for("search_db_query") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Abfrage fehlgeschlagen.');
|
||||
}
|
||||
|
||||
resultBody.innerHTML = '';
|
||||
if (data.categories && data.categories.length) {
|
||||
data.categories.forEach((row) => {
|
||||
const tr = document.createElement('tr');
|
||||
const cat = document.createElement('td');
|
||||
const cnt = document.createElement('td');
|
||||
|
||||
cat.textContent = row.category || 'Keine Kategorie';
|
||||
cnt.textContent = row.count;
|
||||
|
||||
tr.append(cat, cnt);
|
||||
resultBody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
const tr = document.createElement('tr');
|
||||
const td = document.createElement('td');
|
||||
td.colSpan = 2;
|
||||
td.textContent = 'Keine Treffer gefunden.';
|
||||
tr.appendChild(td);
|
||||
resultBody.appendChild(tr);
|
||||
}
|
||||
|
||||
totalCount.textContent = `${data.total || 0} Dateien`;
|
||||
resultWrapper.style.display = 'block';
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message;
|
||||
feedback.style.display = 'block';
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = 'Abfrage starten';
|
||||
}
|
||||
});
|
||||
});
|
||||
<script type="application/json" id="search-db-analyzer-data">
|
||||
{{ {
|
||||
'folders_url': url_for('search_db_folders'),
|
||||
'query_url': url_for('search_db_query')
|
||||
} | tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
{# page title #}
|
||||
{% block title %}Analyse Wiederholungen{% endblock %}
|
||||
{% block page_id %}songs_dashboard{% endblock %}
|
||||
|
||||
{# page content #}
|
||||
{% block content %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user