Compare commits
4 Commits
7b001efb35
...
9b3d594f1d
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b3d594f1d | |||
| c855bfdbc1 | |||
| 6a27d47222 | |||
| 53b9fec7bd |
@ -108,6 +108,7 @@ def searchcommand():
|
|||||||
sql += " WHERE " + " AND ".join(conditions)
|
sql += " WHERE " + " AND ".join(conditions)
|
||||||
cursor.execute(sql, params)
|
cursor.execute(sql, params)
|
||||||
raw_results = cursor.fetchall()
|
raw_results = cursor.fetchall()
|
||||||
|
total_results = len(raw_results)
|
||||||
|
|
||||||
# Process results
|
# Process results
|
||||||
results = []
|
results = []
|
||||||
@ -135,5 +136,5 @@ def searchcommand():
|
|||||||
results.sort(key=lambda x: x.get(key, 0), reverse=True)
|
results.sort(key=lambda x: x.get(key, 0), reverse=True)
|
||||||
|
|
||||||
# Limit results
|
# Limit results
|
||||||
results = results[:100]
|
results = results[:20]
|
||||||
return jsonify(results=results)
|
return jsonify(results=results, total=total_results)
|
||||||
|
|||||||
@ -10,6 +10,32 @@ APP_CONFIG = auth.return_app_config()
|
|||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
SEARCH_DB_PATH = os.path.join(BASE_DIR, "search.db")
|
SEARCH_DB_PATH = os.path.join(BASE_DIR, "search.db")
|
||||||
|
|
||||||
|
# Filetype buckets (extension-based, lowercase, leading dot).
|
||||||
|
FILETYPE_BUCKETS = [
|
||||||
|
("Liedtexte", {
|
||||||
|
".sng"
|
||||||
|
}),
|
||||||
|
("Image", {
|
||||||
|
".png", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".svg", ".ico"
|
||||||
|
}),
|
||||||
|
("Video", {
|
||||||
|
".mp4", ".m4v", ".mov", ".avi", ".mkv", ".webm", ".mpg", ".mpeg", ".3gp", ".3g2", ".wmv"
|
||||||
|
}),
|
||||||
|
("Photo", {
|
||||||
|
".jpg", ".jpeg", ".heic", ".heif", ".dng", ".cr2", ".cr3", ".arw", ".nef", ".orf", ".rw2", ".raf"
|
||||||
|
}),
|
||||||
|
("Document", {
|
||||||
|
".pdf", ".doc", ".docx", ".ppt", ".pptx", ".txt", ".rtf", ".md", ".odt", ".pages", ".key", ".note"
|
||||||
|
}),
|
||||||
|
("Table", {
|
||||||
|
".xls", ".xlsx", ".csv", ".ods", ".tsv"
|
||||||
|
}),
|
||||||
|
("Audio", {
|
||||||
|
".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma", ".aiff", ".alac"
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
FILETYPE_ORDER = [label for label, _ in FILETYPE_BUCKETS] + ["Misc"]
|
||||||
|
|
||||||
# Open search.db in read-only mode to avoid accidental writes.
|
# Open search.db in read-only mode to avoid accidental writes.
|
||||||
search_db = sqlite3.connect(f"file:{SEARCH_DB_PATH}?mode=ro", uri=True, check_same_thread=False)
|
search_db = sqlite3.connect(f"file:{SEARCH_DB_PATH}?mode=ro", uri=True, check_same_thread=False)
|
||||||
search_db.row_factory = sqlite3.Row
|
search_db.row_factory = sqlite3.Row
|
||||||
@ -20,6 +46,32 @@ def _normalize_folder_path(folder_path: str) -> str:
|
|||||||
return folder_path.replace("\\", "/").strip().strip("/")
|
return folder_path.replace("\\", "/").strip().strip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_for_filetype(filetype: str) -> str:
|
||||||
|
if not filetype:
|
||||||
|
return "Misc"
|
||||||
|
ft = str(filetype).strip().lower()
|
||||||
|
if not ft:
|
||||||
|
return "Misc"
|
||||||
|
|
||||||
|
if "/" in ft and not ft.startswith("."):
|
||||||
|
if ft.startswith("image/"):
|
||||||
|
return "Image"
|
||||||
|
if ft.startswith("video/"):
|
||||||
|
return "Video"
|
||||||
|
if ft.startswith("audio/"):
|
||||||
|
return "Audio"
|
||||||
|
if ft.startswith("text/") or ft in ("application/pdf",):
|
||||||
|
return "Document"
|
||||||
|
|
||||||
|
if not ft.startswith("."):
|
||||||
|
ft = f".{ft}"
|
||||||
|
|
||||||
|
for label, exts in FILETYPE_BUCKETS:
|
||||||
|
if ft in exts:
|
||||||
|
return label
|
||||||
|
return "Misc"
|
||||||
|
|
||||||
|
|
||||||
def _list_children(parent: str = "") -> List[str]:
|
def _list_children(parent: str = "") -> List[str]:
|
||||||
"""
|
"""
|
||||||
Return the next folder level for the given parent.
|
Return the next folder level for the given parent.
|
||||||
@ -82,13 +134,35 @@ def _query_counts(folder_path: str) -> Dict[str, Any]:
|
|||||||
cursor = search_db.cursor()
|
cursor = search_db.cursor()
|
||||||
rows = cursor.execute(sql, params).fetchall()
|
rows = cursor.execute(sql, params).fetchall()
|
||||||
|
|
||||||
|
type_rows = cursor.execute(
|
||||||
|
f"""
|
||||||
|
SELECT COALESCE(filetype, '') AS filetype_label,
|
||||||
|
COUNT(*) AS file_count
|
||||||
|
FROM files
|
||||||
|
WHERE {where_sql}
|
||||||
|
GROUP BY filetype_label
|
||||||
|
""",
|
||||||
|
params
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
total = sum(row["file_count"] for row in rows)
|
total = sum(row["file_count"] for row in rows)
|
||||||
categories = [
|
categories = [
|
||||||
{"category": row["category_label"], "count": row["file_count"]}
|
{"category": row["category_label"], "count": row["file_count"]}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
return {"total": total, "categories": categories}
|
type_totals: Dict[str, int] = {}
|
||||||
|
for row in type_rows:
|
||||||
|
bucket = _bucket_for_filetype(row["filetype_label"])
|
||||||
|
type_totals[bucket] = type_totals.get(bucket, 0) + row["file_count"]
|
||||||
|
|
||||||
|
filetypes = [
|
||||||
|
{"type": label, "count": type_totals[label]}
|
||||||
|
for label in FILETYPE_ORDER
|
||||||
|
if type_totals.get(label)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"total": total, "categories": categories, "filetypes": filetypes}
|
||||||
|
|
||||||
|
|
||||||
def search_db_analyzer():
|
def search_db_analyzer():
|
||||||
|
|||||||
137
static/app.css
137
static/app.css
@ -59,6 +59,15 @@ main > * {
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure modals appear above the sticky header */
|
||||||
|
.modal {
|
||||||
|
z-index: 3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
z-index: 2990;
|
||||||
|
}
|
||||||
|
|
||||||
.site-header img.logo {
|
.site-header img.logo {
|
||||||
height: 54px;
|
height: 54px;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
@ -80,13 +89,22 @@ main > * {
|
|||||||
padding-bottom: 220px;
|
padding-bottom: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper > .admin-nav,
|
.wrapper > .admin-nav {
|
||||||
.wrapper > .main-tabs {
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper > main,
|
.wrapper > #page-content {
|
||||||
.wrapper > section {
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper > #page-content > .main-tabs {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper > #page-content > main,
|
||||||
|
.wrapper > #page-content > section {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
}
|
}
|
||||||
@ -98,7 +116,7 @@ main > * {
|
|||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
width: 95%;
|
width: 95%;
|
||||||
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container a {
|
.container a {
|
||||||
@ -110,16 +128,6 @@ main > * {
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Carded content shell */
|
|
||||||
main.tab-content,
|
|
||||||
section.tab-content {
|
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 20px;
|
|
||||||
margin-top: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Search view container (custom element) */
|
/* Search view container (custom element) */
|
||||||
search {
|
search {
|
||||||
display: block;
|
display: block;
|
||||||
@ -165,7 +173,7 @@ search {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
margin: 0 0 14px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb a {
|
.breadcrumb a {
|
||||||
@ -186,7 +194,6 @@ search {
|
|||||||
color: var(--brand-ink);
|
color: var(--brand-ink);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
margin-bottom: 12px;
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin-left: calc(50% - 50vw);
|
margin-left: calc(50% - 50vw);
|
||||||
margin-right: calc(50% - 50vw);
|
margin-right: calc(50% - 50vw);
|
||||||
@ -597,21 +604,26 @@ footer .audio-player-container {
|
|||||||
|
|
||||||
/* Tab Navigation Styles */
|
/* Tab Navigation Styles */
|
||||||
.main-tabs {
|
.main-tabs {
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 6px 18px 0;
|
|
||||||
gap: 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-wrap: nowrap;
|
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin-left: calc(50% - 50vw);
|
margin-left: calc(50% - 50vw);
|
||||||
margin-right: calc(50% - 50vw);
|
margin-right: calc(50% - 50vw);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-tabs-scroll {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 16px 18px 0;
|
||||||
|
gap: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: visible;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
@ -637,8 +649,7 @@ footer .audio-player-container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-button:hover {
|
.tab-button:hover {
|
||||||
color: var(--brand-ink);
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(230, 230, 230, 0.85));
|
||||||
background: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button.active {
|
.tab-button.active {
|
||||||
@ -653,25 +664,33 @@ footer .audio-player-container {
|
|||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Carded content shell */
|
||||||
.tab-content {
|
.tab-content {
|
||||||
display: none;
|
display: none;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-content.active {
|
.tab-content.active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Messages Section Styles */
|
/* Messages/Calendar shared structure */
|
||||||
.messages-section-header {
|
.tab-section-header {
|
||||||
max-width: 900px;
|
margin-top: 10px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-section-body {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 0 10px;
|
padding: 0 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 10px 0 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-card {
|
.message-card {
|
||||||
@ -876,20 +895,13 @@ footer .audio-player-container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Calendar Section */
|
/* Calendar Section */
|
||||||
#calendar-section .container {
|
|
||||||
max-width: none;
|
|
||||||
width: 100%;
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
gap: 0;
|
gap: 10px;
|
||||||
border: 1px solid var(--border-color);
|
border: 0;
|
||||||
border-top: 0;
|
border-radius: 0;
|
||||||
border-radius: 0 0 10px 10px;
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -899,14 +911,46 @@ footer .audio-player-container {
|
|||||||
|
|
||||||
.calendar-day {
|
.calendar-day {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 12px 12px 6px;
|
border-radius: 0;
|
||||||
|
box-shadow: 0 12px 20px rgba(15, 23, 42, 0.1);
|
||||||
|
padding: 16px 12px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: linear-gradient(315deg, #eef1f6 0%, #ffffff 70%);
|
||||||
|
box-shadow: -3px -3px 8px rgba(15, 23, 42, 0.12);
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
clip-path: polygon(0 100%, 100% 100%, 100% 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: linear-gradient(315deg,
|
||||||
|
transparent 45%,
|
||||||
|
rgba(15, 23, 42, 0.18) 48%,
|
||||||
|
transparent 52%);
|
||||||
|
clip-path: polygon(0 100%, 100% 100%, 100% 0);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day-today {
|
.calendar-day-today {
|
||||||
@ -1207,3 +1251,8 @@ footer .audio-player-container {
|
|||||||
background-color: rgba(246, 195, 68, 0.25);
|
background-color: rgba(246, 195, 68, 0.25);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.file-access-actions {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
165
static/app.js
165
static/app.js
@ -42,21 +42,28 @@ async function generateThumbsSequentially(entries) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache common DOM elements
|
function getMainContainer() {
|
||||||
const mainContainer = document.querySelector('main');
|
return document.querySelector('main');
|
||||||
const searchContainer = document.querySelector('search');
|
}
|
||||||
const footer = document.querySelector('footer');
|
|
||||||
|
|
||||||
console.log(mainContainer, searchContainer, footer);
|
function getSearchContainer() {
|
||||||
|
return document.querySelector('search');
|
||||||
|
}
|
||||||
|
|
||||||
function viewSearch() {
|
function viewSearch() {
|
||||||
// Hide the main container and show the search container
|
// Hide the main container and show the search container
|
||||||
|
const mainContainer = getMainContainer();
|
||||||
|
const searchContainer = getSearchContainer();
|
||||||
|
if (!mainContainer || !searchContainer) return;
|
||||||
mainContainer.style.display = 'none';
|
mainContainer.style.display = 'none';
|
||||||
searchContainer.style.display = 'block';
|
searchContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewMain() {
|
function viewMain() {
|
||||||
// Hide the search container and show the main container
|
// Hide the search container and show the main container
|
||||||
|
const mainContainer = getMainContainer();
|
||||||
|
const searchContainer = getSearchContainer();
|
||||||
|
if (!mainContainer || !searchContainer) return;
|
||||||
searchContainer.style.display = 'none';
|
searchContainer.style.display = 'none';
|
||||||
mainContainer.style.display = 'block';
|
mainContainer.style.display = 'block';
|
||||||
}
|
}
|
||||||
@ -572,7 +579,6 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
link.addEventListener('click', function (event) {
|
link.addEventListener('click', function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const url = '/create_token/' + this.getAttribute('data-url');
|
const url = '/create_token/' + this.getAttribute('data-url');
|
||||||
console.log(url);
|
|
||||||
fetch(url)
|
fetch(url)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@ -581,7 +587,6 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
return response.text();
|
return response.text();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
console.log(data);
|
|
||||||
document.getElementById('transcriptContent').innerHTML = data;
|
document.getElementById('transcriptContent').innerHTML = data;
|
||||||
document.getElementById('transcriptModal').style.display = 'block';
|
document.getElementById('transcriptModal').style.display = 'block';
|
||||||
})
|
})
|
||||||
@ -593,33 +598,63 @@ document.querySelectorAll('.play-file').forEach(link => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle back/forward navigation.
|
// Handle back/forward navigation (bind once).
|
||||||
window.addEventListener('popstate', function (event) {
|
if (!window.__appPopstateBound) {
|
||||||
const subpath = event.state ? event.state.subpath : '';
|
window.addEventListener('popstate', function (event) {
|
||||||
loadDirectory(subpath);
|
const subpath = event.state ? event.state.subpath : '';
|
||||||
});
|
loadDirectory(subpath);
|
||||||
|
});
|
||||||
|
window.__appPopstateBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
} // End of attachEventListeners function
|
} // End of attachEventListeners function
|
||||||
|
|
||||||
// Modal close logic for transcript modal.
|
function initTranscriptModal() {
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
const modal = document.getElementById('transcriptModal');
|
||||||
const closeBtn = document.querySelector('#transcriptModal .close');
|
if (!modal || modal.dataset.bound) return;
|
||||||
closeBtn.addEventListener('click', function () {
|
|
||||||
document.getElementById('transcriptModal').style.display = 'none';
|
const closeBtn = modal.querySelector('.close');
|
||||||
});
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener('click', function () {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
window.addEventListener('click', function (event) {
|
window.addEventListener('click', function (event) {
|
||||||
if (event.target == document.getElementById('transcriptModal')) {
|
if (event.target === modal) {
|
||||||
document.getElementById('transcriptModal').style.display = 'none';
|
modal.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
modal.dataset.bound = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAppPage() {
|
||||||
|
attachEventListeners();
|
||||||
|
initTranscriptModal();
|
||||||
|
if (typeof window.initSearch === 'function') window.initSearch();
|
||||||
|
if (typeof window.initMessages === 'function') window.initMessages();
|
||||||
|
if (typeof window.initGallery === 'function') window.initGallery();
|
||||||
|
if (typeof window.initCalendar === 'function') window.initCalendar();
|
||||||
|
|
||||||
|
syncThemeColor();
|
||||||
|
|
||||||
// Load initial directory based on URL.
|
// Load initial directory based on URL.
|
||||||
let initialSubpath = '';
|
let initialSubpath = '';
|
||||||
|
const pendingFolderOpen = getPendingFolderOpen();
|
||||||
if (window.location.pathname.indexOf('/path/') === 0) {
|
if (window.location.pathname.indexOf('/path/') === 0) {
|
||||||
initialSubpath = window.location.pathname.substring(6); // remove "/path/"
|
initialSubpath = window.location.pathname.substring(6); // remove "/path/"
|
||||||
}
|
}
|
||||||
loadDirectory(initialSubpath);
|
if (pendingFolderOpen?.folder !== undefined) {
|
||||||
});
|
initialSubpath = pendingFolderOpen.folder || '';
|
||||||
|
}
|
||||||
|
loadDirectory(initialSubpath).then(() => {
|
||||||
|
if (pendingFolderOpen?.file) {
|
||||||
|
highlightPendingFile(pendingFolderOpen.file);
|
||||||
|
}
|
||||||
|
if (pendingFolderOpen) {
|
||||||
|
clearPendingFolderOpen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -656,28 +691,32 @@ function reloadDirectory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
document.getElementById('globalAudio').addEventListener('ended', () => {
|
if (!window.__appAudioEndedBound) {
|
||||||
|
const globalAudio = document.getElementById('globalAudio');
|
||||||
reloadDirectory().then(() => {
|
if (globalAudio) {
|
||||||
|
globalAudio.addEventListener('ended', () => {
|
||||||
// If we had a track playing, try to find it in the updated list.
|
reloadDirectory().then(() => {
|
||||||
if (currentTrackPath) {
|
// If we had a track playing, try to find it in the updated list.
|
||||||
const newIndex = currentMusicFiles.findIndex(file => file.path === currentTrackPath);
|
if (currentTrackPath) {
|
||||||
currentMusicIndex = newIndex;
|
const newIndex = currentMusicFiles.findIndex(file => file.path === currentTrackPath);
|
||||||
}
|
currentMusicIndex = newIndex;
|
||||||
|
}
|
||||||
// Now, if there's a next track, auto-play it.
|
|
||||||
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
|
// Now, if there's a next track, auto-play it.
|
||||||
const nextFile = currentMusicFiles[currentMusicIndex + 1];
|
if (currentMusicIndex >= 0 && currentMusicIndex < currentMusicFiles.length - 1) {
|
||||||
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`);
|
const nextFile = currentMusicFiles[currentMusicIndex + 1];
|
||||||
if (nextLink) {
|
const nextLink = document.querySelector(`.play-file[data-url=\"${nextFile.path}\"]`);
|
||||||
nextLink.click();
|
if (nextLink) {
|
||||||
}
|
nextLink.click();
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}
|
||||||
console.error('Error during reload:', error);
|
}).catch(error => {
|
||||||
});
|
console.error('Error during reload:', error);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
window.__appAudioEndedBound = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// document.addEventListener("DOMContentLoaded", function() {
|
// document.addEventListener("DOMContentLoaded", function() {
|
||||||
// // Automatically reload every 5 minutes (300,000 milliseconds)
|
// // Automatically reload every 5 minutes (300,000 milliseconds)
|
||||||
@ -701,4 +740,40 @@ function syncThemeColor() {
|
|||||||
.forEach(svg => svg.setAttribute('fill', cssVar));
|
.forEach(svg => svg.setAttribute('fill', cssVar));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', syncThemeColor);
|
// syncThemeColor is called during app init.
|
||||||
|
|
||||||
|
function getPendingFolderOpen() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem('pendingFolderOpen');
|
||||||
|
return raw ? JSON.parse(raw) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingFolderOpen() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem('pendingFolderOpen');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightPendingFile(filePath) {
|
||||||
|
if (!filePath) return;
|
||||||
|
const target = document.querySelector(`.play-file[data-url=\"${filePath}\"]`);
|
||||||
|
if (target) {
|
||||||
|
target.classList.add('search-highlight');
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
||||||
|
window.PageRegistry.register('app', { init: initAppPage });
|
||||||
|
} else {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initAppPage);
|
||||||
|
} else {
|
||||||
|
initAppPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
const AUDIO_PLAYER_STATE_KEY = 'globalAudioPlayerState';
|
||||||
|
|
||||||
class SimpleAudioPlayer {
|
class SimpleAudioPlayer {
|
||||||
constructor({
|
constructor({
|
||||||
audioSelector = '#globalAudio',
|
audioSelector = '#globalAudio',
|
||||||
@ -53,6 +55,11 @@ class SimpleAudioPlayer {
|
|||||||
|
|
||||||
// Setup MediaSession handlers + heartbeat
|
// Setup MediaSession handlers + heartbeat
|
||||||
this._initMediaSession();
|
this._initMediaSession();
|
||||||
|
|
||||||
|
// Restore previous playback state (if any) and persist on navigation
|
||||||
|
this._restoreState();
|
||||||
|
window.addEventListener('pagehide', () => this._saveState());
|
||||||
|
window.addEventListener('beforeunload', () => this._saveState());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,9 +75,8 @@ class SimpleAudioPlayer {
|
|||||||
if (!Number.isFinite(duration) || duration <= 0) return;
|
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||||
if (!Number.isFinite(position) || position < 0) return;
|
if (!Number.isFinite(position) || position < 0) return;
|
||||||
|
|
||||||
const playbackRate = this.audio.paused
|
let playbackRate = Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1;
|
||||||
? 0
|
if (!playbackRate) playbackRate = 1;
|
||||||
: (Number.isFinite(this.audio.playbackRate) ? this.audio.playbackRate : 1);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ('playbackState' in navigator.mediaSession) {
|
if ('playbackState' in navigator.mediaSession) {
|
||||||
@ -206,13 +212,61 @@ class SimpleAudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleCollapse() {
|
toggleCollapse() {
|
||||||
const collapsed = this.container.classList.toggle('collapsed');
|
const collapsed = !this.container.classList.contains('collapsed');
|
||||||
|
this._setCollapsed(collapsed);
|
||||||
|
this._saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setCollapsed(collapsed) {
|
||||||
|
if (!this.container) return;
|
||||||
|
this.container.classList.toggle('collapsed', collapsed);
|
||||||
if (this.minBtn) {
|
if (this.minBtn) {
|
||||||
this.minBtn.setAttribute('aria-expanded', (!collapsed).toString());
|
this.minBtn.setAttribute('aria-expanded', (!collapsed).toString());
|
||||||
this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen');
|
this.minBtn.setAttribute('aria-label', collapsed ? 'Player ausklappen' : 'Player einklappen');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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() {
|
async fileDownload() {
|
||||||
const src = this.audio.currentSrc || this.audio.src;
|
const src = this.audio.currentSrc || this.audio.src;
|
||||||
if (!src) return;
|
if (!src) return;
|
||||||
@ -243,7 +297,13 @@ class SimpleAudioPlayer {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadTrack(relUrl, reqId) {
|
async loadTrack(relUrl, reqId, options = {}) {
|
||||||
|
if (reqId && typeof reqId === 'object') {
|
||||||
|
options = reqId;
|
||||||
|
reqId = null;
|
||||||
|
}
|
||||||
|
const { autoplay = true, resumeTime = 0 } = options;
|
||||||
|
|
||||||
this.currentRelUrl = relUrl;
|
this.currentRelUrl = relUrl;
|
||||||
const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)));
|
const requestId = reqId || (crypto.randomUUID ? crypto.randomUUID() : (Date.now().toString(36) + Math.random().toString(36).slice(2)));
|
||||||
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`;
|
const urlWithReq = `/media/${relUrl}${relUrl.includes('?') ? '&' : '?'}req=${encodeURIComponent(requestId)}`;
|
||||||
@ -277,7 +337,35 @@ class SimpleAudioPlayer {
|
|||||||
|
|
||||||
this.audio.src = urlWithReq;
|
this.audio.src = urlWithReq;
|
||||||
this.audio.load();
|
this.audio.load();
|
||||||
await this.audio.play();
|
|
||||||
|
const wantsResume = Number.isFinite(resumeTime) && resumeTime > 0;
|
||||||
|
if (wantsResume) {
|
||||||
|
this.audio.addEventListener('loadedmetadata', () => {
|
||||||
|
const safeTime = Number.isFinite(this.audio.duration)
|
||||||
|
? Math.min(resumeTime, this.audio.duration)
|
||||||
|
: resumeTime;
|
||||||
|
this.audio.currentTime = safeTime;
|
||||||
|
this.updateTimeline();
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
this.audio.play().catch(() => this._updatePlayState());
|
||||||
|
} else {
|
||||||
|
this._updatePlayState();
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wantsResume) {
|
||||||
|
if (autoplay) {
|
||||||
|
try {
|
||||||
|
await this.audio.play();
|
||||||
|
} catch {
|
||||||
|
this._updatePlayState();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._updatePlayState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Full breadcrumb
|
// Full breadcrumb
|
||||||
const parts = relUrl.split('/');
|
const parts = relUrl.split('/');
|
||||||
@ -293,6 +381,7 @@ class SimpleAudioPlayer {
|
|||||||
artwork: [{ src:'/icon/logo-192x192.png', sizes:'192x192', type:'image/png' }]
|
artwork: [{ src:'/icon/logo-192x192.png', sizes:'192x192', type:'image/png' }]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this._saveState();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name !== 'AbortError') {
|
if (err.name !== 'AbortError') {
|
||||||
this.nowInfo.textContent = 'Error loading track';
|
this.nowInfo.textContent = 'Error loading track';
|
||||||
@ -325,6 +414,7 @@ class SimpleAudioPlayer {
|
|||||||
|
|
||||||
// Prev & Next
|
// Prev & Next
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', wrap(() => {
|
navigator.mediaSession.setActionHandler('previoustrack', wrap(() => {
|
||||||
|
if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return;
|
||||||
if (currentMusicIndex > 0) {
|
if (currentMusicIndex > 0) {
|
||||||
const prevFile = currentMusicFiles[currentMusicIndex - 1];
|
const prevFile = currentMusicFiles[currentMusicIndex - 1];
|
||||||
const prevLink = document.querySelector(`.play-file[data-url="${prevFile.path}"]`);
|
const prevLink = document.querySelector(`.play-file[data-url="${prevFile.path}"]`);
|
||||||
@ -334,6 +424,7 @@ class SimpleAudioPlayer {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', wrap(() => {
|
navigator.mediaSession.setActionHandler('nexttrack', wrap(() => {
|
||||||
|
if (typeof currentMusicIndex !== 'number' || !Array.isArray(currentMusicFiles)) return;
|
||||||
if (currentMusicIndex < currentMusicFiles.length - 1) {
|
if (currentMusicIndex < currentMusicFiles.length - 1) {
|
||||||
const nextFile = currentMusicFiles[currentMusicIndex + 1];
|
const nextFile = currentMusicFiles[currentMusicIndex + 1];
|
||||||
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`);
|
const nextLink = document.querySelector(`.play-file[data-url="${nextFile.path}"]`);
|
||||||
|
|||||||
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() {
|
function initGallery() {
|
||||||
|
const galleryModal = document.getElementById('gallery-modal');
|
||||||
|
if (!galleryModal || galleryModal.dataset.galleryBound) return;
|
||||||
|
|
||||||
initGallerySwipe();
|
initGallerySwipe();
|
||||||
initGalleryControls();
|
initGalleryControls();
|
||||||
|
|
||||||
// Close modal when clicking outside the image.
|
// Close modal when clicking outside the image.
|
||||||
const galleryModal = document.getElementById('gallery-modal');
|
|
||||||
galleryModal.addEventListener('click', function(e) {
|
galleryModal.addEventListener('click', function(e) {
|
||||||
if (e.target === galleryModal) {
|
if (e.target === galleryModal) {
|
||||||
closeGalleryModal();
|
closeGalleryModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
galleryModal.dataset.galleryBound = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the gallery once the DOM content is loaded.
|
window.initGallery = initGallery;
|
||||||
if (document.readyState === "loading") {
|
|
||||||
document.addEventListener('DOMContentLoaded', initGallery);
|
|
||||||
} else {
|
|
||||||
initGallery();
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,11 +5,27 @@ let messageModal;
|
|||||||
let currentEditingId = null;
|
let currentEditingId = null;
|
||||||
|
|
||||||
// Initialize messages on page load
|
// Initialize messages on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
function initMessages() {
|
||||||
|
const container = document.getElementById('messages-container');
|
||||||
|
if (!container || container.dataset.bound) return;
|
||||||
|
container.dataset.bound = '1';
|
||||||
// Initialize Bootstrap modal
|
// Initialize Bootstrap modal
|
||||||
const modalElement = document.getElementById('messageModal');
|
const modalElement = document.getElementById('messageModal');
|
||||||
if (modalElement) {
|
if (modalElement) {
|
||||||
messageModal = new bootstrap.Modal(modalElement);
|
messageModal = new bootstrap.Modal(modalElement);
|
||||||
|
if (!modalElement.dataset.focusGuard) {
|
||||||
|
modalElement.addEventListener('hide.bs.modal', () => {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && modalElement.contains(active)) {
|
||||||
|
active.blur();
|
||||||
|
// Ensure focus isn't left on an element being hidden
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (document.body) document.body.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modalElement.dataset.focusGuard = '1';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
@ -146,7 +162,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
}
|
||||||
|
|
||||||
async function loadMessages() {
|
async function loadMessages() {
|
||||||
try {
|
try {
|
||||||
@ -479,3 +495,6 @@ function insertLink(path, name, type) {
|
|||||||
// Optionally close the browser after inserting
|
// Optionally close the browser after inserting
|
||||||
fileBrowserCollapse.hide();
|
fileBrowserCollapse.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
window.initMessages = initMessages;
|
||||||
|
|||||||
37
static/page_registry.js
Normal file
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 to render search results from a response object
|
||||||
function renderResults(data) {
|
function renderResults(data) {
|
||||||
const resultsDiv = document.getElementById('results');
|
const resultsDiv = document.getElementById('results');
|
||||||
resultsDiv.innerHTML = '<h5>Suchergebnisse:</h5>';
|
const results = Array.isArray(data.results) ? data.results : [];
|
||||||
|
const total = Number.isFinite(data.total) ? data.total : results.length;
|
||||||
|
const shown = results.length;
|
||||||
|
const remaining = Math.max(0, total - shown);
|
||||||
|
resultsDiv.innerHTML = `<h5>${total} Treffer</h5>`;
|
||||||
|
|
||||||
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
|
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.flac'];
|
||||||
|
|
||||||
if (data.results && data.results.length > 0) {
|
if (results.length > 0) {
|
||||||
data.results.forEach(file => {
|
results.forEach(file => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
|
const filenameWithoutExtension = file.filename.split('.').slice(0, -1).join('.');
|
||||||
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
const parentFolder = file.relative_path.split('/').slice(0, -1).join('/');
|
||||||
@ -32,10 +39,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
`;
|
`;
|
||||||
resultsDiv.appendChild(card);
|
resultsDiv.appendChild(card);
|
||||||
});
|
});
|
||||||
|
if (remaining > 0) {
|
||||||
|
const more = document.createElement('p');
|
||||||
|
more.className = 'text-muted';
|
||||||
|
more.textContent = `und weitere ${remaining} Treffer`;
|
||||||
|
resultsDiv.appendChild(more);
|
||||||
|
}
|
||||||
attachEventListeners();
|
attachEventListeners();
|
||||||
attachSearchFolderButtons();
|
attachSearchFolderButtons();
|
||||||
} else {
|
} 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
|
// Form submission event
|
||||||
document.getElementById('searchForm').addEventListener('submit', function(e) {
|
form.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const query = document.getElementById('query').value.trim();
|
const query = document.getElementById('query').value.trim();
|
||||||
const includeTranscript = document.getElementById('includeTranscript').checked;
|
const includeTranscript = document.getElementById('includeTranscript').checked;
|
||||||
@ -203,7 +216,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// window.location.href = '/';
|
// window.location.href = '/';
|
||||||
viewMain();
|
viewMain();
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
function attachSearchFolderButtons() {
|
function attachSearchFolderButtons() {
|
||||||
document.querySelectorAll('.folder-open-btn').forEach(btn => {
|
document.querySelectorAll('.folder-open-btn').forEach(btn => {
|
||||||
@ -241,4 +254,7 @@ function syncThemeColor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sync once on load
|
// 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' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block title %}{{ title_short }}{% endblock %}
|
{% block title %}{{ title_short }}{% endblock %}
|
||||||
|
{% block page_id %}app{% endblock %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<meta property="og:title" content="{{ og_title }}">
|
<meta property="og:title" content="{{ og_title }}" data-page-head>
|
||||||
<meta property="og:description" content="{{ og_description }}">
|
<meta property="og:description" content="{{ og_description }}" data-page-head>
|
||||||
<meta property="og:image" content="/icon/logo-192x192.png">
|
<meta property="og:image" content="/icon/logo-192x192.png" data-page-head>
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website" data-page-head>
|
||||||
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft.">
|
<meta name="description" content="... uns aber, die wir gerettet werden, ist es eine Gotteskraft." data-page-head>
|
||||||
<meta name="author" content="{{ title_short }}">
|
<meta name="author" content="{{ title_short }}" data-page-head>
|
||||||
<link rel="icon" href="/icon/logo-192x192.png" type="image/png" sizes="192x192">
|
<link rel="icon" href="/icon/logo-192x192.png" type="image/png" sizes="192x192" data-page-head>
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}" data-page-head>
|
||||||
<link rel="touch-icon" href="/icon/logo-192x192.png">
|
<link rel="touch-icon" href="/icon/logo-192x192.png" data-page-head>
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes" data-page-head>
|
||||||
<meta name="mobile-web-app-status-bar-style" content="default">
|
<meta name="mobile-web-app-status-bar-style" content="default" data-page-head>
|
||||||
<meta name="mobile-web-app-title" content="Gottesdienste">
|
<meta name="mobile-web-app-title" content="Gottesdienste" data-page-head>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='gallery.css') }}" data-page-head>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" data-page-head></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
||||||
<script>const admin_enabled = {{ admin_enabled | tojson | safe }};</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
{% if features %}
|
{% if features %}
|
||||||
<nav class="main-tabs">
|
<nav class="main-tabs">
|
||||||
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
|
<div class="main-tabs-scroll">
|
||||||
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
|
{% if "files" in features %}<button class="tab-button active" data-tab="browse">Audio/Photo</button>{% endif %}
|
||||||
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
|
{% if "messages" in features %}<button class="tab-button" data-tab="messages">Nachrichten</button>{% endif %}
|
||||||
|
{% if "calendar" in features %}<button class="tab-button" data-tab="calendar">Kalender</button>{% endif %}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@ -184,45 +185,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</search>
|
</search>
|
||||||
|
|
||||||
<!-- Global Audio Player in Footer -->
|
|
||||||
<footer>
|
|
||||||
<div class="audio-player-container" id="audioPlayerContainer">
|
|
||||||
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
|
|
||||||
<span class="icon-collapse" aria-hidden="true">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span class="icon-expand" aria-hidden="true">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 5v14M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<div class="audio-player">
|
|
||||||
<audio id="globalAudio" prefetch="auto">
|
|
||||||
Your browser does not support the audio element.
|
|
||||||
</audio>
|
|
||||||
<div class="controls">
|
|
||||||
<button class="player-button icon-color">
|
|
||||||
</button>
|
|
||||||
<div class="slider">
|
|
||||||
<input type="range" class="timeline" max="100" value="0" step="0.1">
|
|
||||||
<div id="timeInfo" class="now-playing-info"></div>
|
|
||||||
</div>
|
|
||||||
<button class="sound-button icon-color" onclick="player.fileDownload()">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M12 2.25A9.75 9.75 0 1 1 2.25 12 9.75 9.75 0 0 1 12 2.25Zm0 5a.75.75 0 0 0-.75.75v4.19l-1.72-1.72a.75.75 0 1 0-1.06 1.06l3.03 3.03a.75.75 0 0 0 1.06 0l3.03-3.03a.75.75 0 1 0-1.06-1.06L12.75 12.2V8a.75.75 0 0 0-.75-.75ZM7.5 15.25a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 0-1.5Z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="nowPlayingInfo" class="now-playing-info">
|
|
||||||
keine Datei ausgewählt...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Transcript Modal -->
|
<!-- Transcript Modal -->
|
||||||
<div id="transcriptModal">
|
<div id="transcriptModal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@ -253,11 +215,6 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='audioplayer.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='gallery.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='messages.js') }}"></script>
|
|
||||||
<script>
|
<script>
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
|
|||||||
@ -18,13 +18,15 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='audioplayer.css') }}">
|
||||||
<script src="{{ url_for('static', filename='functions.js') }}"></script>
|
<script src="{{ url_for('static', filename='functions.js') }}"></script>
|
||||||
|
<script>const admin_enabled = {{ admin_enabled | default(false) | tojson | safe }};</script>
|
||||||
|
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a href="/">
|
<a href="/" data-ajax-nav>
|
||||||
<img src="/custom_logo/logoW.png" alt="Logo" class="logo">
|
<img src="/custom_logo/logoW.png" alt="Logo" class="logo">
|
||||||
</a>
|
</a>
|
||||||
<h1>{{ title_long }}</h1>
|
<h1>{{ title_long }}</h1>
|
||||||
@ -35,22 +37,22 @@
|
|||||||
<div class="admin-nav">
|
<div class="admin-nav">
|
||||||
<div class="admin-nav-rail">
|
<div class="admin-nav-rail">
|
||||||
<div class="admin-nav-track">
|
<div class="admin-nav-track">
|
||||||
<a href="{{ url_for('index') }}">App</a>
|
<a href="{{ url_for('index') }}" data-ajax-nav>App</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('mylinks') }}">Meine Links</a>
|
<a href="{{ url_for('mylinks') }}" data-ajax-nav>Meine Links</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config" data-ajax-nav>Ordnerkonfiguration</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<div class="dropdown dropend d-inline-block">
|
<div class="dropdown dropend d-inline-block">
|
||||||
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
|
<a class="dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
|
||||||
Auswertungen
|
Auswertungen
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}">Dashbord</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('dashboard') }}" data-ajax-nav>Dashbord</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('connections') }}">Verbindungen</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('connections') }}" data-ajax-nav>Verbindungen</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('file_access') }}">Dateizugriffe</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('file_access') }}" data-ajax-nav>Dateizugriffe</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}">Wiederholungen</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('songs_dashboard') }}" data-ajax-nav>Wiederholungen</a></li>
|
||||||
<li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a></li>
|
<li><a class="dropdown-item" href="{{ url_for('search_db_analyzer') }}" data-ajax-nav>Ordner auswerten</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -58,11 +60,65 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
<div id="page-content" data-page-id="{% block page_id %}{% endblock %}">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Audio Player in Footer -->
|
||||||
|
<footer>
|
||||||
|
<div class="audio-player-container" id="audioPlayerContainer">
|
||||||
|
<button type="button" class="minimize-button icon-color" aria-label="Player einklappen" aria-expanded="true">
|
||||||
|
<span class="icon-collapse" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="icon-expand" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="audio-player">
|
||||||
|
<audio id="globalAudio" prefetch="auto">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
<div class="controls">
|
||||||
|
<button class="player-button icon-color">
|
||||||
|
</button>
|
||||||
|
<div class="slider">
|
||||||
|
<input type="range" class="timeline" max="100" value="0" step="0.1">
|
||||||
|
<div id="timeInfo" class="now-playing-info"></div>
|
||||||
|
</div>
|
||||||
|
<button class="sound-button icon-color" onclick="player.fileDownload()">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2.25A9.75 9.75 0 1 1 2.25 12 9.75 9.75 0 0 1 12 2.25Zm0 5a.75.75 0 0 0-.75.75v4.19l-1.72-1.72a.75.75 0 1 0-1.06 1.06l3.03 3.03a.75.75 0 0 0 1.06 0l3.03-3.03a.75.75 0 1 0-1.06-1.06L12.75 12.2V8a.75.75 0 0 0-.75-.75ZM7.5 15.25a.75.75 0 0 0 0 1.5h9a.75.75 0 0 0 0-1.5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="nowPlayingInfo" class="now-playing-info">
|
||||||
|
keine Datei ausgewählt...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='audioplayer.js') }}"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="{{ url_for('static', filename='general.js') }}"></script>
|
<script src="{{ url_for('static', filename='general.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='page_registry.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='gallery.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='messages.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='calendar.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='file_access.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='connections.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='dashboard.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='folder_secret_config_editor.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='search_db_analyzer.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='page_router.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<!-- Calendar Section -->
|
<!-- Calendar Section -->
|
||||||
<section id="calendar-section" class="tab-content">
|
<section id="calendar-section" class="tab-content">
|
||||||
<div class="container">
|
<div class="tab-section-shell">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
|
||||||
<h3 class="mb-0">Kalender</h3>
|
<h3 class="mb-0">Kalender</h3>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<select id="calendar-location-filter" class="form-select form-select-sm">
|
<select id="calendar-location-filter" class="form-select form-select-sm">
|
||||||
<option value="">Ort (alle)</option>
|
<option value="">alle</option>
|
||||||
</select>
|
</select>
|
||||||
{% if admin_enabled %}
|
{% if admin_enabled %}
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
@ -24,10 +24,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="calendar-weekday-header"></div>
|
<div class="tab-section-body">
|
||||||
|
<div class="calendar-weekday-header"></div>
|
||||||
|
|
||||||
<div id="calendar-days" class="calendar-grid">
|
<div id="calendar-days" class="calendar-grid">
|
||||||
<!-- Populated via inline script below -->
|
<!-- Populated via inline script below -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -83,673 +85,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
const toLocalISO = (d) => {
|
|
||||||
const year = d.getFullYear();
|
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const holidayCache = new Map();
|
|
||||||
|
|
||||||
function calculateEasterSunday(year) {
|
|
||||||
const a = year % 19;
|
|
||||||
const b = Math.floor(year / 100);
|
|
||||||
const c = year % 100;
|
|
||||||
const d = Math.floor(b / 4);
|
|
||||||
const e = b % 4;
|
|
||||||
const f = Math.floor((b + 8) / 25);
|
|
||||||
const g = Math.floor((b - f + 1) / 3);
|
|
||||||
const h = (19 * a + b - d - g + 15) % 30;
|
|
||||||
const i = Math.floor(c / 4);
|
|
||||||
const k = c % 4;
|
|
||||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
|
||||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
|
||||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
|
||||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
|
||||||
return new Date(year, month - 1, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGermanHolidays(year) {
|
|
||||||
if (holidayCache.has(year)) return holidayCache.get(year);
|
|
||||||
|
|
||||||
const holidays = new Map();
|
|
||||||
const add = (month, day, name) => {
|
|
||||||
holidays.set(toLocalISO(new Date(year, month - 1, day)), name);
|
|
||||||
};
|
|
||||||
|
|
||||||
add(1, 1, 'Neujahr');
|
|
||||||
add(5, 1, 'Tag der Arbeit');
|
|
||||||
add(10, 3, 'Tag der Deutschen Einheit');
|
|
||||||
add(12, 25, '1. Weihnachtstag');
|
|
||||||
add(12, 26, '2. Weihnachtstag');
|
|
||||||
|
|
||||||
const easterSunday = calculateEasterSunday(year);
|
|
||||||
const addOffset = (offset, name) => {
|
|
||||||
const d = new Date(easterSunday);
|
|
||||||
d.setDate(easterSunday.getDate() + offset);
|
|
||||||
holidays.set(toLocalISO(d), name);
|
|
||||||
};
|
|
||||||
|
|
||||||
addOffset(-2, 'Karfreitag');
|
|
||||||
addOffset(0, 'Ostersonntag');
|
|
||||||
addOffset(1, 'Ostermontag');
|
|
||||||
addOffset(39, 'Christi Himmelfahrt');
|
|
||||||
addOffset(49, 'Pfingstsonntag');
|
|
||||||
addOffset(50, 'Pfingstmontag');
|
|
||||||
|
|
||||||
holidayCache.set(year, holidays);
|
|
||||||
return holidays;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHolidayName(date) {
|
|
||||||
const holidays = getGermanHolidays(date.getFullYear());
|
|
||||||
return holidays.get(toLocalISO(date)) || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDesktopView() {
|
|
||||||
return window.matchMedia && window.matchMedia('(min-width: 992px)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCalendarRange() {
|
|
||||||
const weeks = window._calendarIsAdmin ? 12 : 4;
|
|
||||||
const start = new Date();
|
|
||||||
start.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
if (isDesktopView()) {
|
|
||||||
// shift back to Monday (0 = Sunday)
|
|
||||||
const diffToMonday = (start.getDay() + 6) % 7;
|
|
||||||
start.setDate(start.getDate() - diffToMonday);
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = new Date(start);
|
|
||||||
end.setDate(end.getDate() + (weeks * 7) - 1);
|
|
||||||
return { start, end };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWeekdayHeader(startDate) {
|
|
||||||
const header = document.querySelector('.calendar-weekday-header');
|
|
||||||
if (!header) return;
|
|
||||||
header.innerHTML = '';
|
|
||||||
const formatDayShort = new Intl.DateTimeFormat('de-DE', { weekday: 'short' });
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const d = new Date(startDate);
|
|
||||||
d.setDate(startDate.getDate() + i);
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = formatDayShort.format(d);
|
|
||||||
header.appendChild(div);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCalendarStructure(force = false) {
|
|
||||||
const daysContainer = document.getElementById('calendar-days');
|
|
||||||
if (!daysContainer || (daysContainer.childElementCount > 0 && !force)) return;
|
|
||||||
|
|
||||||
const isAdmin = typeof admin_enabled !== 'undefined' && admin_enabled;
|
|
||||||
const calendarColSpan = 3;
|
|
||||||
window._calendarColSpan = calendarColSpan;
|
|
||||||
window._calendarIsAdmin = isAdmin;
|
|
||||||
daysContainer.innerHTML = '';
|
|
||||||
|
|
||||||
const formatDayName = new Intl.DateTimeFormat('de-DE', { weekday: 'long' });
|
|
||||||
const formatDate = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
||||||
const { start, end } = getCalendarRange();
|
|
||||||
const todayIso = toLocalISO(new Date());
|
|
||||||
renderWeekdayHeader(start);
|
|
||||||
const dayCount = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < dayCount; i++) {
|
|
||||||
const date = new Date(start);
|
|
||||||
date.setDate(start.getDate() + i);
|
|
||||||
const isoDate = toLocalISO(date);
|
|
||||||
const isSunday = date.getDay() === 0;
|
|
||||||
const holidayName = getHolidayName(date);
|
|
||||||
const holidayBadge = holidayName ? `<span class="calendar-holiday-badge">${holidayName}</span>` : '';
|
|
||||||
|
|
||||||
const dayBlock = document.createElement('div');
|
|
||||||
dayBlock.className = 'calendar-day empty';
|
|
||||||
dayBlock.dataset.date = isoDate;
|
|
||||||
if (isoDate === toLocalISO(new Date())) {
|
|
||||||
dayBlock.classList.add('calendar-day-today');
|
|
||||||
}
|
|
||||||
if (isSunday) {
|
|
||||||
dayBlock.classList.add('calendar-day-sunday');
|
|
||||||
}
|
|
||||||
if (holidayName) {
|
|
||||||
dayBlock.classList.add('calendar-day-holiday');
|
|
||||||
}
|
|
||||||
if (isDesktopView() && isoDate < todayIso) {
|
|
||||||
dayBlock.classList.add('calendar-day-hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
dayBlock.innerHTML = `
|
|
||||||
<div class="calendar-day-header">
|
|
||||||
<div class="calendar-day-name">${formatDayName.format(date)}</div>
|
|
||||||
<div class="calendar-day-date">${formatDate.format(date)}${holidayBadge}</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive calendar-table-wrapper">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Zeit</th>
|
|
||||||
<th scope="col">Titel</th>
|
|
||||||
<th scope="col">Ort</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="calendar-entries-list">
|
|
||||||
<tr class="calendar-empty-row">
|
|
||||||
<td colspan="${calendarColSpan}" class="text-center text-muted">Keine Einträge</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
daysContainer.appendChild(dayBlock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetDayBlock(block) {
|
|
||||||
const tbody = block.querySelector('.calendar-entries-list');
|
|
||||||
if (!tbody) return;
|
|
||||||
block.classList.add('empty');
|
|
||||||
tbody.innerHTML = `
|
|
||||||
<tr class="calendar-empty-row">
|
|
||||||
<td colspan="${window._calendarColSpan || 3}" class="text-center text-muted">Keine Einträge</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCalendarEntries() {
|
|
||||||
document.querySelectorAll('.calendar-day').forEach(resetDayBlock);
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
return (str || '').replace(/[&<>"']/g, (c) => ({
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
}[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' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block title %}Recent Connections{% endblock %}
|
{% block title %}Recent Connections{% endblock %}
|
||||||
|
{% block page_id %}connections{% endblock %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" data-page-head />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-page-head></script>
|
||||||
<style>
|
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js" data-page-head></script>
|
||||||
|
<style data-page-head>
|
||||||
/* page-specific styles */
|
/* page-specific styles */
|
||||||
.split-view {
|
.split-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -154,269 +156,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}{% endblock %}
|
||||||
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// Initialize the map centered on Europe
|
|
||||||
const map = L.map('map').setView([50.0, 10.0], 5);
|
|
||||||
const refreshMapSize = () => setTimeout(() => map.invalidateSize(), 150);
|
|
||||||
refreshMapSize();
|
|
||||||
window.addEventListener('resize', refreshMapSize);
|
|
||||||
window.addEventListener('orientationchange', refreshMapSize);
|
|
||||||
|
|
||||||
// Add OpenStreetMap tile layer
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
// Store active dots with their data
|
|
||||||
const activeDots = new Map();
|
|
||||||
let totalConnections = 0;
|
|
||||||
|
|
||||||
// Custom pulsing circle marker
|
|
||||||
const PulsingMarker = L.CircleMarker.extend({
|
|
||||||
options: {
|
|
||||||
radius: 10,
|
|
||||||
fillColor: '#ff4444',
|
|
||||||
color: '#ff0000',
|
|
||||||
weight: 2,
|
|
||||||
opacity: 1,
|
|
||||||
fillOpacity: 0.8
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// WebSocket connection
|
|
||||||
const socket = io();
|
|
||||||
socket.on("connect", () => {
|
|
||||||
socket.emit("request_initial_data");
|
|
||||||
socket.emit("request_map_data");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map functions
|
|
||||||
function addOrUpdateDot(lat, lon, city, country, timestamp) {
|
|
||||||
const key = `${lat},${lon}`;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (activeDots.has(key)) {
|
|
||||||
const dot = activeDots.get(key);
|
|
||||||
dot.mass += 1;
|
|
||||||
dot.lastUpdate = now;
|
|
||||||
dot.connections.push({ city, country, timestamp });
|
|
||||||
|
|
||||||
const newRadius = 10 + Math.log(dot.mass) * 5;
|
|
||||||
dot.marker.setRadius(newRadius);
|
|
||||||
updatePopup(dot);
|
|
||||||
} else {
|
|
||||||
const marker = new PulsingMarker([lat, lon], {
|
|
||||||
radius: 10
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
const dot = {
|
|
||||||
marker: marker,
|
|
||||||
mass: 1,
|
|
||||||
createdAt: now,
|
|
||||||
lastUpdate: now,
|
|
||||||
lat: lat,
|
|
||||||
lon: lon,
|
|
||||||
connections: [{ city, country, timestamp }]
|
|
||||||
};
|
|
||||||
|
|
||||||
activeDots.set(key, dot);
|
|
||||||
updatePopup(dot);
|
|
||||||
totalConnections++;
|
|
||||||
}
|
|
||||||
updateMapStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePopup(dot) {
|
|
||||||
const location = dot.connections[0];
|
|
||||||
const content = `
|
|
||||||
<strong>${location.city}, ${location.country}</strong><br>
|
|
||||||
Connections: ${dot.mass}<br>
|
|
||||||
Latest: ${new Date(location.timestamp).toLocaleString()}
|
|
||||||
`;
|
|
||||||
dot.marker.bindPopup(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateMapStats() {
|
|
||||||
let mostRecent = null;
|
|
||||||
activeDots.forEach(dot => {
|
|
||||||
if (!mostRecent || dot.lastUpdate > mostRecent) {
|
|
||||||
mostRecent = dot.lastUpdate;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mostRecent) {
|
|
||||||
const timeAgo = getTimeAgo(mostRecent);
|
|
||||||
document.getElementById('last-connection').textContent = timeAgo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimeAgo(timestamp) {
|
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
return `${hours}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (!bytes && bytes !== 0) return "-";
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
let num = bytes;
|
|
||||||
let unitIndex = 0;
|
|
||||||
while (num >= 1024 && unitIndex < units.length - 1) {
|
|
||||||
num /= 1024;
|
|
||||||
unitIndex++;
|
|
||||||
}
|
|
||||||
const rounded = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
|
|
||||||
return `${rounded} ${units[unitIndex]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBool(val) {
|
|
||||||
if (val === true || val === 1) return true;
|
|
||||||
if (typeof val === 'string') {
|
|
||||||
const lower = val.toLowerCase();
|
|
||||||
return lower === 'true' || lower === '1' || lower === 'yes';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function animateDots() {
|
|
||||||
const now = Date.now();
|
|
||||||
const fadeTime = 10 * 60 * 1000; // 10 minutes
|
|
||||||
|
|
||||||
activeDots.forEach((dot, key) => {
|
|
||||||
const age = now - dot.lastUpdate;
|
|
||||||
|
|
||||||
if (age > fadeTime) {
|
|
||||||
map.removeLayer(dot.marker);
|
|
||||||
activeDots.delete(key);
|
|
||||||
} else {
|
|
||||||
const fadeProgress = age / fadeTime;
|
|
||||||
const baseRadius = 10 + Math.log(dot.mass) * 5;
|
|
||||||
const currentRadius = baseRadius * (1 - fadeProgress * 0.8);
|
|
||||||
dot.marker.setRadius(Math.max(currentRadius, 2));
|
|
||||||
|
|
||||||
const opacity = 1 - fadeProgress * 0.7;
|
|
||||||
dot.marker.setStyle({
|
|
||||||
fillOpacity: opacity * 0.8,
|
|
||||||
opacity: opacity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
updateMapStats();
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(animateDots, 100);
|
|
||||||
setInterval(() => {
|
|
||||||
if (activeDots.size > 0) {
|
|
||||||
updateMapStats();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Handle map data
|
|
||||||
socket.on('map_initial_data', function(data) {
|
|
||||||
console.log('Received map data:', data.connections.length, 'connections');
|
|
||||||
data.connections.forEach(conn => {
|
|
||||||
if (conn.lat && conn.lon) {
|
|
||||||
addOrUpdateDot(conn.lat, conn.lon, conn.city, conn.country, conn.timestamp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('new_connection', function(data) {
|
|
||||||
console.log('New connection:', data);
|
|
||||||
if (data.lat && data.lon) {
|
|
||||||
addOrUpdateDot(data.lat, data.lon, data.city, data.country, data.timestamp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Table functions
|
|
||||||
function updateStats(data) {
|
|
||||||
document.getElementById("totalConnections").textContent = data.length;
|
|
||||||
const totalBytes = data.reduce((sum, rec) => {
|
|
||||||
const val = parseFloat(rec.filesize);
|
|
||||||
return sum + (isNaN(val) ? 0 : val);
|
|
||||||
}, 0);
|
|
||||||
document.getElementById("totalSize").textContent = formatBytes(totalBytes);
|
|
||||||
const cachedCount = data.reduce((sum, rec) => sum + (toBool(rec.cached) ? 1 : 0), 0);
|
|
||||||
const pct = data.length ? ((cachedCount / data.length) * 100).toFixed(0) : 0;
|
|
||||||
document.getElementById("cachedPercent").textContent = `${pct}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRow(record, animate=true) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
tr.dataset.timestamp = record.timestamp;
|
|
||||||
if (animate) {
|
|
||||||
tr.classList.add("slide-in");
|
|
||||||
tr.addEventListener("animationend", () =>
|
|
||||||
tr.classList.remove("slide-in"), { once: true });
|
|
||||||
}
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${record.timestamp}</td>
|
|
||||||
<td>${record.location}</td>
|
|
||||||
<td>${record.user_agent}</td>
|
|
||||||
<td>${record.full_path}</td>
|
|
||||||
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
|
|
||||||
<td>${record.mime_typ}</td>
|
|
||||||
<td>${record.cached}</td>
|
|
||||||
`;
|
|
||||||
return tr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTable(data) {
|
|
||||||
const tbody = document.getElementById("connectionsTableBody");
|
|
||||||
const existing = {};
|
|
||||||
Array.from(tbody.children).forEach(r => existing[r.dataset.timestamp] = r);
|
|
||||||
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
data.forEach(rec => {
|
|
||||||
let row = existing[rec.timestamp];
|
|
||||||
if (row) {
|
|
||||||
row.innerHTML = createRow(rec, false).innerHTML;
|
|
||||||
row.classList.remove("slide-out");
|
|
||||||
} else {
|
|
||||||
row = createRow(rec, true);
|
|
||||||
}
|
|
||||||
frag.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
tbody.innerHTML = "";
|
|
||||||
tbody.appendChild(frag);
|
|
||||||
}
|
|
||||||
|
|
||||||
function animateTableWithNewRow(data) {
|
|
||||||
const tbody = document.getElementById("connectionsTableBody");
|
|
||||||
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
|
|
||||||
const newRecs = data.filter(r => !currentTs.has(r.timestamp));
|
|
||||||
|
|
||||||
if (newRecs.length) {
|
|
||||||
const temp = createRow(newRecs[0], false);
|
|
||||||
temp.style.visibility = "hidden";
|
|
||||||
tbody.appendChild(temp);
|
|
||||||
const h = temp.getBoundingClientRect().height;
|
|
||||||
temp.remove();
|
|
||||||
|
|
||||||
tbody.style.transform = `translateY(${h}px)`;
|
|
||||||
setTimeout(() => {
|
|
||||||
updateTable(data);
|
|
||||||
tbody.style.transition = "none";
|
|
||||||
tbody.style.transform = "translateY(0)";
|
|
||||||
void tbody.offsetWidth;
|
|
||||||
tbody.style.transition = "transform 0.5s ease-out";
|
|
||||||
}, 500);
|
|
||||||
} else {
|
|
||||||
updateTable(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on("recent_connections", data => {
|
|
||||||
updateStats(data);
|
|
||||||
animateTableWithNewRow(data);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
|
|
||||||
{# page title #}
|
{# page title #}
|
||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
{% block page_id %}dashboard{% endblock %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" data-page-head />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" data-page-head></script>
|
||||||
<style>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js" data-page-head></script>
|
||||||
|
<style data-page-head>
|
||||||
#dashboard-map {
|
#dashboard-map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 420px;
|
height: 420px;
|
||||||
@ -269,210 +271,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="application/json" id="dashboard-page-data">
|
||||||
|
{{ {
|
||||||
|
'map_data': map_data,
|
||||||
|
'distinct_device_data': distinct_device_data,
|
||||||
|
'timeframe_data': timeframe_data,
|
||||||
|
'user_agent_data': user_agent_data,
|
||||||
|
'folder_data': folder_data,
|
||||||
|
'timeframe': timeframe
|
||||||
|
} | tojson }}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}{% endblock %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
||||||
<script>
|
|
||||||
const rawMapData = {{ map_data|tojson|default('[]') }};
|
|
||||||
const mapData = Array.isArray(rawMapData) ? rawMapData : [];
|
|
||||||
|
|
||||||
// Leaflet map with aggregated downloads (fail-safe so charts still render)
|
|
||||||
try {
|
|
||||||
const createLeafletMap = (mapElement) => {
|
|
||||||
const map = L.map(mapElement).setView([50, 10], 4);
|
|
||||||
const bounds = L.latLngBounds();
|
|
||||||
const defaultCenter = [50, 10];
|
|
||||||
const defaultZoom = 4;
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
mapData.forEach(point => {
|
|
||||||
if (point.lat === null || point.lon === null) return;
|
|
||||||
const radius = Math.max(6, 4 + Math.log(point.count + 1) * 4);
|
|
||||||
const marker = L.circleMarker([point.lat, point.lon], {
|
|
||||||
radius,
|
|
||||||
color: '#ff0000',
|
|
||||||
fillColor: '#ff4444',
|
|
||||||
fillOpacity: 0.75,
|
|
||||||
weight: 2
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
marker.bindPopup(`
|
|
||||||
<strong>${point.city}, ${point.country}</strong><br>
|
|
||||||
Downloads: ${point.count}
|
|
||||||
`);
|
|
||||||
bounds.extend([point.lat, point.lon]);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mapData.length === 0) {
|
|
||||||
const msg = document.createElement('div');
|
|
||||||
msg.textContent = 'Keine Geo-Daten für den ausgewählten Zeitraum.';
|
|
||||||
msg.className = 'text-muted small mt-3';
|
|
||||||
mapElement.appendChild(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bounds.isValid()) {
|
|
||||||
map.fitBounds(bounds, { padding: [24, 24] });
|
|
||||||
} else {
|
|
||||||
map.setView(defaultCenter, defaultZoom);
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshSize = () => setTimeout(() => map.invalidateSize(), 150);
|
|
||||||
refreshSize();
|
|
||||||
window.addEventListener('resize', refreshSize);
|
|
||||||
window.addEventListener('orientationchange', refreshSize);
|
|
||||||
return { map, refreshSize };
|
|
||||||
};
|
|
||||||
|
|
||||||
(function initDashboardMap() {
|
|
||||||
const mapElement = document.getElementById('dashboard-map');
|
|
||||||
if (!mapElement || typeof L === 'undefined') return;
|
|
||||||
createLeafletMap(mapElement);
|
|
||||||
|
|
||||||
const modalEl = document.getElementById('mapModal');
|
|
||||||
let modalMapInstance = null;
|
|
||||||
if (modalEl) {
|
|
||||||
modalEl.addEventListener('shown.bs.modal', () => {
|
|
||||||
const modalMapElement = document.getElementById('dashboard-map-modal');
|
|
||||||
if (!modalMapElement) return;
|
|
||||||
const modalBody = modalEl.querySelector('.modal-body');
|
|
||||||
const modalHeader = modalEl.querySelector('.modal-header');
|
|
||||||
if (modalBody && modalHeader) {
|
|
||||||
const availableHeight = window.innerHeight - modalHeader.offsetHeight;
|
|
||||||
modalBody.style.height = `${availableHeight}px`;
|
|
||||||
modalMapElement.style.height = '100%';
|
|
||||||
}
|
|
||||||
if (!modalMapInstance) {
|
|
||||||
const { map, refreshSize } = createLeafletMap(modalMapElement);
|
|
||||||
modalMapInstance = { map, refreshSize };
|
|
||||||
}
|
|
||||||
// Ensure proper sizing after modal animation completes
|
|
||||||
setTimeout(() => {
|
|
||||||
modalMapInstance.map.invalidateSize();
|
|
||||||
if (modalMapInstance.refreshSize) modalMapInstance.refreshSize();
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Dashboard map init failed:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data passed from the backend as JSON
|
|
||||||
let distinctDeviceData = {{ distinct_device_data|tojson }};
|
|
||||||
let timeframeData = {{ timeframe_data|tojson }};
|
|
||||||
const userAgentData = {{ user_agent_data|tojson }};
|
|
||||||
const folderData = {{ folder_data|tojson }};
|
|
||||||
|
|
||||||
// Remove the first (incomplete) bucket from the arrays
|
|
||||||
// distinctDeviceData = distinctDeviceData.slice(1);
|
|
||||||
// timeframeData = timeframeData.slice(1);
|
|
||||||
|
|
||||||
// Shift the labels to local time zone
|
|
||||||
const timeframe = "{{ timeframe }}"; // e.g., 'last24hours', '7days', '30days', or '365days'
|
|
||||||
const shiftedLabels = timeframeData.map((item) => {
|
|
||||||
if (timeframe === 'last24hours') {
|
|
||||||
const bucketDate = new Date(item.bucket);
|
|
||||||
const now = new Date();
|
|
||||||
const isCurrentHour =
|
|
||||||
bucketDate.getFullYear() === now.getFullYear() &&
|
|
||||||
bucketDate.getMonth() === now.getMonth() &&
|
|
||||||
bucketDate.getDate() === now.getDate() &&
|
|
||||||
bucketDate.getHours() === now.getHours();
|
|
||||||
const bucketEnd = isCurrentHour ? now : new Date(bucketDate.getTime() + 3600 * 1000);
|
|
||||||
return `${bucketDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${bucketEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
|
||||||
} else if (timeframe === '7days' || timeframe === '30days') {
|
|
||||||
const localDate = new Date(item.bucket);
|
|
||||||
return localDate.toLocaleDateString();
|
|
||||||
} else if (timeframe === '365days') {
|
|
||||||
const [year, month] = item.bucket.split('-');
|
|
||||||
const dateObj = new Date(year, month - 1, 1);
|
|
||||||
return dateObj.toLocaleString([], { month: 'short', year: 'numeric' });
|
|
||||||
} else {
|
|
||||||
return item.bucket;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Distinct Device Chart
|
|
||||||
const ctxDistinctDevice = document.getElementById('distinctDeviceChart').getContext('2d');
|
|
||||||
new Chart(ctxDistinctDevice, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: shiftedLabels,
|
|
||||||
datasets: [{
|
|
||||||
label: 'Device Count',
|
|
||||||
data: distinctDeviceData.map(item => item.count),
|
|
||||||
borderWidth: 2
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
x: { title: { display: true, text: 'Time Range' } },
|
|
||||||
y: {
|
|
||||||
title: { display: true, text: 'Device Count' },
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: { stepSize: 1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Timeframe Breakdown Chart
|
|
||||||
const ctxTimeframe = document.getElementById('downloadTimeframeChart').getContext('2d');
|
|
||||||
new Chart(ctxTimeframe, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: shiftedLabels,
|
|
||||||
datasets: [{
|
|
||||||
label: 'Download Count',
|
|
||||||
data: timeframeData.map(item => item.count),
|
|
||||||
borderWidth: 2
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
x: { title: { display: true, text: 'Time Range' } },
|
|
||||||
y: {
|
|
||||||
title: { display: true, text: 'Download Count' },
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: { stepSize: 1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// User Agent Distribution Chart - Pie Chart
|
|
||||||
const ctxUserAgent = document.getElementById('userAgentChart').getContext('2d');
|
|
||||||
new Chart(ctxUserAgent, {
|
|
||||||
type: 'pie',
|
|
||||||
data: {
|
|
||||||
labels: userAgentData.map(item => item.device),
|
|
||||||
datasets: [{
|
|
||||||
data: userAgentData.map(item => item.count)
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: { responsive: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Folder Distribution Chart - Pie Chart
|
|
||||||
const ctxFolder = document.getElementById('folderChart').getContext('2d');
|
|
||||||
new Chart(ctxFolder, {
|
|
||||||
type: 'pie',
|
|
||||||
data: {
|
|
||||||
labels: folderData.map(item => item.folder),
|
|
||||||
datasets: [{
|
|
||||||
data: folderData.map(item => item.count)
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: { responsive: true }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{# page title #}
|
{# page title #}
|
||||||
{% block title %}Dateizugriffe{% endblock %}
|
{% block title %}Dateizugriffe{% endblock %}
|
||||||
|
{% block page_id %}file_access{% endblock %}
|
||||||
|
|
||||||
{# page content #}
|
{# page content #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -92,18 +93,30 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% if top20_files %}
|
{% if top20_files %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped">
|
<table class="table table-striped" id="file-access-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Access Count</th>
|
<th>Access Count</th>
|
||||||
<th>File Path</th>
|
<th>File Path</th>
|
||||||
|
<th>Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in top20_files %}
|
{% for row in top20_files %}
|
||||||
|
{% set parent_path = row.rel_path.rsplit('/', 1)[0] if '/' in row.rel_path else '' %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ row.access_count }}</td>
|
<td>{{ row.access_count }}</td>
|
||||||
<td>{{ row.rel_path }}</td>
|
<td>{{ row.rel_path }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2 file-access-actions">
|
||||||
|
<button class="btn btn-light btn-sm play-access-btn" data-path="{{ row.rel_path | e }}" title="Play" aria-label="Play">
|
||||||
|
<i class="bi bi-volume-up"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-light btn-sm folder-open-btn" data-folder="{{ parent_path | e }}" data-file="{{ row.rel_path | e }}" title="Ordner öffnen" aria-label="Ordner öffnen">
|
||||||
|
<i class="bi bi-folder"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{# page title #}
|
{# page title #}
|
||||||
{% block title %}Ordnerkonfiguration{% endblock %}
|
{% block title %}Ordnerkonfiguration{% endblock %}
|
||||||
|
{% block page_id %}folder_secret_config{% endblock %}
|
||||||
|
|
||||||
{# page content #}
|
{# page content #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -30,446 +31,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
<script type="application/json" id="folder-config-page-data">
|
||||||
|
{{ { 'alphabet': alphabet } | tojson }}
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
const ALPHABET = {{ alphabet|tojson }};
|
|
||||||
let data = [];
|
|
||||||
let editing = new Set();
|
|
||||||
let pendingDelete = null;
|
|
||||||
|
|
||||||
// QR Code generation function using backend
|
|
||||||
async function generateQRCode(secret, imgElement) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/admin/generate_qr/${encodeURIComponent(secret)}`);
|
|
||||||
const data = await response.json();
|
|
||||||
imgElement.src = `data:image/png;base64,${data.qr_code}`;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating QR code:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// helper to format DD.MM.YYYY → YYYY-MM-DD
|
|
||||||
function formatISO(d) {
|
|
||||||
const [dd, mm, yyyy] = d.split('.');
|
|
||||||
return (dd && mm && yyyy) ? `${yyyy}-${mm}-${dd}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// load from server
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/admin/folder_secret_config_editor/data');
|
|
||||||
data = await res.json();
|
|
||||||
render();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// send delete/update actions
|
|
||||||
async function sendAction(payload) {
|
|
||||||
await fetch('/admin/folder_secret_config_editor/action', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
await loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete record after confirmation
|
|
||||||
function deleteRec(secret) {
|
|
||||||
sendAction({ action: 'delete', secret });
|
|
||||||
}
|
|
||||||
|
|
||||||
// build all cards
|
|
||||||
function render() {
|
|
||||||
const cont = document.getElementById('records');
|
|
||||||
cont.innerHTML = '';
|
|
||||||
|
|
||||||
data.forEach(rec => {
|
|
||||||
const key = rec.secret;
|
|
||||||
const isEdit = editing.has(key);
|
|
||||||
const expired = (formatISO(rec.validity) && new Date(formatISO(rec.validity)) < new Date());
|
|
||||||
const cls = isEdit ? 'unlocked' : 'locked';
|
|
||||||
|
|
||||||
// outer card
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'card mb-3';
|
|
||||||
wrapper.dataset.secret = key;
|
|
||||||
|
|
||||||
// Card header for collapse toggle
|
|
||||||
const cardHeader = document.createElement('div');
|
|
||||||
cardHeader.className = 'card-header';
|
|
||||||
cardHeader.style.cursor = 'pointer';
|
|
||||||
const collapseId = `collapse-${key}`;
|
|
||||||
cardHeader.setAttribute('data-bs-toggle', 'collapse');
|
|
||||||
cardHeader.setAttribute('data-bs-target', `#${collapseId}`);
|
|
||||||
cardHeader.setAttribute('aria-expanded', 'false');
|
|
||||||
cardHeader.setAttribute('aria-controls', collapseId);
|
|
||||||
|
|
||||||
const headerTitle = document.createElement('div');
|
|
||||||
headerTitle.className = 'd-flex justify-content-between align-items-center';
|
|
||||||
const folderNames = rec.folders.map(f => f.foldername).join(', ');
|
|
||||||
headerTitle.innerHTML = `<strong>Ordner: ${folderNames}${expired ? ' <span class="text-danger fw-bold"> ! abgelaufen !</span>' : ''}</strong><i class="bi bi-chevron-down"></i>`;
|
|
||||||
cardHeader.appendChild(headerTitle);
|
|
||||||
wrapper.appendChild(cardHeader);
|
|
||||||
|
|
||||||
// Collapsible body
|
|
||||||
const collapseDiv = document.createElement('div');
|
|
||||||
collapseDiv.className = 'collapse';
|
|
||||||
collapseDiv.id = collapseId;
|
|
||||||
|
|
||||||
const body = document.createElement('div');
|
|
||||||
body.className = `card-body ${cls}`;
|
|
||||||
|
|
||||||
// Create row for two-column layout (only if not editing)
|
|
||||||
if (!isEdit) {
|
|
||||||
const topRow = document.createElement('div');
|
|
||||||
topRow.className = 'row mb-3';
|
|
||||||
|
|
||||||
// Left column for secret and validity
|
|
||||||
const leftCol = document.createElement('div');
|
|
||||||
leftCol.className = 'col-md-8';
|
|
||||||
|
|
||||||
// secret input
|
|
||||||
const secDiv = document.createElement('div');
|
|
||||||
secDiv.className = 'mb-2';
|
|
||||||
secDiv.innerHTML = `Secret: `;
|
|
||||||
const secInput = document.createElement('input');
|
|
||||||
secInput.className = 'form-control';
|
|
||||||
secInput.type = 'text';
|
|
||||||
secInput.value = rec.secret;
|
|
||||||
secInput.readOnly = true;
|
|
||||||
secInput.dataset.field = 'secret';
|
|
||||||
secDiv.appendChild(secInput);
|
|
||||||
leftCol.appendChild(secDiv);
|
|
||||||
|
|
||||||
// validity input
|
|
||||||
const valDiv = document.createElement('div');
|
|
||||||
valDiv.className = 'mb-2';
|
|
||||||
valDiv.innerHTML = `Gültig bis: `;
|
|
||||||
const valInput = document.createElement('input');
|
|
||||||
valInput.className = 'form-control';
|
|
||||||
valInput.type = 'date';
|
|
||||||
valInput.value = formatISO(rec.validity);
|
|
||||||
valInput.readOnly = true;
|
|
||||||
valInput.dataset.field = 'validity';
|
|
||||||
valDiv.appendChild(valInput);
|
|
||||||
leftCol.appendChild(valDiv);
|
|
||||||
|
|
||||||
topRow.appendChild(leftCol);
|
|
||||||
|
|
||||||
// Right column for QR code
|
|
||||||
const rightCol = document.createElement('div');
|
|
||||||
rightCol.className = 'col-md-4 text-center';
|
|
||||||
|
|
||||||
const qrImg = document.createElement('img');
|
|
||||||
qrImg.className = 'qr-code';
|
|
||||||
qrImg.style.maxWidth = '200px';
|
|
||||||
qrImg.style.width = '100%';
|
|
||||||
qrImg.style.border = '1px solid #ddd';
|
|
||||||
qrImg.style.padding = '10px';
|
|
||||||
qrImg.style.borderRadius = '5px';
|
|
||||||
qrImg.alt = 'QR Code';
|
|
||||||
generateQRCode(key, qrImg);
|
|
||||||
rightCol.appendChild(qrImg);
|
|
||||||
|
|
||||||
topRow.appendChild(rightCol);
|
|
||||||
body.appendChild(topRow);
|
|
||||||
} else {
|
|
||||||
// When editing, show fields vertically without QR code
|
|
||||||
// secret input
|
|
||||||
const secDiv = document.createElement('div');
|
|
||||||
secDiv.className = 'mb-2';
|
|
||||||
secDiv.innerHTML = `Secret: `;
|
|
||||||
const secInput = document.createElement('input');
|
|
||||||
secInput.className = 'form-control';
|
|
||||||
secInput.type = 'text';
|
|
||||||
secInput.value = rec.secret;
|
|
||||||
secInput.readOnly = false;
|
|
||||||
secInput.dataset.field = 'secret';
|
|
||||||
secDiv.appendChild(secInput);
|
|
||||||
body.appendChild(secDiv);
|
|
||||||
|
|
||||||
// validity input
|
|
||||||
const valDiv = document.createElement('div');
|
|
||||||
valDiv.className = 'mb-2';
|
|
||||||
valDiv.innerHTML = `Gültig bis: `;
|
|
||||||
const valInput = document.createElement('input');
|
|
||||||
valInput.className = 'form-control';
|
|
||||||
valInput.type = 'date';
|
|
||||||
valInput.value = formatISO(rec.validity);
|
|
||||||
valInput.readOnly = false;
|
|
||||||
valInput.dataset.field = 'validity';
|
|
||||||
valDiv.appendChild(valInput);
|
|
||||||
body.appendChild(valDiv);
|
|
||||||
}
|
|
||||||
|
|
||||||
// folders
|
|
||||||
const folderHeader = document.createElement('h6');
|
|
||||||
folderHeader.textContent = 'Ordner';
|
|
||||||
body.appendChild(folderHeader);
|
|
||||||
|
|
||||||
rec.folders.forEach((f, i) => {
|
|
||||||
const fwrap = document.createElement('div');
|
|
||||||
fwrap.style = 'border:1px solid #ccc; padding:10px; margin-bottom:10px; border-radius:5px;';
|
|
||||||
|
|
||||||
// name
|
|
||||||
fwrap.appendChild(document.createTextNode("Ordnername: "));
|
|
||||||
const nameGroup = document.createElement('div');
|
|
||||||
nameGroup.className = 'input-group mb-2';
|
|
||||||
const nameInput = document.createElement('input');
|
|
||||||
nameInput.className = 'form-control';
|
|
||||||
nameInput.type = 'text';
|
|
||||||
nameInput.value = f.foldername;
|
|
||||||
nameInput.readOnly = !isEdit;
|
|
||||||
nameInput.dataset.field = `foldername-${i}`;
|
|
||||||
nameGroup.appendChild(nameInput);
|
|
||||||
|
|
||||||
if (isEdit) {
|
|
||||||
const remBtn = document.createElement('button');
|
|
||||||
remBtn.className = 'btn btn-outline-danger';
|
|
||||||
remBtn.type = 'button';
|
|
||||||
remBtn.textContent = 'entfernen';
|
|
||||||
remBtn.addEventListener('click', () => removeFolder(key, i));
|
|
||||||
nameGroup.appendChild(remBtn);
|
|
||||||
}
|
|
||||||
fwrap.appendChild(nameGroup);
|
|
||||||
|
|
||||||
// path
|
|
||||||
fwrap.appendChild(document.createTextNode("Ordnerpfad: "));
|
|
||||||
const pathInput = document.createElement('input');
|
|
||||||
pathInput.className = 'form-control mb-2';
|
|
||||||
pathInput.type = 'text';
|
|
||||||
pathInput.value = f.folderpath;
|
|
||||||
pathInput.readOnly = !isEdit;
|
|
||||||
pathInput.dataset.field = `folderpath-${i}`;
|
|
||||||
fwrap.appendChild(pathInput);
|
|
||||||
|
|
||||||
body.appendChild(fwrap);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isEdit) {
|
|
||||||
const addFld = document.createElement('button');
|
|
||||||
addFld.className = 'btn btn-sm btn-primary mb-2';
|
|
||||||
addFld.type = 'button';
|
|
||||||
addFld.textContent = 'Ordner hinzufügen';
|
|
||||||
addFld.addEventListener('click', () => addFolder(key));
|
|
||||||
body.appendChild(addFld);
|
|
||||||
}
|
|
||||||
|
|
||||||
// actions row
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
|
|
||||||
if (!isEdit) {
|
|
||||||
const openButton = document.createElement('button');
|
|
||||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
|
||||||
openButton.onclick = () => window.open(`/?secret=${rec.secret}`, '_self');
|
|
||||||
openButton.textContent = 'Link öffnen';
|
|
||||||
actions.appendChild(openButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isEdit) {
|
|
||||||
const openButton = document.createElement('button');
|
|
||||||
openButton.className = 'btn btn-secondary btn-sm me-2';
|
|
||||||
openButton.onclick = () => toClipboard(`${window.location.origin}/?secret=${rec.secret}`);
|
|
||||||
openButton.textContent = 'Link kopieren';
|
|
||||||
actions.appendChild(openButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
const delBtn = document.createElement('button');
|
|
||||||
delBtn.className = 'btn btn-danger btn-sm me-2 delete-btn';
|
|
||||||
delBtn.type = 'button';
|
|
||||||
delBtn.textContent = 'löschen';
|
|
||||||
delBtn.dataset.secret = key;
|
|
||||||
actions.appendChild(delBtn);
|
|
||||||
|
|
||||||
const cloneBtn = document.createElement('button');
|
|
||||||
cloneBtn.className = 'btn btn-secondary btn-sm me-2';
|
|
||||||
cloneBtn.type = 'button';
|
|
||||||
cloneBtn.textContent = 'clonen';
|
|
||||||
cloneBtn.addEventListener('click', () => cloneRec(key));
|
|
||||||
actions.appendChild(cloneBtn);
|
|
||||||
|
|
||||||
if (isEdit || expired) {
|
|
||||||
const renewBtn = document.createElement('button');
|
|
||||||
renewBtn.className = 'btn btn-info btn-sm me-2';
|
|
||||||
renewBtn.type = 'button';
|
|
||||||
renewBtn.textContent = 'erneuern';
|
|
||||||
renewBtn.addEventListener('click', () => renewRec(key));
|
|
||||||
actions.appendChild(renewBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEdit) {
|
|
||||||
const saveBtn = document.createElement('button');
|
|
||||||
saveBtn.className = 'btn btn-success btn-sm me-2';
|
|
||||||
saveBtn.type = 'button';
|
|
||||||
saveBtn.textContent = 'speichern';
|
|
||||||
saveBtn.addEventListener('click', () => saveRec(key));
|
|
||||||
actions.appendChild(saveBtn);
|
|
||||||
|
|
||||||
const cancelBtn = document.createElement('button');
|
|
||||||
cancelBtn.className = 'btn btn-secondary btn-sm';
|
|
||||||
cancelBtn.type = 'button';
|
|
||||||
cancelBtn.textContent = 'abbrechen';
|
|
||||||
cancelBtn.addEventListener('click', () => cancelEdit(key));
|
|
||||||
actions.appendChild(cancelBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.appendChild(actions);
|
|
||||||
collapseDiv.appendChild(body);
|
|
||||||
wrapper.appendChild(collapseDiv);
|
|
||||||
cont.appendChild(wrapper);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate a unique secret
|
|
||||||
function generateSecret(existing) {
|
|
||||||
let s;
|
|
||||||
do {
|
|
||||||
s = Array.from({length:32}, () => ALPHABET[Math.floor(Math.random()*ALPHABET.length)]).join('');
|
|
||||||
} while (existing.includes(s));
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRUD helpers
|
|
||||||
function cancelEdit(secret) {
|
|
||||||
editing.delete(secret);
|
|
||||||
// If this was a new record that was never saved, remove it
|
|
||||||
const originalExists = data.some(r => r.secret === secret && !editing.has(secret));
|
|
||||||
loadData(); // Reload to discard changes
|
|
||||||
}
|
|
||||||
function cloneRec(secret) {
|
|
||||||
const idx = data.findIndex(r => r.secret === secret);
|
|
||||||
const rec = JSON.parse(JSON.stringify(data[idx]));
|
|
||||||
const existing = data.map(r => r.secret);
|
|
||||||
rec.secret = generateSecret(existing);
|
|
||||||
const futureDate = new Date();
|
|
||||||
futureDate.setDate(futureDate.getDate() + 35);
|
|
||||||
// Format as DD.MM.YYYY for validity input
|
|
||||||
const dd = String(futureDate.getDate()).padStart(2, '0');
|
|
||||||
const mm = String(futureDate.getMonth() + 1).padStart(2, '0');
|
|
||||||
const yyyy = futureDate.getFullYear();
|
|
||||||
rec.validity = `${dd}.${mm}.${yyyy}`;
|
|
||||||
data.splice(idx+1, 0, rec);
|
|
||||||
editing.add(rec.secret);
|
|
||||||
render();
|
|
||||||
// Auto-expand the cloned item
|
|
||||||
setTimeout(() => {
|
|
||||||
const collapseEl = document.getElementById(`collapse-${rec.secret}`);
|
|
||||||
if (collapseEl) {
|
|
||||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
|
||||||
bsCollapse.show();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
async function renewRec(secret) {
|
|
||||||
// find current record
|
|
||||||
const rec = data.find(r => r.secret === secret);
|
|
||||||
if (!rec) return;
|
|
||||||
|
|
||||||
// generate a fresh unique secret
|
|
||||||
const existing = data.map(r => r.secret);
|
|
||||||
const newSecret = generateSecret(existing);
|
|
||||||
|
|
||||||
// validity = today + 35 days, formatted as YYYY-MM-DD
|
|
||||||
const future = new Date();
|
|
||||||
future.setDate(future.getDate() + 35);
|
|
||||||
const yyyy = future.getFullYear();
|
|
||||||
const mm = String(future.getMonth() + 1).padStart(2, '0');
|
|
||||||
const dd = String(future.getDate()).padStart(2, '0');
|
|
||||||
const validity = `${yyyy}-${mm}-${dd}`;
|
|
||||||
|
|
||||||
// keep folders unchanged
|
|
||||||
const folders = rec.folders.map(f => ({
|
|
||||||
foldername: f.foldername,
|
|
||||||
folderpath: f.folderpath
|
|
||||||
}));
|
|
||||||
|
|
||||||
// persist via existing endpoint
|
|
||||||
await sendAction({
|
|
||||||
action: 'update',
|
|
||||||
oldSecret: secret,
|
|
||||||
newSecret,
|
|
||||||
validity,
|
|
||||||
folders
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function addFolder(secret) {
|
|
||||||
data.find(r => r.secret === secret).folders.push({foldername:'', folderpath:''});
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
function removeFolder(secret, i) {
|
|
||||||
data.find(r => r.secret === secret).folders.splice(i,1);
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRec(secret) {
|
|
||||||
const card = document.querySelector(`[data-secret="${secret}"]`);
|
|
||||||
const newSecret = card.querySelector('input[data-field="secret"]').value.trim();
|
|
||||||
const validity = card.querySelector('input[data-field="validity"]').value;
|
|
||||||
const rec = data.find(r => r.secret === secret);
|
|
||||||
const folders = rec.folders.map((_, i) => ({
|
|
||||||
foldername: card.querySelector(`input[data-field="foldername-${i}"]`).value.trim(),
|
|
||||||
folderpath: card.querySelector(`input[data-field="folderpath-${i}"]`).value.trim()
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (!newSecret || data.some(r => r.secret !== secret && r.secret === newSecret)) {
|
|
||||||
return alert('Secret must be unique and non-empty');
|
|
||||||
}
|
|
||||||
if (!validity) {
|
|
||||||
return alert('Validity required');
|
|
||||||
}
|
|
||||||
if (folders.some(f => !f.foldername || !f.folderpath)) {
|
|
||||||
return alert('Folder entries cannot be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendAction({
|
|
||||||
action: 'update',
|
|
||||||
oldSecret: secret,
|
|
||||||
newSecret,
|
|
||||||
validity,
|
|
||||||
folders
|
|
||||||
});
|
|
||||||
editing.delete(secret);
|
|
||||||
await loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// modal handling for delete
|
|
||||||
document.addEventListener('click', e => {
|
|
||||||
if (e.target.matches('.delete-btn')) {
|
|
||||||
pendingDelete = e.target.dataset.secret;
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
|
|
||||||
modal.show();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.querySelector('.confirm-delete').addEventListener('click', () => {
|
|
||||||
if (pendingDelete) deleteRec(pendingDelete);
|
|
||||||
pendingDelete = null;
|
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('confirmDeleteModal'));
|
|
||||||
modal.hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
// init
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
document.getElementById('add-btn').addEventListener('click', () => {
|
|
||||||
const existing = data.map(r => r.secret);
|
|
||||||
const newSecret = generateSecret(existing);
|
|
||||||
data.push({ secret: newSecret, validity: new Date().toISOString().slice(0,10), folders: [] });
|
|
||||||
editing.add(newSecret);
|
|
||||||
render();
|
|
||||||
// Auto-expand the new item
|
|
||||||
setTimeout(() => {
|
|
||||||
const collapseEl = document.getElementById(`collapse-${newSecret}`);
|
|
||||||
if (collapseEl) {
|
|
||||||
const bsCollapse = new bootstrap.Collapse(collapseEl, { toggle: false });
|
|
||||||
bsCollapse.show();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<!-- Messages Section -->
|
<!-- Messages Section -->
|
||||||
<section id="messages-section" class="tab-content">
|
<section id="messages-section" class="tab-content">
|
||||||
<div class="container">
|
<div class="tab-section-shell">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 messages-section-header">
|
<div class="d-flex justify-content-between align-items-center mb-3 tab-section-header">
|
||||||
<h3 class="mb-0">Nachrichten</h3>
|
<h3 class="mb-0">Nachrichten</h3>
|
||||||
{% if admin_enabled %}
|
{% if admin_enabled %}
|
||||||
<div>
|
<div>
|
||||||
@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="messages-container" class="messages-container">
|
<div id="messages-container" class="messages-container tab-section-body">
|
||||||
<!-- Messages will be loaded here via JavaScript -->
|
<!-- Messages will be loaded here via JavaScript -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{# page title #}
|
{# page title #}
|
||||||
{% block title %}Meine Links{% endblock %}
|
{% block title %}Meine Links{% endblock %}
|
||||||
|
{% block page_id %}mylinks{% endblock %}
|
||||||
|
|
||||||
{# page‐specific content #}
|
{# page‐specific content #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{# page title #}
|
{# page title #}
|
||||||
{% block title %}Dateizugriffe{% endblock %}
|
{% block title %}Dateizugriffe{% endblock %}
|
||||||
|
{% block page_id %}permission{% endblock %}
|
||||||
|
|
||||||
{# page content #}
|
{# page content #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block title %}Search-DB Analyse{% endblock %}
|
{% block title %}Search-DB Analyse{% endblock %}
|
||||||
|
{% block page_id %}search_db_analyzer{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Index auswerten</h2>
|
<h2>Ordner auswerten</h2>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen.
|
Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen.
|
||||||
</p>
|
</p>
|
||||||
@ -27,236 +28,42 @@
|
|||||||
<h4 class="mb-0">Ergebnis</h4>
|
<h4 class="mb-0">Ergebnis</h4>
|
||||||
<span class="badge bg-secondary" id="totalCount"></span>
|
<span class="badge bg-secondary" id="totalCount"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="row g-3">
|
||||||
<table class="table table-striped align-middle">
|
<div class="col-lg-7">
|
||||||
<thead>
|
<div class="table-responsive">
|
||||||
<tr>
|
<table class="table table-striped align-middle">
|
||||||
<th>Kategorie</th>
|
<thead>
|
||||||
<th>Anzahl Dateien</th>
|
<tr>
|
||||||
</tr>
|
<th>Kategorie</th>
|
||||||
</thead>
|
<th>Anzahl Dateien</th>
|
||||||
<tbody id="result-body"></tbody>
|
</tr>
|
||||||
</table>
|
</thead>
|
||||||
|
<tbody id="result-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Dateityp</th>
|
||||||
|
<th>Anzahl Dateien</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="filetype-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
<script type="application/json" id="search-db-analyzer-data">
|
||||||
|
{{ {
|
||||||
{% block scripts %}
|
'folders_url': url_for('search_db_folders'),
|
||||||
{{ super() }}
|
'query_url': url_for('search_db_query')
|
||||||
<script>
|
} | tojson }}
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const form = document.getElementById('analyzer-form');
|
|
||||||
const feedback = document.getElementById('analyzer-feedback');
|
|
||||||
const resultWrapper = document.getElementById('analyzer-result');
|
|
||||||
const resultBody = document.getElementById('result-body');
|
|
||||||
const totalCount = document.getElementById('totalCount');
|
|
||||||
const levelsContainer = document.getElementById('folder-levels');
|
|
||||||
|
|
||||||
const levelSelects = [];
|
|
||||||
|
|
||||||
const createSelect = (level, options) => {
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = 'folder-level';
|
|
||||||
const select = document.createElement('select');
|
|
||||||
select.className = 'form-select';
|
|
||||||
select.dataset.level = level;
|
|
||||||
select.required = level === 0;
|
|
||||||
|
|
||||||
const placeholder = document.createElement('option');
|
|
||||||
placeholder.value = '';
|
|
||||||
placeholder.textContent = level === 0 ? 'Bitte auswählen' : 'Optionaler Unterordner';
|
|
||||||
select.appendChild(placeholder);
|
|
||||||
|
|
||||||
options.forEach((opt) => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = opt;
|
|
||||||
option.textContent = opt;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
|
|
||||||
select.addEventListener('change', () => {
|
|
||||||
removeLevelsFrom(level + 1);
|
|
||||||
updateControlButtons();
|
|
||||||
});
|
|
||||||
|
|
||||||
const controls = document.createElement('div');
|
|
||||||
controls.className = 'd-flex gap-2 align-items-center';
|
|
||||||
const btnGroup = document.createElement('div');
|
|
||||||
btnGroup.className = 'btn-group btn-group-sm';
|
|
||||||
|
|
||||||
const addBtn = document.createElement('button');
|
|
||||||
addBtn.type = 'button';
|
|
||||||
addBtn.className = 'btn btn-outline-secondary';
|
|
||||||
addBtn.textContent = '+';
|
|
||||||
addBtn.addEventListener('click', () => handleAddLevel());
|
|
||||||
btnGroup.appendChild(addBtn);
|
|
||||||
|
|
||||||
let removeBtn = null;
|
|
||||||
if (level > 0) {
|
|
||||||
removeBtn = document.createElement('button');
|
|
||||||
removeBtn.type = 'button';
|
|
||||||
removeBtn.className = 'btn btn-outline-danger';
|
|
||||||
removeBtn.textContent = '-';
|
|
||||||
removeBtn.addEventListener('click', () => {
|
|
||||||
removeLevelsFrom(level);
|
|
||||||
updateControlButtons();
|
|
||||||
});
|
|
||||||
btnGroup.appendChild(removeBtn);
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.append(select, btnGroup);
|
|
||||||
wrapper.append(controls);
|
|
||||||
levelsContainer.appendChild(wrapper);
|
|
||||||
levelSelects.push({ wrapper, select, addBtn, removeBtn, btnGroup });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeLevelsFrom = (startIndex) => {
|
|
||||||
while (levelSelects.length > startIndex) {
|
|
||||||
const item = levelSelects.pop();
|
|
||||||
levelsContainer.removeChild(item.wrapper);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentPath = () => {
|
|
||||||
const parts = levelSelects
|
|
||||||
.map(({ select }) => select.value)
|
|
||||||
.filter((v) => v && v.trim() !== '');
|
|
||||||
return parts.join('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchChildren = async (parentPath = '') => {
|
|
||||||
const params = parentPath ? `?parent=${encodeURIComponent(parentPath)}` : '';
|
|
||||||
const response = await fetch(`{{ url_for("search_db_folders") }}${params}`);
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || 'Ordner konnten nicht geladen werden.');
|
|
||||||
}
|
|
||||||
return data.children || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateControlButtons = () => {
|
|
||||||
if (!levelSelects.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
levelSelects.forEach((item, idx) => {
|
|
||||||
const isLast = idx === levelSelects.length - 1;
|
|
||||||
if (item.btnGroup) {
|
|
||||||
item.btnGroup.classList.toggle('d-none', !isLast);
|
|
||||||
}
|
|
||||||
if (item.addBtn) {
|
|
||||||
item.addBtn.disabled = !item.select.value;
|
|
||||||
}
|
|
||||||
if (item.removeBtn) {
|
|
||||||
const hideRemove = !isLast || idx === 0;
|
|
||||||
item.removeBtn.classList.toggle('d-none', hideRemove);
|
|
||||||
item.removeBtn.disabled = hideRemove;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const initBaseLevel = async () => {
|
|
||||||
try {
|
|
||||||
const children = await fetchChildren('');
|
|
||||||
if (!children.length) {
|
|
||||||
feedback.textContent = 'Keine Ordner in der Datenbank gefunden.';
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createSelect(0, children);
|
|
||||||
updateControlButtons();
|
|
||||||
} catch (error) {
|
|
||||||
feedback.textContent = error.message;
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddLevel = async () => {
|
|
||||||
feedback.style.display = 'none';
|
|
||||||
const path = currentPath();
|
|
||||||
if (!path) {
|
|
||||||
feedback.textContent = 'Bitte zuerst einen Hauptordner auswählen.';
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const children = await fetchChildren(path);
|
|
||||||
if (!children.length) {
|
|
||||||
feedback.textContent = 'Keine weiteren Unterordner vorhanden.';
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createSelect(levelSelects.length, children);
|
|
||||||
updateControlButtons();
|
|
||||||
} catch (error) {
|
|
||||||
feedback.textContent = error.message;
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initBaseLevel();
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
feedback.style.display = 'none';
|
|
||||||
resultWrapper.style.display = 'none';
|
|
||||||
|
|
||||||
const folderPath = currentPath();
|
|
||||||
|
|
||||||
if (!folderPath) {
|
|
||||||
feedback.textContent = 'Bitte einen Ordner auswählen.';
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitButton = form.querySelector('button[type="submit"]');
|
|
||||||
submitButton.disabled = true;
|
|
||||||
submitButton.textContent = 'Wird geladen...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('{{ url_for("search_db_query") }}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ folder_path: folderPath })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || 'Abfrage fehlgeschlagen.');
|
|
||||||
}
|
|
||||||
|
|
||||||
resultBody.innerHTML = '';
|
|
||||||
if (data.categories && data.categories.length) {
|
|
||||||
data.categories.forEach((row) => {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
const cat = document.createElement('td');
|
|
||||||
const cnt = document.createElement('td');
|
|
||||||
|
|
||||||
cat.textContent = row.category || 'Keine Kategorie';
|
|
||||||
cnt.textContent = row.count;
|
|
||||||
|
|
||||||
tr.append(cat, cnt);
|
|
||||||
resultBody.appendChild(tr);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
const td = document.createElement('td');
|
|
||||||
td.colSpan = 2;
|
|
||||||
td.textContent = 'Keine Treffer gefunden.';
|
|
||||||
tr.appendChild(td);
|
|
||||||
resultBody.appendChild(tr);
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCount.textContent = `${data.total || 0} Dateien`;
|
|
||||||
resultWrapper.style.display = 'block';
|
|
||||||
} catch (error) {
|
|
||||||
feedback.textContent = error.message;
|
|
||||||
feedback.style.display = 'block';
|
|
||||||
} finally {
|
|
||||||
submitButton.disabled = false;
|
|
||||||
submitButton.textContent = 'Abfrage starten';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
{# page title #}
|
{# page title #}
|
||||||
{% block title %}Analyse Wiederholungen{% endblock %}
|
{% block title %}Analyse Wiederholungen{% endblock %}
|
||||||
|
{% block page_id %}songs_dashboard{% endblock %}
|
||||||
|
|
||||||
{# page content #}
|
{# page content #}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -157,4 +158,4 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user