Initial commit

This commit is contained in:
root
2025-12-03 16:46:11 +00:00
commit 1612d10bb1
25 changed files with 145983 additions and 0 deletions

875
app/templates/admin.html Normal file

File diff suppressed because one or more lines are too long

22
app/templates/empty.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<title>Setup</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
</head>
<body>
<section class="hero is-fullheight is-info">
<div class="hero-body">
<div class="">
<p class="title">
Willkommen!
</p>
<p class="subtitle">
Dein Dashboard ist noch leer.
</p>
<a href="/admin" class="button is-white">Zum Admin-Bereich</a>
</div>
</div>
</section>
</body>
</html>

642
app/templates/index.html Normal file
View File

@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ data.settings.title }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--theme-color: {{ data.settings.theme_color }};
--header-font-color: {{ data.settings.header_font_color|default('#FFFFFF') }};
--header-inactive-font-color: {{ data.settings.header_inactive_font_color|default('#C0C0C0') }};
--group-title-font-color: {{ data.settings.group_title_font_color|default('#FFFFFF') }};
}
body {
background-color: #333; min-height: 100vh;
{% if data.settings.bg_image %}
background-image: url('/static/uploads/{{ data.settings.bg_image }}');
background-size: cover; background-position: center; background-attachment: fixed;
{% endif %}
}
.body-overlay { background-color: rgba(255, 255, 255, {{ data.settings.overlay_opacity }}); min-height: 100vh; }
.navbar.custom-header { background-color: {{ data.settings.header_color }}; box-shadow: 0 2px 10px rgba(0,0,0,0.3); }
.navbar.custom-header .navbar-item,
.navbar.custom-header strong {
color: var(--header-font-color);
}
.navbar.custom-header .navbar-item.has-text-grey-light {
color: var(--header-inactive-font-color);
}
.navbar.custom-header .navbar-start {
margin-left: 20px;
}
.logo-img { max-height: 2.5rem !important; margin-right: 15px; }
.navbar-item.is-active {
background-color: var(--theme-color) !important;
color: var(--header-font-color) !important;
padding: 5px 10px !important;
margin: 5px 0 !important;
}
a.navbar-item:hover { background-color: rgba(255,255,255,0.1); color: var(--header-font-color); }
.navbar-start .navbar-item {
min-width: 150px;
justify-content: center;
padding: 5px 10px !important;
margin: 5px 0 !important;
}
#search-dropdown .dropdown-menu {
right: 0;
left: auto;
min-width: 25rem;
}
.group-title {
color: var(--group-title-font-color);
text-shadow: 1px 1px 3px rgba(0,0,0,0.8);
margin-bottom: 1rem !important;
font-weight: 600;
display: flex;
align-items: center;
}
.container { /* Added by Gemini */
max-width: 80% !important;
width: 80% !important;
}
.group-column {
padding: 0.75rem !important; /* Reduced from 1.5rem to fit 3 columns */
margin-bottom: 1.5rem !important;
}
{% if data.settings.overlay_opacity|float > 0.6 %}
.group-title { color: #363636; text-shadow: none; }
{% endif %}
.card {
border-radius: 16px; /* More rounded */
border: 1px solid rgba(255, 255, 255, 0.25); /* Iced edge */
border-top: 1px solid rgba(255, 255, 255, 0.5); /* Highlight top edge */
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
background-color: rgba({{ data.settings.group_bg_color_rgb | default('255,255,255') }}, {{ data.settings.box_glassiness | default(0.5) }});
backdrop-filter: blur(15px); /* Stronger blur */
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.3s ease, box-shadow 0.3s ease; /* Smooth animation */
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
border-color: rgba(255, 255, 255, 0.4);
}
.card-content {
flex-grow: 1;
}
/* Widget Styles */
.widget {
margin-bottom: 1.5rem;
}
/* Apply glass effect to widget notifications too if they are used */
.widget .notification {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
backdrop-filter: blur(15px);
}
.widgets-sidebar {
padding: 0.75rem;
}
.link-item {
position: relative;
padding: 12px 15px; /* More breathing room */
border-bottom: 1px solid rgba(0,0,0,0.05); /* Subtler separator */
transition: background-color 0.2s;
display: flex; align-items: center; color: #4a4a4a;
height: 85px !important;
box-sizing: border-box;
overflow: hidden;
}
.link-item:hover {
background-color: rgba(255,255,255,0.4) !important; /* Lighter hover for glass feel */
color: var(--theme-color);
}
.link-item:last-child { border-bottom: none; }
.status-indicator {
position: absolute;
top: 10px;
right: 10px;
width: 10px;
height: 10px;
background-color: #ccc;
border-radius: 50%;
transition: background-color 0.3s;
}
.status-indicator.is-online {
background-color: #48c774;
}
.status-indicator.is-offline {
background-color: #f14668;
}
.icon-container {
width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
font-size: 1.8rem; margin-right: 15px; color: var(--theme-color); flex-shrink: 0;
}
.icon-container img { max-width: 100%; max-height: 100%; object-fit: contain; }
.link-content {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
}
.link-content p.title,
.link-content p.subtitle {
margin: 0;
}
.link-content p.title {
font-size: 1.1rem; /* Increased font size */
color: #363636;
font-weight: 600;
line-height: 1.3;
}
.link-content p.subtitle {
font-size: 0.9rem; /* Increased font size */
color: #888;
line-height: 1.3;
}
#clock {
font-size: 1.1rem; /* Increased font size */
color: var(--header-font-color); /* Ensure it uses the header font color */
}
</style>
<style>
.link-item .link-content p.subtitle {
margin-top: 0 !important;
}
</style>
</head>
<body>
<div class="body-overlay">
<nav class="navbar custom-header" role="navigation">
<div class="navbar-brand">
<a class="navbar-item" href="#">
{% if data.settings.logo_image %}
<img src="/static/uploads/{{ data.settings.logo_image }}" class="logo-img">
{% endif %}
<strong class="is-size-4 has-text-white">{{ data.settings.title }}</strong>
</a>
</div>
<div class="navbar-menu is-active" style="background: transparent;">
<div class="navbar-start">
{% if data.pages %}
{% for page in data.pages %}
<a class="navbar-item {% if loop.index0 == active_index %}is-active has-text-white{% else %}has-text-grey-light{% endif %}"
href="/?page={{ loop.index0 }}">
{% if page.icon %}
{% if '.' in page.icon or '/' in page.icon %}
<img src="{{ '/static/uploads/' + page.icon if not 'http' in page.icon else page.icon }}" style="width:20px; margin-right:8px;">
{% else %}
<i class="{{ page.icon }}" style="width:20px; margin-right:8px; text-align:center;"></i>
{% endif %}
{% endif %}
{{ page.name }}
</a>
{% endfor %}
{% endif %}
</div>
<div class="navbar-end">
<span class="navbar-item" id="clock"></span>
<div class="navbar-item">
<div class="dropdown" id="search-dropdown">
<div class="dropdown-trigger">
<div class="control has-icons-left">
<input class="input is-small" type="text" id="search-input" placeholder="Suchen..." aria-haspopup="true" aria-controls="search-results">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</div>
</div>
<div class="dropdown-menu" id="search-results" role="menu">
<div class="dropdown-content">
<!-- JS will populate this -->
</div>
</div>
</div>
</div>
<a class="navbar-item has-text-white" href="/admin"><i class="fas fa-cog"></i></a>
</div>
</div>
</nav>
<div class="container p-4">
{% if active_index == 0 %}
<div class="columns is-centered mb-5">
<div class="column is-half">
<form action="https://www.google.com/search" method="GET">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-medium" type="text" name="q" placeholder="Google Suche..." style="border-radius: 20px 0 0 20px; border: 1px solid rgba(255,255,255,0.3); border-right: none; box-shadow: 0 4px 15px rgba(0,0,0,0.1); background-color: rgba(255,255,255,0.7); backdrop-filter: blur(10px); color: #333;">
</div>
<div class="control">
<button class="button is-medium" type="submit" style="border-radius: 0 20px 20px 0; border: 1px solid rgba(255,255,255,0.3); border-left: none; box-shadow: 0 4px 15px rgba(0,0,0,0.1); background-color: var(--theme-color); color: white; opacity: 0.9;">
<span class="icon"><i class="fas fa-search"></i></span>
</button>
</div>
</div>
</form>
</div>
</div>
{% endif %}
{% if active_page %}
{# Dynamic Layout Logic #}
{% set has_widgets = active_page.widgets|length > 0 %}
{% set user_cols = data.settings.columns %}
{# Defaults (No Widgets) #}
{% set main_col_width = 'is-12' %}
{% set sidebar_width = '' %}
{% set item_col_width = user_cols %}
{% if has_widgets %}
{% if user_cols == 'is-one-third-desktop' %} {# 3 Cols -> 2 + 1 #}
{% set main_col_width = 'is-two-thirds' %} {# 66% #}
{% set sidebar_width = 'is-one-third' %} {# 33% #}
{% set item_col_width = 'is-half-desktop' %} {# 50% of 66% = 33% total #}
{% elif user_cols == 'is-one-quarter-desktop' %} {# 4 Cols -> 3 + 1 #}
{% set main_col_width = 'is-three-quarters' %} {# 75% #}
{% set sidebar_width = 'is-one-quarter' %} {# 25% #}
{% set item_col_width = 'is-one-third-desktop' %}{# 33% of 75% = 25% total #}
{% elif user_cols == 'is-one-fifth-desktop' %} {# 5 Cols -> 4 + 1 #}
{% set main_col_width = 'is-four-fifths' %} {# 80% #}
{% set sidebar_width = 'is-one-fifth' %} {# 20% #}
{% set item_col_width = 'is-one-quarter-desktop' %}{# 25% of 80% = 20% total #}
{% endif %}
{% endif %}
<div class="columns is-variable is-4"> <!-- Main Grid -->
<!-- Link Groups Column -->
<div class="column {{ main_col_width }}">
<div class="columns is-multiline is-variable is-4">
{% for group in active_page.groups %}
<div class="column {{ item_col_width }} is-full-mobile group-column">
<!-- Remove height:100% to allow auto-height based on content -->
<div style="display: flex; flex-direction: column;">
<h3 class="title is-5 group-title">
{% if group.icon %}
{% if '.' in group.icon or '/' in group.icon %}
<img src="{{ '/static/uploads/' + group.icon if not 'http' in group.icon else group.icon }}" style="height:24px; margin-right:10px;">
{% else %}
<i class="{{ group.icon }}" style="margin-right:10px;"></i>
{% endif %}
{% endif %}
{{ group.name }}
</h3>
<div class="card" style="flex-grow: 1;">
<div class="card-content p-0">
{% for link in group.links %}
<a href="{{ link.url }}" target="{{ link.target|default('_self') }}" class="link-item">
<span class="status-indicator"></span>
<div class="icon-container">
{% if '.' in link.icon or '/' in link.icon %}
<img src="{{ '/static/uploads/' + link.icon if not 'http' in link.icon else link.icon }}">
{% else %}
<i class="{{ link.icon }}"></i>
{% endif %}
</div>
<div class="link-content">
<p class="title">{{ link.name }}</p>
{% if link.subtitle %}
<p class="subtitle">{{ link.subtitle }}</p>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Sidebar Widgets Section -->
{% if has_widgets %}
<div class="column {{ sidebar_width }}">
<div style="margin-top: 2.5rem;"> <!-- Align with cards (offsetting the title) -->
{% for widget in active_page.widgets %}
<div class="widget mb-5" data-widget-id="{{ widget.id }}" data-widget-type="{{ widget.type }}">
<div class="notification is-light">
Lade Widget...
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Search functionality
const searchInput = document.getElementById('search-input');
const searchDropdown = document.getElementById('search-dropdown');
const searchResultsContainer = document.querySelector('#search-results .dropdown-content');
if (searchInput && searchDropdown && searchResultsContainer) {
searchInput.addEventListener('keyup', async function(event) {
const searchTerm = event.target.value.toLowerCase();
if (searchTerm.length < 2) {
searchDropdown.classList.remove('is-active');
return;
}
const response = await fetch(`/search?q=${encodeURIComponent(searchTerm)}`);
const results = await response.json();
searchResultsContainer.innerHTML = '';
if (results.length > 0) {
results.forEach(result => {
const resultElement = document.createElement('a');
resultElement.href = result.url;
resultElement.target = '_blank';
resultElement.classList.add('dropdown-item');
let iconHtml = '';
if (result.icon) {
if (result.icon.includes('.') || result.icon.includes('/')) {
const src = result.icon.startsWith('http') ? result.icon : `/static/uploads/${result.icon}`;
iconHtml = `<img src="${src}" style="height:24px; width: 24px; margin-right:10px; object-fit: contain;">`;
} else {
iconHtml = `<i class="${result.icon}" style="width: 24px; margin-right:10px; text-align: center;"></i>`;
}
} else {
iconHtml = `<span style="height:24px; width: 24px; margin-right:10px;"></span>`;
}
resultElement.innerHTML = `
<div style="display: flex; align-items: center;">
${iconHtml}
<div>
<p style="font-weight: 600;">${result.name}</p>
<p style="font-size: 0.8rem; color: #888;">${result.subtitle || ''}</p>
<p style="font-size: 0.7rem; color: #aaa;">Seite: ${result.page_name} / Gruppe: ${result.group_name}</p>
</div>
</div>
`;
searchResultsContainer.appendChild(resultElement);
});
} else {
const noResult = document.createElement('div');
noResult.classList.add('dropdown-item');
noResult.textContent = 'Keine Ergebnisse gefunden.';
searchResultsContainer.appendChild(noResult);
}
searchDropdown.classList.add('is-active');
});
document.addEventListener('click', function(event) {
if (!searchDropdown.contains(event.target)) {
searchDropdown.classList.remove('is-active');
}
});
}
// Link Status Checker
const allLinks = document.querySelectorAll('.link-item');
allLinks.forEach(link => {
const url = link.href;
const indicator = link.querySelector('.status-indicator');
if (!indicator) return;
if (!url || !url.startsWith('http')) {
indicator.style.display = 'none';
return;
}
fetch(`/check_url?url=${encodeURIComponent(url)}`)
.then(response => response.json())
.then(data => {
if (data.status === 'online') {
indicator.classList.add('is-online');
} else {
indicator.classList.add('is-offline');
}
})
.catch(error => {
indicator.classList.add('is-offline');
});
});
// Clock
const clockElement = document.getElementById('clock');
function updateClock() {
if (clockElement) {
const now = new Date();
clockElement.textContent = now.toLocaleTimeString('de-DE');
}
}
setInterval(updateClock, 1000);
updateClock(); // initial call
// --- Widget Rendering ---
const renderPiholeStatus = (widgetElement, data) => {
if (data.error) {
widgetElement.innerHTML = `<div class="notification is-danger"><strong>Pi-hole Error:</strong> ${data.error}</div>`;
return;
}
const statusClass = data.status === 'enabled' ? 'is-success' : 'is-warning';
widgetElement.innerHTML = `
<div class="card">
<header class="card-header">
<p class="card-header-title">
<span class="icon"><i class="fas fa-chart-bar"></i></span>
Pi-hole Status
</p>
</header>
<div class="card-content">
<div class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="heading">Status</p>
<p class="title is-4"><span class="tag ${statusClass} is-medium">${data.status}</span></p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Blocked</p>
<p class="title is-4">${parseFloat(data.ads_percentage_today || 0).toLocaleString('de-DE')}%</p>
</div>
</div>
</div>
<div class="level is-mobile">
<div class="level-item has-text-centered">
<div>
<p class="heading">Queries</p>
<p class="title is-5">${parseInt(data.dns_queries_today || 0).toLocaleString('de-DE')}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading">Blocklist</p>
<p class="title is-5">${parseInt(data.domains_being_blocked || 0).toLocaleString('de-DE')}</p>
</div>
</div>
</div>
</div>
</div>
`;
};
const renderOpenMeteoWeather = (widgetElement, data) => {
if (data.error) {
widgetElement.innerHTML = `<div class="notification is-danger"><strong>Weather Error:</strong> ${data.error}</div>`;
return;
}
const current = data.current || {};
const daily = data.daily || {};
const units = data.units || {};
const temp = current.temperature_2m;
const wind = current.wind_speed_10m;
const code = current.weather_code;
// Simple icon mapping (very basic)
let iconClass = 'fa-sun';
if (code > 3) iconClass = 'fa-cloud-sun';
if (code > 45) iconClass = 'fa-cloud-rain';
if (code > 70) iconClass = 'fa-snowflake';
if (code > 95) iconClass = 'fa-bolt';
const sunrise = daily.sunrise && daily.sunrise[0] ? daily.sunrise[0].split('T')[1] : '--:--';
const sunset = daily.sunset && daily.sunset[0] ? daily.sunset[0].split('T')[1] : '--:--';
widgetElement.innerHTML = `
<div class="card"> <!-- Use standard glass card -->
<div class="card-content">
<div class="level is-mobile">
<div class="level-left">
<div>
<p class="heading" style="color: #888;">Wetter</p>
<p class="title is-3" style="color: var(--theme-color);">${temp} ${units.temperature_2m}</p>
<p class="subtitle is-6" style="color: #666;"><i class="fas fa-wind mr-1"></i> ${wind} ${units.wind_speed_10m}</p>
</div>
</div>
<div class="level-right has-text-centered">
<div>
<i class="fas ${iconClass} fa-3x" style="color: var(--theme-color); opacity: 0.8;"></i>
</div>
</div>
</div>
<div class="level is-mobile mt-2" style="border-top: 1px solid rgba(0,0,0,0.05); padding-top: 10px;">
<div class="level-item has-text-centered">
<div>
<p class="heading" style="color: #aaa; margin-bottom: 0;">Aufgang</p>
<p class="is-size-6" style="color: #555;"><i class="fas fa-sun mr-1" style="color: #FFD700;"></i> ${sunrise}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="heading" style="color: #aaa; margin-bottom: 0;">Untergang</p>
<p class="is-size-6" style="color: #555;"><i class="fas fa-moon mr-1" style="color: #666;"></i> ${sunset}</p>
</div>
</div>
</div>
</div>
</div>
`;
};
const renderIframeWidget = (widgetElement, data) => {
const height = data.height || '300';
widgetElement.innerHTML = `
<div class="card" style="overflow: hidden;">
<iframe src="${data.url}" style="width: 100%; height: ${height}px; border: none;"></iframe>
</div>
`;
};
const WIDGET_RENDERERS = {
'pihole_status': renderPiholeStatus,
'openmeteo_weather': renderOpenMeteoWeather,
'iframe_widget': renderIframeWidget
};
// Fetch and render widgets
document.querySelectorAll('.widget').forEach(widgetElement => {
const widgetId = widgetElement.dataset.widgetId;
const widgetType = widgetElement.dataset.widgetType;
const renderer = WIDGET_RENDERERS[widgetType];
if (!widgetId || !renderer) {
widgetElement.innerHTML = '<div class="notification is-danger">Widget-Konfigurationsfehler.</div>';
return;
}
fetch(`/api/widget_data/${widgetId}`)
.then(response => {
// Always parse the JSON to get access to the body, regardless of status
return response.json().then(data => {
if (!response.ok) {
// If response is not ok, pass the JSON data to the catch block
return Promise.reject(data);
}
// If response is ok, pass the JSON data to the next .then()
return data;
});
})
.then(data => {
// This is the success handler
renderer(widgetElement, data);
})
.catch(errorData => {
// errorData is now the JSON object from the server
console.error('Error fetching widget data:', errorData);
let errorMessage = 'Ein unbekannter Fehler ist aufgetreten.';
let debugInfo = '';
if (errorData && errorData.error) {
errorMessage = errorData.error;
}
if (errorData && errorData.debug) {
debugInfo = `<pre style="white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; margin-top: 10px; background-color: #f5f5f5; padding: 5px; color: #333; font-size: 0.75rem;">${errorData.debug.join('\n')}</pre>`;
}
widgetElement.innerHTML = `<div class="notification is-danger"><strong>Fehler:</strong> ${errorMessage}${debugInfo}</div>`;
});
});
});
</script>
</body>
</html>

52
app/templates/login.html Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
</head>
<body>
<section class="section">
<div class="container">
<div class="columns is-centered">
<div class="column is-half">
<h1 class="title">Admin Login</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="notification is-{{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('login', next=request.args.get('next')) }}">
<div class="field">
<label class="label">Benutzername</label>
<div class="control">
<input class="input" type="text" name="username" required>
</div>
</div>
<div class="field">
<label class="label">Passwort</label>
<div class="control">
<input class="input" type="password" name="password" required>
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-primary" type="submit">Login</button>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
</body>
</html>

23
app/templates/media.html Normal file
View File

@@ -0,0 +1,23 @@
<tbody id="mediaTableBody">
{% for file in files %}
<tr>
<td style="width: 50px;">
{% if file.type == 'Icon' or file.type == 'Hintergrund' or file.type == 'Logo' %}
<img src="/static/uploads/{{ file.name }}" style="max-height: 30px;">
{% else %}
<i class="fas fa-file"></i>
{% endif %}
</td>
<td>{{ file.name }}</td>
<td>{{ file.size }}</td>
<td>{{ file.type }}</td>
<td>
<a href="/admin/delete_media/{{ file.name }}" class="button is-danger is-small" onclick="return confirm('Sicher?')">Löschen</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="has-text-centered">Keine Dateien vorhanden.</td>
</tr>
{% endfor %}
</tbody>