Compare commits

..

2 Commits

12 changed files with 614 additions and 418 deletions

2
.gitignore vendored
View File

@ -10,7 +10,7 @@
/search.db /search.db
/access_log.db /access_log.db
/access_log.db.bak /access_log.db.bak
/folder_permission_config.json /folder_secret_config.json
/folder_mount_config.json /folder_mount_config.json
/.env /.env
/app_config.json /app_config.json

View File

@ -6,6 +6,7 @@ from auth import require_secret
from collections import defaultdict from collections import defaultdict
import json import json
import os import os
import auth
file_access_temp = [] file_access_temp = []
@ -189,11 +190,11 @@ def songs_dashboard():
performance_data = [(count, titel) for titel, count in performance_counts.items()] performance_data = [(count, titel) for titel, count in performance_counts.items()]
performance_data.sort(reverse=True, key=lambda x: x[0]) performance_data.sort(reverse=True, key=lambda x: x[0])
return render_template('songs_dashboard.html', timeframe=timeframe_param, performance_data=performance_data, site=site, category=category) return render_template('songs_dashboard.html', timeframe=timeframe_param, performance_data=performance_data, site=site, category=category, admin_enabled=auth.is_admin())
@require_secret @require_secret
def connections(): def connections():
return render_template('connections.html') return render_template('connections.html', admin_enabled=auth.is_admin())
@require_secret @require_secret
def dashboard(): def dashboard():
@ -476,7 +477,8 @@ def dashboard():
unique_files=unique_files, unique_files=unique_files,
unique_user=unique_user, unique_user=unique_user,
cached_percentage=cached_percentage, cached_percentage=cached_percentage,
timeframe_data=timeframe_data timeframe_data=timeframe_data,
admin_enabled=auth.is_admin()
) )
def export_to_excel(): def export_to_excel():

11
app.py
View File

@ -22,11 +22,12 @@ import base64
import search import search
import auth import auth
import analytics as a import analytics as a
import folder_secret_config_editor as fsce
with open("app_config.json", 'r') as file: with open("app_config.json", 'r') as file:
app_config = json.load(file) app_config = json.load(file)
with open('folder_permission_config.json') as file: with open('folder_secret_config.json') as file:
folder_config = json.load(file) folder_config = json.load(file)
cache_audio = diskcache.Cache('./filecache_audio', size_limit= app_config['filecache_size_limit_audio'] * 1024**3) cache_audio = diskcache.Cache('./filecache_audio', size_limit= app_config['filecache_size_limit_audio'] * 1024**3)
@ -53,6 +54,9 @@ app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POS
app.add_url_rule('/songs_dashboard', view_func=a.songs_dashboard) app.add_url_rule('/songs_dashboard', view_func=a.songs_dashboard)
app.add_url_rule('/admin/folder_secret_config_editor', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(fsce.folder_secret_config_data))
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
# Grab the HOST_RULE environment variable # Grab the HOST_RULE environment variable
host_rule = os.getenv("HOST_RULE", "") host_rule = os.getenv("HOST_RULE", "")
@ -548,14 +552,11 @@ def handle_request_initial_data():
def index(path): def index(path):
title_short = app_config.get('TITLE_SHORT', 'Default Title') title_short = app_config.get('TITLE_SHORT', 'Default Title')
title_long = app_config.get('TITLE_LONG' , 'Default Title') title_long = app_config.get('TITLE_LONG' , 'Default Title')
admin_enabled = False
if 'admin' in session:
admin_enabled = session['admin']
return render_template("app.html", return render_template("app.html",
title_short=title_short, title_short=title_short,
title_long=title_long, title_long=title_long,
admin_enabled=admin_enabled, admin_enabled=auth.is_admin()
) )
if __name__ == '__main__': if __name__ == '__main__':

23
auth.py
View File

@ -17,9 +17,24 @@ import hashlib
with open("app_config.json", 'r') as file: with open("app_config.json", 'r') as file:
app_config = json.load(file) app_config = json.load(file)
with open('folder_permission_config.json') as file: with open('folder_secret_config.json') as file:
folder_config = json.load(file) folder_config = json.load(file)
def is_admin():
"""
Check if the user is an admin based on the session.
"""
return session.get('admin', False)
def require_admin(f):
@wraps(f)
@require_secret
def decorated_function(*args, **kwargs):
if is_admin():
return f(*args, **kwargs)
else:
return "You don't have admin permission", 403
return decorated_function
def require_secret(f): def require_secret(f):
@wraps(f) @wraps(f)
@ -104,8 +119,10 @@ def require_secret(f):
if args_admin and config_admin: if args_admin and config_admin:
if args_admin == config_admin: if args_admin == config_admin:
print(f"Admin access granted with key: {args_admin}")
session['admin'] = True session['admin'] = True
else: else:
print(f"Admin access denied with key: {args_admin}")
session['admin'] = False session['admin'] = False
# 6) If we have folders, proceed; otherwise show index # 6) If we have folders, proceed; otherwise show index
@ -129,6 +146,7 @@ def require_secret(f):
header_text_color=header_text_color, header_text_color=header_text_color,
main_text_color=main_text_color, main_text_color=main_text_color,
background_color=background_color, background_color=background_color,
admin_enabled=is_admin()
) )
return decorated_function return decorated_function
@ -197,7 +215,8 @@ def mylinks():
token_qr_codes=token_qr_codes, token_qr_codes=token_qr_codes,
token_folders=token_folders, token_folders=token_folders,
token_url=token_url, token_url=token_url,
token_valid_to=token_valid_to token_valid_to=token_valid_to,
admin_enabled=is_admin()
) )

View File

@ -0,0 +1,63 @@
from flask import Flask, request, jsonify, render_template
import json
import os
from datetime import datetime
import secrets
import string
import auth
DATA_FILE = 'folder_secret_config.json'
# Secret alphabet
ALPHABET = string.ascii_letters + string.digits
@auth.require_admin
def load_data():
with open(DATA_FILE) as f:
try:
data = json.load(f)
print(f"Loaded {len(data)} records from {DATA_FILE}.")
return data
except:
print(f"Error loading {DATA_FILE}. File may be empty or corrupted.")
return []
@auth.require_admin
def save_data(data):
with open(DATA_FILE, 'w') as f:
json.dump(data, f, indent=4)
@auth.require_admin
def folder_secret_config_editor():
return render_template('folder_secret_config_editor.html', alphabet=ALPHABET, admin_enabled=auth.is_admin())
@auth.require_admin
def folder_secret_config_data():
return jsonify(load_data())
@auth.require_admin
def folder_secret_config_action():
p = request.get_json()
data = load_data()
action = p.get('action')
if action == 'delete':
data = [r for r in data if r['secret'] != p['secret']]
elif action == 'update':
old = p['oldSecret']; new = p['newSecret']
for i, r in enumerate(data):
if r['secret'] == old:
r['secret'] = new
r['validity'] = datetime.strptime(p['validity'], '%Y-%m-%d').strftime('%d.%m.%Y')
r['folders'] = p['folders']
break
else:
# append if not found
data.append({
'secret': new,
'validity': datetime.strptime(p['validity'], '%Y-%m-%d').strftime('%d.%m.%Y'),
'folders': p['folders']
})
save_data(data)
return jsonify(success=True)

View File

@ -55,6 +55,11 @@
</svg> </svg>
</button> </button>
</div> </div>
{% if admin_enabled %}
<div style="text-align: center; margin-top: 20px;">
<a href="{{ url_for('folder_secret_config_editor') }}" class="btn btn-secondary" id="edit-folder-config" style="color: #ccc; text-decoration: none;">admin</a>
</div>
{% endif %}
</div> </div>
<!-- Global Audio Player in Footer --> <!-- Global Audio Player in Footer -->

71
templates/base.html Normal file
View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Meine Links{% endblock %}</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<style>
/* common styles */
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.qr-code {
max-width: 300px;
display: block;
margin: auto;
}
.card {
margin-bottom: 1.5rem;
}
.locked { background-color:rgb(255, 255, 255); }
.unlocked { background-color: #fff3cd; }
</style>
{% block head_extra %}{% endblock %}
</head>
<body>
<script src="{{ url_for('static', filename='functions.js') }}"></script>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
{% if admin_enabled %}
<li class="nav-item"><a href="{{ url_for('folder_secret_config_editor') }}" class="nav-link">Edit Folder Config</a></li>
{% endif %}
</ul>
</div>
<a class="navbar-brand" href="#">
{% block nav_brand %}Übersicht deiner gültigen Links{% endblock %}
</a>
{% block nav_extra %}{% endblock %}
</div>
</nav>
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -1,83 +1,38 @@
<!DOCTYPE html> {# templates/connections.html #}
<html lang="en"> {% extends 'base.html' %}
<head>
<meta charset="UTF-8" /> {% block title %}Recent Connections{% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Recent Connections</title> {% block nav_brand %}Downloads der letzten 10 Minuten{% endblock %}
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" {% block nav_extra %}
rel="stylesheet" <span class="ms-3">
/> Anzahl Verbindungen: <strong id="totalConnections">0</strong>
</span>
{% endblock %}
{% block head_extra %}
<style> <style>
/* Basic layout */ /* page-specific styles */
html, .table-container { flex: 1; overflow-y: auto; }
body {
height: 100%;
margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.table-container {
flex: 1;
overflow-y: auto;
}
/* Animate the table body moving down via transform */
#connectionsTableBody { #connectionsTableBody {
transition: transform 0.5s ease-out; transition: transform 0.5s ease-out;
transform: translateY(0); transform: translateY(0);
} }
/* Advanced animation for new rows */
@keyframes slideIn { @keyframes slideIn {
0% { 0% { opacity: 0; transform: scale(0.8); }
opacity: 0; 100% { opacity: 1; transform: scale(1); }
transform: scale(0.8);
} }
100% { .slide-in { animation: slideIn 0.5s ease-out; }
opacity: 1;
transform: scale(1);
}
}
.slide-in {
animation: slideIn 0.5s ease-out;
}
/* Animation for disappearing rows */
@keyframes slideOut { @keyframes slideOut {
0% { 0% { opacity: 1; transform: scale(1); }
opacity: 1; 100% { opacity: 0; transform: scale(0.1); }
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.1);
}
}
.slide-out {
animation: slideOut 0.5s ease-in forwards;
} }
.slide-out { animation: slideOut 0.5s ease-in forwards; }
</style> </style>
</head> {% endblock %}
<body>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Downloads der letzten 10 Minuten</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
<span>Anzahl Verbindungen: <strong id="totalConnections">0</strong></span>
</div>
</nav>
{% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="table-responsive table-container"> <div class="table-responsive table-container">
<table class="table table-hover"> <table class="table table-hover">
@ -93,39 +48,32 @@
</tr> </tr>
</thead> </thead>
<tbody id="connectionsTableBody"> <tbody id="connectionsTableBody">
<!-- Rows are dynamically inserted here --> {# dynamically populated via Socket.IO #}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
{% endblock %}
<!-- Socket.IO client library --> {% block scripts %}
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
<script> <script>
const socket = io(); const socket = io();
socket.on("connect", () => socket.emit("request_initial_data"));
// Request initial data once connected.
socket.on("connect", () => {
socket.emit("request_initial_data");
});
// compute & write stats into the header
function updateStats(data) { function updateStats(data) {
const total = data.length; document.getElementById("totalConnections").textContent = data.length;
document.getElementById("totalConnections").textContent = total;
} }
// Helper: Create a table row. function createRow(record, animate=true) {
// When applyAnimation is true, the row uses the slide-in animation. const tr = document.createElement("tr");
function createRow(record, applyAnimation = true) { tr.dataset.timestamp = record.timestamp;
const row = document.createElement("tr"); if (animate) {
row.setAttribute("data-timestamp", record.timestamp); tr.classList.add("slide-in");
if (applyAnimation) { tr.addEventListener("animationend", () =>
row.classList.add("slide-in"); tr.classList.remove("slide-in"), { once: true });
row.addEventListener("animationend", () =>
row.classList.remove("slide-in"), { once: true });
} }
row.innerHTML = ` tr.innerHTML = `
<td>${record.timestamp}</td> <td>${record.timestamp}</td>
<td>${record.location}</td> <td>${record.location}</td>
<td>${record.user_agent}</td> <td>${record.user_agent}</td>
@ -134,71 +82,47 @@
<td>${record.mime_typ}</td> <td>${record.mime_typ}</td>
<td>${record.cached}</td> <td>${record.cached}</td>
`; `;
return row; return tr;
} }
// Update the table body with the new data.
// New rows animate in; existing rows simply update.
function updateTable(data) { function updateTable(data) {
const tbody = document.getElementById("connectionsTableBody"); const tbody = document.getElementById("connectionsTableBody");
// Map current rows by their timestamp. const existing = {};
const currentRows = {}; Array.from(tbody.children).forEach(r => existing[r.dataset.timestamp] = r);
Array.from(tbody.children).forEach(row => {
currentRows[row.getAttribute("data-timestamp")] = row; const frag = document.createDocumentFragment();
}); data.forEach(rec => {
const fragment = document.createDocumentFragment(); let row = existing[rec.timestamp];
data.forEach(record => { if (row) {
let row; row.innerHTML = createRow(rec, false).innerHTML;
if (currentRows[record.timestamp]) {
// Existing row: update content without new animation.
row = currentRows[record.timestamp];
row.innerHTML = `
<td>${record.timestamp}</td>
<td>${record.location}</td>
<td>${record.user_agent}</td>
<td>${record.full_path}</td>
<td>${record.filesize}</td>
<td>${record.mime_typ}</td>
<td>${record.cached}</td>
`;
row.classList.remove("slide-out"); row.classList.remove("slide-out");
} else { } else {
// New row: create with slide-in animation. row = createRow(rec, true);
row = createRow(record, true);
} }
fragment.appendChild(row); frag.appendChild(row);
}); });
tbody.innerHTML = ""; tbody.innerHTML = "";
tbody.appendChild(fragment); tbody.appendChild(frag);
} }
// Animate the table body moving down by the height of a new row before updating.
function animateTableWithNewRow(data) { function animateTableWithNewRow(data) {
const tbody = document.getElementById("connectionsTableBody"); const tbody = document.getElementById("connectionsTableBody");
// Identify any new records (by comparing timestamps). const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
const currentTimestamps = new Set( const newRecs = data.filter(r => !currentTs.has(r.timestamp));
Array.from(tbody.children).map(row => row.getAttribute("data-timestamp"))
);
const newRecords = data.filter(record => !currentTimestamps.has(record.timestamp));
if (newRecords.length > 0) { if (newRecs.length) {
// Create a temporary row to measure its height. const temp = createRow(newRecs[0], false);
const tempRow = createRow(newRecords[0], false); temp.style.visibility = "hidden";
tempRow.style.visibility = "hidden"; tbody.appendChild(temp);
tbody.appendChild(tempRow); const h = temp.getBoundingClientRect().height;
const newRowHeight = tempRow.getBoundingClientRect().height; temp.remove();
tempRow.remove();
// Animate the tbody moving down by the measured height. tbody.style.transform = `translateY(${h}px)`;
tbody.style.transform = `translateY(${newRowHeight}px)`;
// After the transition, update the table and reset the transform.
setTimeout(() => { setTimeout(() => {
updateTable(data); updateTable(data);
// Remove the transform instantly.
tbody.style.transition = "none"; tbody.style.transition = "none";
tbody.style.transform = "translateY(0)"; tbody.style.transform = "translateY(0)";
// Force reflow to apply the style immediately.
void tbody.offsetWidth; void tbody.offsetWidth;
tbody.style.transition = "transform 0.5s ease-out"; tbody.style.transition = "transform 0.5s ease-out";
}, 500); }, 500);
@ -207,12 +131,9 @@
} }
} }
// Listen for incoming connection data from the server. socket.on("recent_connections", data => {
socket.on("recent_connections", function(data) {
updateStats(data); updateStats(data);
animateTableWithNewRow(data); animateTableWithNewRow(data);
}); });
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"></script> {% endblock %}
</body>
</html>

View File

@ -1,42 +1,14 @@
<!DOCTYPE html> {# templates/dashboard.html #}
<html lang="en"> {% extends 'base.html' %}
<head>
<meta charset="UTF-8"> {# page title #}
<meta name="viewport" content="width=device-width, initial-scale=1"> {% block title %}Dashboard{% endblock %}
<title>Dashboard - Verbindungsanalyse</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"> {# override navbar text: #}
<style> {% block nav_brand %}Auswertung-Downloads{% endblock %}
html, body {
height: 100%; {# page content #}
margin: 0; {% block content %}
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.card {
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Dashboard - Verbindungsanalyse</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
</div>
</nav>
<!-- Main Container --> <!-- Main Container -->
<div class="container-fluid px-4"> <div class="container-fluid px-4">
@ -271,8 +243,9 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<!-- Scripts --> {% block scripts %}
<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="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script> <script>
@ -389,5 +362,4 @@
options: { responsive: true } options: { responsive: true }
}); });
</script> </script>
</body> {% endblock %}
</html>

View File

@ -0,0 +1,187 @@
{# templates/mylinks.html #}
{% extends 'base.html' %}
{# page title #}
{% block title %}Edit Folder Config{% endblock %}
{# override navbar text: #}
{% block nav_brand %}Folder Config Editor{% endblock %}
{# page content #}
{% block content %}
<div class="container">
<h1>Edit Folder Config</h1>
<div id="records"></div>
<button id="add-btn" class="btn btn-primary mt-3">Add New Record</button>
</div>
<!-- Confirmation Modal -->
<div class="modal fade" id="confirmDeleteModal" tabindex="-1" aria-labelledby="confirmDeleteLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmDeleteLabel">Confirm Deletion</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Do you really want to delete this record?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger confirm-delete">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<!-- jQuery and Bootstrap JS -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const ALPHABET = {{ alphabet|tojson }};
let data = [];
let editing = new Set();
let pendingDelete = null; // store secret pending confirmation
function loadData() {
$.getJSON('/admin/folder_secret_config_editor/data', res => { data = res; render(); });
}
function render() {
const $cont = $('#records').empty();
data.forEach(rec => {
const key = rec.secret;
const isEdit = editing.has(key);
const cls = isEdit ? 'unlocked' : 'locked';
let html = `<div class="card mb-3 ${cls}" data-secret="${key}"><div class="card-body">`;
html += `<h5>Record</h5>`;
html += `<div class="mb-2">Secret: <input class="form-control" type="text" value="${rec.secret}" ${isEdit?'':'readonly'} data-field="secret"></div>`;
html += `<div class="mb-2">Validity: <input class="form-control" type="date" value="${formatISO(rec.validity)}" ${isEdit?'':'readonly'} data-field="validity"></div>`;
html += `<h6>Folders</h6>`;
rec.folders.forEach((f,i) => {
html += `<div class="input-group mb-2">`;
html += `<input class="form-control" type="text" value="${f.foldername}" ${isEdit?'':'readonly'} data-field="foldername-${i}">`;
if(isEdit) html += `<button class="btn btn-outline-danger" onclick="removeFolder('${key}',${i})">Remove</button>`;
html += `</div>`;
html += `<input class="form-control mb-2" type="text" value="${f.folderpath}" ${isEdit?'':'readonly'} data-field="folderpath-${i}">`;
});
if(isEdit) html += `<button class="btn btn-sm btn-primary mb-2" onclick="addFolder('${key}')">Add Folder</button>`;
html += `<div>`;
// Change Delete button to a class + data-secret
html += `<button class="btn btn-danger btn-sm me-2 delete-btn" data-secret="${key}">Delete</button>`;
html += `<button class="btn btn-secondary btn-sm me-2" onclick="cloneRec('${key}')">Clone</button>`;
if(isEdit) html += `<button class="btn btn-success btn-sm" onclick="saveRec('${key}')">Save</button>`;
else html += `<button class="btn btn-warning btn-sm" onclick="editRec('${key}')">Edit</button>`;
html += `</div></div></div>`;
$cont.append(html);
});
}
function formatISO(d) {
const p = d.split('.');
return p.length===3 ? `${p[2]}-${p[1]}-${p[0]}` : '';
}
// original delete function, called after confirmation
function deleteRec(secret) {
$.ajax({
url: '/admin/folder_secret_config_editor/action',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ action: 'delete', secret })
}).always(loadData);
}
// hook delete buttons to show modal
$(document).on('click', '.delete-btn', function() {
pendingDelete = $(this).data('secret');
const modal = new bootstrap.Modal(document.getElementById('confirmDeleteModal'));
modal.show();
});
// when user confirms deletion
$('.confirm-delete').on('click', function() {
if (pendingDelete) {
deleteRec(pendingDelete);
pendingDelete = null;
}
const modalEl = document.getElementById('confirmDeleteModal');
const modal = bootstrap.Modal.getInstance(modalEl);
modal.hide();
});
function editRec(secret) { editing.add(secret); render(); }
function cloneRec(secret) {
const rec = data.find(r=>r.secret===secret);
const existing = data.map(r=>r.secret);
const clone = JSON.parse(JSON.stringify(rec));
clone.secret = generateSecret(existing);
data.splice(data.findIndex(r=>r.secret===secret)+1, 0, clone);
editing.add(clone.secret);
render();
}
function addFolder(secret) {
const rec = data.find(r=>r.secret===secret);
rec.folders.push({foldername:'',folderpath:''}); render();
}
function removeFolder(secret,i) {
const rec = data.find(r=>r.secret===secret);
rec.folders.splice(i,1); render();
}
function saveRec(secret) {
const rec = data.find(r=>r.secret===secret);
const $card = $(`[data-secret='${secret}']`);
const newSecret = $card.find('input[data-field=secret]').val();
const validity = $card.find('input[data-field=validity]').val();
const folders = rec.folders.map((_,i) => ({
foldername: $card.find(`[data-field=foldername-${i}]`).val(),
folderpath: $card.find(`[data-field=folderpath-${i}]`).val()
}));
if(!newSecret||data.some(r=>r.secret!==secret&&r.secret===newSecret)){
alert('Secret must be unique and non-empty');
return;
}
if(!validity){
alert('Validity required');
return;
}
if(folders.some(f=>!f.foldername||!f.folderpath)){
alert('Folder entries cannot be empty');
return;
}
$.ajax({
url:'/admin/folder_secret_config_editor/action',
method:'POST',
contentType:'application/json',
data: JSON.stringify({
action:'update',
oldSecret:secret,
newSecret,
validity,
folders
})
}).always(()=>{
editing.delete(secret);
loadData();
});
}
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;
}
$(document).ready(loadData);
$('#add-btn').click(() => {
const existing = data.map(r=>r.secret);
const newSecret = generateSecret(existing);
data.push({secret:newSecret, validity:new Date().toISOString().substr(0,10), folders:[]});
editing.add(newSecret);
render();
});
</script>
{% endblock %}

View File

@ -1,60 +1,37 @@
<!DOCTYPE html> {# templates/mylinks.html #}
<html lang="en"> {% extends 'base.html' %}
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Meine Links</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.qr-code {
max-width: 300px;
display: block;
margin: auto;
}
</style>
</head>
<body>
<script src="{{ url_for('static', filename='functions.js') }}"></script>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Übersicht deiner gültigen Links</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid"> {# page title #}
{% block title %}Meine Links{% endblock %}
{# override navbar text: #}
{#
{% block nav_brand %}
Übersicht deiner gültigen Links
{% endblock %}
#}
{# pagespecific content #}
{% block content %}
<div class="container-fluid">
<div class="row"> <div class="row">
{% if valid_secrets %} {% if valid_secrets %}
{% for secret in valid_secrets %} {% for secret in valid_secrets %}
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="data:image/png;base64,{{ secret_qr_codes[secret] }}" class="card-img-top qr-code p-3" alt="QR Code for secret"> <img src="data:image/png;base64,{{ secret_qr_codes[secret] }}"
class="card-img-top qr-code p-3"
alt="QR Code for secret">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Geheimnis: {{ secret }}</h5> <h5 class="card-title">Geheimnis: {{ secret }}</h5>
<p class="card-text"> <p class="card-text">
<small class="text-muted">Gültig bis: {{ secret_valid_to[secret] }}</small> <small class="text-muted">Gültig bis: {{ secret_valid_to[secret] }}</small>
</p> </p>
<a href="{{ secret_url[secret] }}" class="btn btn-secondary btn-sm">Link öffnen</a> <a href="{{ secret_url[secret] }}" class="btn btn-secondary btn-sm">Link öffnen</a>
<button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ secret_url[secret] }}')">Link kopieren</button> <button class="btn btn-secondary btn-sm"
onclick="toClipboard('{{ secret_url[secret] }}')">
Link kopieren
</button>
<br> <br>
<form method="post" action="{{ url_for('remove_secret') }}" class="mt-3"> <form method="post" action="{{ url_for('remove_secret') }}" class="mt-3">
<input type="hidden" name="secret" value="{{ secret }}"> <input type="hidden" name="secret" value="{{ secret }}">
@ -77,18 +54,24 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if valid_tokens %} {% if valid_tokens %}
{% for token in valid_tokens %} {% for token in valid_tokens %}
<div class="col-md-4 mb-4"> <div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="data:image/png;base64,{{ token_qr_codes[token] }}" class="card-img-top qr-code p-3" alt="QR Code for token"> <img src="data:image/png;base64,{{ token_qr_codes[token] }}"
class="card-img-top qr-code p-3"
alt="QR Code for token">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Token-Link:</h5> <h5 class="card-title">Token-Link:</h5>
<p class="card-text"> <p class="card-text">
<small class="text-muted">Gültig bis: {{ token_valid_to[token] }}</small> <small class="text-muted">Gültig bis: {{ token_valid_to[token] }}</small>
</p> </p>
<a href="{{ token_url[token] }}" class="btn btn-secondary btn-sm">Link öffnen</a> <a href="{{ token_url[token] }}" class="btn btn-secondary btn-sm">Link öffnen</a>
<button class="btn btn-secondary btn-sm" onclick="toClipboard('{{ token_url[token] }}')">Link kopieren</button> <button class="btn btn-secondary btn-sm"
onclick="toClipboard('{{ token_url[token] }}')">
Link kopieren
</button>
<br> <br>
<form method="post" action="{{ url_for('remove_token') }}" class="mt-3"> <form method="post" action="{{ url_for('remove_token') }}" class="mt-3">
<input type="hidden" name="token" value="{{ token }}"> <input type="hidden" name="token" value="{{ token }}">
@ -111,13 +94,12 @@
</div> </div>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if not valid_secrets and not valid_tokens %} {% if not valid_secrets and not valid_tokens %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
Du hast aktuell keine gültigen Links. Du hast aktuell keine gültigen Links.
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> {% endblock %}
</body>
</html>

View File

@ -1,42 +1,14 @@
<!DOCTYPE html> {# templates/songs_dashboard.html #}
<html lang="en"> {% extends 'base.html' %}
<head>
<meta charset="UTF-8"> {# page title #}
<meta name="viewport" content="width=device-width, initial-scale=1"> {% block title %}Edit Folder Config{% endblock %}
<title>Dashboard - Verbindungsanalyse</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"> {# override navbar text: #}
<style> {% block nav_brand %}Dashboard - Analyse Wiederholungen{% endblock %}
html, body {
height: 100%; {# page content #}
margin: 0; {% block content %}
padding: 0;
}
.container-fluid {
height: 100%;
display: flex;
flex-direction: column;
}
.card {
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<!-- Navigation Bar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-secondary mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="#">Dashboard - Analyse Wiederholungen</a>
<div class="navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">App</a></li>
<li class="nav-item"><a href="{{ url_for('mylinks') }}" class="nav-link">Meine Links</a></li>
<li class="nav-item"><a href="{{ url_for('connections') }}" class="nav-link">Verbindungen</a></li>
<li class="nav-item"><a href="{{ url_for('dashboard') }}" class="nav-link">Auswertung-Downloads</a></li>
<li class="nav-item"><a href="{{ url_for('songs_dashboard') }}" class="nav-link">Auswertung-Wiederholungen</a></li>
</ul>
</div>
</div>
</nav>
<!-- Main Container --> <!-- Main Container -->
<div class="container-fluid px-4"> <div class="container-fluid px-4">
@ -180,3 +152,4 @@
<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>
</body> </body>
</html> </html>
{% endblock %}