Initial commit
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# Geben Sie hier Ihre Anmeldedaten für den Admin-Bereich ein
|
||||
# Ändern Sie diese Werte unbedingt in etwas Sicheres!
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=password
|
||||
|
||||
# Ändern Sie diesen Schlüssel in eine lange, zufällige Zeichenfolge.
|
||||
# Sie können zum Beispiel 'openssl rand -hex 32' in Ihrem Terminal ausführen, um einen zu generieren.
|
||||
SECRET_KEY=super_secret_key_change_me
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
21
app/Dockerfile
Normal file
21
app/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Installation der Bildverarbeitungs-Abhängigkeiten (Wichtig für Pillow)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
libfreetype6-dev \
|
||||
liblcms2-dev \
|
||||
libwebp-dev \
|
||||
tcl-dev \
|
||||
tk-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
843
app/app.py
Normal file
843
app/app.py
Normal file
@@ -0,0 +1,843 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, session, flash
|
||||
from urllib.parse import urljoin
|
||||
import requests
|
||||
import urllib3
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import os
|
||||
import uuid
|
||||
from werkzeug.utils import secure_filename
|
||||
from PIL import Image
|
||||
from functools import wraps
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import func
|
||||
|
||||
# Disable SSL warnings for local services
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
app = Flask(__name__)
|
||||
UPLOAD_FOLDER = '/app/static/uploads'
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
|
||||
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:////data/dashboard.db'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.secret_key = os.environ.get('SECRET_KEY', 'a-secure-default-secret-key-for-development-only')
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
# --- Caching for Status Checks ---
|
||||
URL_CACHE = {}
|
||||
CACHE_DURATION = 300 # 5 minutes
|
||||
|
||||
# --- Custom Filters ---
|
||||
@app.template_filter('json_loads')
|
||||
def json_loads_filter(s):
|
||||
if not s: return {}
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# --- Database Models ---
|
||||
|
||||
class Setting(db.Model):
|
||||
key = db.Column(db.String(50), primary_key=True)
|
||||
value = db.Column(db.String(255))
|
||||
|
||||
class Page(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
icon = db.Column(db.String(255))
|
||||
order = db.Column(db.Integer, default=0)
|
||||
groups = db.relationship('Group', backref='page', lazy=True, cascade="all, delete-orphan", order_by="Group.order")
|
||||
widgets = db.relationship('Widget', backref='page', lazy=True, cascade="all, delete-orphan", order_by="Widget.order")
|
||||
|
||||
class Group(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
icon = db.Column(db.String(255))
|
||||
order = db.Column(db.Integer, default=0)
|
||||
page_id = db.Column(db.Integer, db.ForeignKey('page.id'), nullable=False)
|
||||
links = db.relationship('Link', backref='group', lazy=True, cascade="all, delete-orphan", order_by="Link.order")
|
||||
|
||||
class Link(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
url = db.Column(db.String(255), nullable=False)
|
||||
icon = db.Column(db.String(255))
|
||||
subtitle = db.Column(db.String(255))
|
||||
target = db.Column(db.String(20), default='_self')
|
||||
order = db.Column(db.Integer, default=0)
|
||||
group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=False)
|
||||
|
||||
class Widget(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
type = db.Column(db.String(50), nullable=False) # e.g., 'pihole_status', 'openmeteo_weather', 'iframe_widget'
|
||||
config = db.Column(db.Text, nullable=True) # JSON-formatted string
|
||||
order = db.Column(db.Integer, default=0)
|
||||
page_id = db.Column(db.Integer, db.ForeignKey('page.id'), nullable=False) # Changed from group_id to page_id
|
||||
|
||||
def migrate_db():
|
||||
from sqlalchemy import text
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
try:
|
||||
with db.engine.connect() as conn:
|
||||
# Check Page order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM page LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Page")
|
||||
conn.execute(text("ALTER TABLE page ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
# Check Group order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM \"group\" LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Group")
|
||||
conn.execute(text("ALTER TABLE \"group\" ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
# Check Link order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM link LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Link")
|
||||
conn.execute(text("ALTER TABLE link ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
# Check Widget page_id migration and Cleanup group_id
|
||||
try:
|
||||
# Check if group_id still exists by trying to select it
|
||||
conn.execute(text("SELECT group_id FROM widget LIMIT 1"))
|
||||
print("Migrating DB: Removing group_id from Widget (Recreating Table)")
|
||||
|
||||
# 1. Create new table
|
||||
conn.execute(text("""
|
||||
CREATE TABLE widget_new (
|
||||
id INTEGER NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
config TEXT,
|
||||
"order" INTEGER,
|
||||
page_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY(page_id) REFERENCES page (id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# 2. Copy data
|
||||
# If page_id was already populated by previous migration, use it.
|
||||
# If not (failed partial migration), try to derive from group.
|
||||
# We try to copy page_id first.
|
||||
try:
|
||||
conn.execute(text("INSERT INTO widget_new (id, type, config, \"order\", page_id) SELECT id, type, config, \"order\", page_id FROM widget WHERE page_id IS NOT NULL"))
|
||||
except Exception:
|
||||
# Fallback: Calculate page_id from group_id link if page_id was missing/null
|
||||
conn.execute(text("INSERT INTO widget_new (id, type, config, \"order\", page_id) SELECT w.id, w.type, w.config, w.\"order\", g.page_id FROM widget w JOIN \"group\" g ON w.group_id = g.id"))
|
||||
|
||||
# 3. Drop old table
|
||||
conn.execute(text("DROP TABLE widget"))
|
||||
|
||||
# 4. Rename new table
|
||||
conn.execute(text("ALTER TABLE widget_new RENAME TO widget"))
|
||||
|
||||
conn.commit()
|
||||
print("Widget table migrated and cleaned successfully.")
|
||||
|
||||
except Exception as e:
|
||||
# If group_id doesn't exist, we assume table is already clean or check logic is different
|
||||
# But we should ensure page_id exists
|
||||
try:
|
||||
conn.execute(text("SELECT page_id FROM widget LIMIT 1"))
|
||||
except:
|
||||
print(f"Deep migration error: {e}")
|
||||
|
||||
# Check Widget order
|
||||
try:
|
||||
conn.execute(text("SELECT \"order\" FROM widget LIMIT 1"))
|
||||
except Exception:
|
||||
print("Migrating DB: Adding order column to Widget")
|
||||
conn.execute(text("ALTER TABLE widget ADD COLUMN \"order\" INTEGER DEFAULT 0"))
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration general warning: {e}")
|
||||
|
||||
migrate_db()
|
||||
|
||||
|
||||
# --- Authentication ---
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not session.get('logged_in'):
|
||||
return redirect(url_for('login', next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
if username == os.environ.get('ADMIN_USERNAME') and password == os.environ.get('ADMIN_PASSWORD'):
|
||||
session['logged_in'] = True
|
||||
flash('Login erfolgreich!', 'success')
|
||||
next_url = request.args.get('next')
|
||||
return redirect(next_url or url_for('admin'))
|
||||
else:
|
||||
flash('Falscher Benutzername oder Passwort.', 'danger')
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.pop('logged_in', None)
|
||||
flash('Du wurdest ausgeloggt.', 'info')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
# --- Helper & Image Processing ---
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def save_and_scale_icon(request_file):
|
||||
if request_file and request_file.filename != '' and allowed_file(request_file.filename):
|
||||
try:
|
||||
unique_filename = f"icon_{uuid.uuid4().hex}.png"
|
||||
temp_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
|
||||
img = Image.open(request_file.stream)
|
||||
img = img.resize((48, 48), Image.Resampling.LANCZOS)
|
||||
if img.mode != 'RGBA': img = img.convert('RGBA')
|
||||
img.save(temp_path, 'PNG')
|
||||
return unique_filename
|
||||
except Exception as e:
|
||||
print(f"Fehler bei der Bildverarbeitung: {e}")
|
||||
return None
|
||||
return None
|
||||
|
||||
def save_background_or_logo(request_file, prefix):
|
||||
if request_file and request_file.filename != '' and allowed_file(request_file.filename):
|
||||
try:
|
||||
filename = secure_filename(f"{prefix}_{request_file.filename}")
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
request_file.save(filepath)
|
||||
return filename
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Speichern: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# --- Database Helper ---
|
||||
|
||||
def get_all_data():
|
||||
settings_query = Setting.query.all()
|
||||
settings = {s.key: s.value for s in settings_query}
|
||||
|
||||
defaults = default_settings()
|
||||
for key, value in defaults.items():
|
||||
if key not in settings:
|
||||
settings[key] = value
|
||||
|
||||
pages = Page.query.order_by(Page.order).all()
|
||||
|
||||
return {
|
||||
"settings": settings,
|
||||
"pages": pages
|
||||
}
|
||||
|
||||
def default_settings():
|
||||
return {
|
||||
"title": "Mein Dashboard",
|
||||
"bg_image": "",
|
||||
"logo_image": "",
|
||||
"header_color": "#363636",
|
||||
"header_font_color": "#FFFFFF",
|
||||
"header_inactive_font_color": "#C0C0C0",
|
||||
"group_title_font_color": "#FFFFFF",
|
||||
"columns": "is-one-quarter-desktop",
|
||||
"theme_color": "#3273dc",
|
||||
"overlay_opacity": "0.85"
|
||||
}
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
def hex_to_rgb(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
return ",".join(str(int(hex_color[i:i+2], 16)) for i in (0, 2, 4))
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
data = get_all_data()
|
||||
if not data['pages']:
|
||||
return render_template('empty.html')
|
||||
|
||||
active_page_index = request.args.get('page', 0, type=int)
|
||||
if active_page_index >= len(data['pages']):
|
||||
active_page_index = 0
|
||||
|
||||
active_page = data['pages'][active_page_index]
|
||||
|
||||
if 'group_bg_color' in data['settings']:
|
||||
data['settings']['group_bg_color_rgb'] = hex_to_rgb(data['settings']['group_bg_color'])
|
||||
|
||||
return render_template('index.html', data=data, active_page=active_page, active_index=active_page_index)
|
||||
|
||||
@app.route('/admin')
|
||||
@login_required
|
||||
def admin():
|
||||
data = get_all_data()
|
||||
return render_template('admin.html', data=data)
|
||||
|
||||
@app.route('/admin/local_icons')
|
||||
@login_required
|
||||
def local_icons():
|
||||
return jsonify([f for f in os.listdir(UPLOAD_FOLDER) if f.startswith('icon_') or f.startswith('upload_') or f.startswith('link_')])
|
||||
|
||||
@app.route('/admin/media_manager')
|
||||
@login_required
|
||||
def media_manager():
|
||||
files = []
|
||||
for f in os.listdir(UPLOAD_FOLDER):
|
||||
filepath = os.path.join(UPLOAD_FOLDER, f)
|
||||
try:
|
||||
if os.path.isfile(filepath):
|
||||
files.append({
|
||||
'name': f,
|
||||
'size': f"{os.path.getsize(filepath) / 1024:.2f} KB",
|
||||
'type': 'Icon' if f.startswith('icon_') else 'Hintergrund' if f.startswith('bg_') else 'Logo' if f.startswith('logo_') else 'Unbekannt'
|
||||
})
|
||||
except Exception as e:
|
||||
continue
|
||||
return render_template('media.html', files=files)
|
||||
|
||||
def _update_setting(key, value):
|
||||
setting = Setting.query.filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = value
|
||||
else:
|
||||
db.session.add(Setting(key=key, value=value))
|
||||
|
||||
@app.route('/admin/update_settings', methods=['POST'])
|
||||
@login_required
|
||||
def update_settings():
|
||||
for key, value in request.form.items():
|
||||
_update_setting(key, value)
|
||||
|
||||
bg_filename = save_background_or_logo(request.files.get('bg_file'), 'bg')
|
||||
if bg_filename:
|
||||
_update_setting('bg_image', bg_filename)
|
||||
|
||||
logo_filename = save_background_or_logo(request.files.get('logo_file'), 'logo')
|
||||
if logo_filename:
|
||||
_update_setting('logo_image', logo_filename)
|
||||
|
||||
_update_setting('settings_configured', 'true')
|
||||
|
||||
db.session.commit()
|
||||
flash('Einstellungen gespeichert!', 'success')
|
||||
return redirect(url_for('admin', tab='settings'))
|
||||
|
||||
@app.route('/admin/delete_media/<string:filename>')
|
||||
@login_required
|
||||
def delete_media(filename):
|
||||
if '..' in filename or os.path.sep in filename:
|
||||
return "Ungültiger Dateiname", 400
|
||||
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Check if this file was used as the background or logo
|
||||
bg_setting = Setting.query.filter_by(key='bg_image', value=filename).first()
|
||||
if bg_setting:
|
||||
bg_setting.value = ""
|
||||
|
||||
logo_setting = Setting.query.filter_by(key='logo_image', value=filename).first()
|
||||
if logo_setting:
|
||||
logo_setting.value = ""
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Datei {filename} gelöscht.', 'info')
|
||||
return redirect(url_for('media_manager'))
|
||||
|
||||
# --- CRUD Routes (Pages, Groups, Links, Widgets) ---
|
||||
|
||||
@app.route('/admin/add_page', methods=['POST'])
|
||||
@login_required
|
||||
def add_page():
|
||||
name = request.form.get('name')
|
||||
if name:
|
||||
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
|
||||
new_page = Page(name=name, icon=icon_val)
|
||||
db.session.add(new_page)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_page/<int:page_id>')
|
||||
@login_required
|
||||
def delete_page(page_id):
|
||||
page = Page.query.get_or_404(page_id)
|
||||
db.session.delete(page)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/add_group/<int:page_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_group(page_id):
|
||||
page = Page.query.get_or_404(page_id)
|
||||
name = request.form.get('name')
|
||||
if name:
|
||||
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
|
||||
new_group = Group(name=name, icon=icon_val, page_id=page.id)
|
||||
db.session.add(new_group)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_group/<int:group_id>')
|
||||
@login_required
|
||||
def delete_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
db.session.delete(group)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/add_link/<int:group_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_link(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
name = request.form.get('name')
|
||||
url = request.form.get('url')
|
||||
if name and url:
|
||||
icon_val = save_and_scale_icon(request.files.get('icon_file')) or request.form.get('icon_text', '')
|
||||
new_link = Link(
|
||||
name=name,
|
||||
url=url,
|
||||
icon=icon_val,
|
||||
subtitle=request.form.get('subtitle'),
|
||||
target=request.form.get('target', '_blank'),
|
||||
group_id=group.id
|
||||
)
|
||||
db.session.add(new_link)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_link/<int:link_id>')
|
||||
@login_required
|
||||
def delete_link(link_id):
|
||||
link = Link.query.get_or_404(link_id)
|
||||
db.session.delete(link)
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/add_widget/<int:page_id>', methods=['POST'])
|
||||
@login_required
|
||||
def add_widget(page_id):
|
||||
widget_type = request.form.get('type')
|
||||
if not widget_type:
|
||||
flash('Widget-Typ nicht angegeben.', 'danger')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
config = {}
|
||||
if widget_type == 'pihole_status':
|
||||
config = {
|
||||
'api_url': request.form.get('pihole_api_url'),
|
||||
'api_key': request.form.get('pihole_api_key')
|
||||
}
|
||||
elif widget_type == 'openmeteo_weather':
|
||||
config = {
|
||||
'latitude': request.form.get('weather_lat'),
|
||||
'longitude': request.form.get('weather_lon')
|
||||
}
|
||||
elif widget_type == 'iframe_widget':
|
||||
config = {
|
||||
'url': request.form.get('iframe_url'),
|
||||
'height': request.form.get('iframe_height')
|
||||
}
|
||||
|
||||
new_widget = Widget(
|
||||
page_id=page_id,
|
||||
type=widget_type,
|
||||
config=json.dumps(config)
|
||||
)
|
||||
db.session.add(new_widget)
|
||||
db.session.commit()
|
||||
flash('Widget erfolgreich hinzugefügt!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_widget/<int:widget_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_widget(widget_id):
|
||||
widget = Widget.query.get_or_404(widget_id)
|
||||
|
||||
config = {}
|
||||
if widget.type == 'pihole_status':
|
||||
config = {
|
||||
'api_url': request.form.get('pihole_api_url'),
|
||||
'api_key': request.form.get('pihole_api_key')
|
||||
}
|
||||
elif widget.type == 'openmeteo_weather':
|
||||
config = {
|
||||
'latitude': request.form.get('weather_lat'),
|
||||
'longitude': request.form.get('weather_lon')
|
||||
}
|
||||
elif widget.type == 'iframe_widget':
|
||||
config = {
|
||||
'url': request.form.get('iframe_url'),
|
||||
'height': request.form.get('iframe_height')
|
||||
}
|
||||
|
||||
widget.config = json.dumps(config)
|
||||
db.session.commit()
|
||||
flash('Widget erfolgreich aktualisiert!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/delete_widget/<int:widget_id>')
|
||||
@login_required
|
||||
def delete_widget(widget_id):
|
||||
widget = Widget.query.get_or_404(widget_id)
|
||||
db.session.delete(widget)
|
||||
db.session.commit()
|
||||
flash('Widget gelöscht.', 'info')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_link/<int:link_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_link(link_id):
|
||||
link = Link.query.get_or_404(link_id)
|
||||
link.name = request.form.get('name')
|
||||
link.url = request.form.get('url')
|
||||
link.subtitle = request.form.get('subtitle')
|
||||
link.target = request.form.get('target')
|
||||
|
||||
new_group_id = request.form.get('group_id')
|
||||
if new_group_id and int(new_group_id) != link.group_id:
|
||||
link.group_id = int(new_group_id)
|
||||
|
||||
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
|
||||
new_icon_text = request.form.get('icon_text')
|
||||
|
||||
if new_icon_file:
|
||||
link.icon = new_icon_file
|
||||
elif new_icon_text:
|
||||
link.icon = new_icon_text
|
||||
|
||||
db.session.commit()
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_page/<int:page_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_page(page_id):
|
||||
page = Page.query.get_or_404(page_id)
|
||||
page.name = request.form.get('name')
|
||||
|
||||
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
|
||||
new_icon_text = request.form.get('icon_text')
|
||||
|
||||
if new_icon_file:
|
||||
page.icon = new_icon_file
|
||||
elif new_icon_text or 'icon_text' in request.form:
|
||||
page.icon = new_icon_text
|
||||
|
||||
db.session.commit()
|
||||
flash('Seite erfolgreich aktualisiert!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
@app.route('/admin/edit_group/<int:group_id>', methods=['POST'])
|
||||
@login_required
|
||||
def edit_group(group_id):
|
||||
group = Group.query.get_or_404(group_id)
|
||||
group.name = request.form.get('name')
|
||||
|
||||
new_page_id = request.form.get('page_id')
|
||||
if new_page_id and int(new_page_id) != group.page_id:
|
||||
group.page_id = int(new_page_id)
|
||||
|
||||
new_icon_file = save_and_scale_icon(request.files.get('icon_file'))
|
||||
new_icon_text = request.form.get('icon_text')
|
||||
|
||||
if new_icon_file:
|
||||
group.icon = new_icon_file
|
||||
elif new_icon_text or 'icon_text' in request.form:
|
||||
group.icon = new_icon_text
|
||||
|
||||
db.session.commit()
|
||||
flash('Gruppe erfolgreich aktualisiert!', 'success')
|
||||
return redirect(url_for('admin'))
|
||||
|
||||
# --- Reordering ---
|
||||
|
||||
@app.route('/admin/reorder_pages', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_pages():
|
||||
order_data = request.json.get('order')
|
||||
if not order_data: return jsonify({'status': 'error'}), 400
|
||||
|
||||
for index, page_id in enumerate(order_data):
|
||||
page = Page.query.get(page_id)
|
||||
if page: page.order = index
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
@app.route('/admin/reorder_groups', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_groups():
|
||||
data = request.json
|
||||
order_data = data.get('order')
|
||||
page_id = data.get('page_id')
|
||||
|
||||
if not order_data: return jsonify({'status': 'error'}), 400
|
||||
|
||||
for index, group_id in enumerate(order_data):
|
||||
group = Group.query.get(group_id)
|
||||
if group:
|
||||
group.order = index
|
||||
if page_id:
|
||||
group.page_id = int(page_id)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
@app.route('/admin/reorder_links', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_links():
|
||||
data = request.json
|
||||
order_data = data.get('order')
|
||||
group_id = data.get('group_id')
|
||||
|
||||
if not order_data: return jsonify({'status': 'error'}), 400
|
||||
|
||||
for index, link_id in enumerate(order_data):
|
||||
link = Link.query.get(link_id)
|
||||
if link:
|
||||
link.order = index
|
||||
if group_id:
|
||||
link.group_id = int(group_id)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'status': 'success'})
|
||||
|
||||
# --- Utility Routes ---
|
||||
|
||||
@app.route('/check_url')
|
||||
def check_url():
|
||||
url = request.args.get('url')
|
||||
if not url:
|
||||
return jsonify({'status': 'offline', 'reason': 'No URL provided'}), 400
|
||||
|
||||
# Check Cache
|
||||
current_time = time.time()
|
||||
if url in URL_CACHE:
|
||||
status, timestamp = URL_CACHE[url]
|
||||
if current_time - timestamp < CACHE_DURATION:
|
||||
return jsonify({'status': status, 'cached': True})
|
||||
|
||||
status = 'offline'
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
# Use a short timeout, and don't verify SSL certs for local services
|
||||
response = requests.head(url, headers=headers, timeout=5, verify=False, allow_redirects=True)
|
||||
|
||||
# If we get ANY response, the server is online/reachable.
|
||||
status = 'online'
|
||||
|
||||
except requests.exceptions.SSLError:
|
||||
# SSL Handshake failed, but server is there.
|
||||
status = 'online'
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
# Connection failed (Timeout, DNS, Refused)
|
||||
# Try one last time with GET, sometimes HEAD is blocked/buggy
|
||||
try:
|
||||
requests.get(url, headers=headers, timeout=5, verify=False, stream=True)
|
||||
status = 'online'
|
||||
except Exception as e2:
|
||||
# Log only actual failures
|
||||
print(f"Check URL failed for {url}: {e2}", file=sys.stdout, flush=True)
|
||||
status = 'offline'
|
||||
|
||||
# Update Cache
|
||||
URL_CACHE[url] = (status, current_time)
|
||||
return jsonify({'status': status})
|
||||
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
query = request.args.get('q', '').strip().lower()
|
||||
if not query or len(query) < 2:
|
||||
return jsonify([])
|
||||
|
||||
search_term = f"%{query}%"
|
||||
|
||||
results = db.session.query(Link, Group.name, Page.name)\
|
||||
.join(Group, Link.group_id == Group.id)\
|
||||
.join(Page, Group.page_id == Page.id)\
|
||||
.filter(
|
||||
(func.lower(Link.name).like(search_term)) |
|
||||
(func.lower(Link.subtitle).like(search_term))
|
||||
).all()
|
||||
|
||||
output = [{
|
||||
'name': link.name,
|
||||
'url': link.url,
|
||||
'subtitle': link.subtitle,
|
||||
'icon': link.icon,
|
||||
'group_name': group_name,
|
||||
'page_name': page_name
|
||||
} for link, group_name, page_name in results]
|
||||
|
||||
return jsonify(output)
|
||||
|
||||
|
||||
# --- Widget Data Handlers ---
|
||||
|
||||
def get_pihole_status(config):
|
||||
user_url = config.get('api_url', '').rstrip('/')
|
||||
password = config.get('api_key')
|
||||
|
||||
if not user_url:
|
||||
return {'error': "Pi-hole API URL not configured."}
|
||||
|
||||
try:
|
||||
if user_url.endswith('/api'):
|
||||
v6_api_base = user_url + '/'
|
||||
else:
|
||||
v6_api_base = urljoin(user_url, 'api/')
|
||||
|
||||
# Step 1: Authenticate
|
||||
auth_url = urljoin(v6_api_base, 'auth')
|
||||
auth_response = requests.post(auth_url, json={'password': password}, verify=False, timeout=5)
|
||||
|
||||
if auth_response.status_code == 404:
|
||||
raise requests.exceptions.RequestException("Auth endpoint not found (404), falling back to v5.")
|
||||
|
||||
auth_response.raise_for_status()
|
||||
|
||||
sid = auth_response.json().get('session', {}).get('sid')
|
||||
if not sid:
|
||||
return {'error': 'Pi-hole v6 auth successful, but no SID returned.'}
|
||||
|
||||
# Step 2a: Get blocking status
|
||||
blocking_url = urljoin(v6_api_base, 'dns/blocking')
|
||||
blocking_response = requests.get(blocking_url, params={'sid': sid}, verify=False, timeout=5)
|
||||
blocking_response.raise_for_status()
|
||||
blocking_data = blocking_response.json()
|
||||
|
||||
is_enabled = blocking_data.get('blocking') == 'enabled'
|
||||
status = 'enabled' if is_enabled else 'disabled'
|
||||
|
||||
# Step 2b: Get summary stats
|
||||
summary_url = urljoin(v6_api_base, 'stats/summary')
|
||||
summary_response = requests.get(summary_url, params={'sid': sid}, verify=False, timeout=5)
|
||||
summary_response.raise_for_status()
|
||||
summary_data = summary_response.json()
|
||||
|
||||
# Parse nested v6 structure
|
||||
queries_data = summary_data.get('queries', {})
|
||||
gravity_data = summary_data.get('gravity', {})
|
||||
|
||||
domains = gravity_data.get('domains_being_blocked', 0)
|
||||
queries = queries_data.get('total', 0)
|
||||
percent = queries_data.get('percent_blocked', 0.0)
|
||||
|
||||
return {
|
||||
'status': status,
|
||||
'domains_being_blocked': domains,
|
||||
'dns_queries_today': queries,
|
||||
'ads_percentage_today': percent
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Fallback for v5
|
||||
try:
|
||||
params = {'summary': ''}
|
||||
if password:
|
||||
params['auth'] = password
|
||||
|
||||
v5_base_url = user_url
|
||||
if v5_base_url.endswith('/api'):
|
||||
v5_base_url = v5_base_url[:-4]
|
||||
|
||||
v5_url = f"{v5_base_url.rstrip('/')}/admin/api.php"
|
||||
|
||||
response = requests.get(v5_url, params=params, timeout=5, verify=False)
|
||||
response.raise_for_status()
|
||||
v5_data = response.json()
|
||||
|
||||
if 'ads_percentage_today' in v5_data and isinstance(v5_data['ads_percentage_today'], str):
|
||||
v5_data['ads_percentage_today'] = float(v5_data['ads_percentage_today'].replace(',', '.'))
|
||||
return v5_data
|
||||
except Exception as e2:
|
||||
return {'error': f'Pi-hole connection failed: {e2}'}
|
||||
|
||||
def get_openmeteo_weather(config):
|
||||
lat = config.get('latitude')
|
||||
lon = config.get('longitude')
|
||||
|
||||
if not lat or not lon:
|
||||
return {'error': 'Latitude/Longitude not configured.'}
|
||||
|
||||
try:
|
||||
url = "https://api.open-meteo.com/v1/forecast"
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current": "temperature_2m,weather_code,wind_speed_10m",
|
||||
"daily": "temperature_2m_max,temperature_2m_min,sunrise,sunset",
|
||||
"timezone": "auto"
|
||||
}
|
||||
response = requests.get(url, params=params, timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
return {
|
||||
'current': data.get('current', {}),
|
||||
'daily': data.get('daily', {}),
|
||||
'units': data.get('current_units', {})
|
||||
}
|
||||
except Exception as e:
|
||||
return {'error': f"Weather API failed: {e}"}
|
||||
|
||||
def get_iframe_widget(config):
|
||||
return {
|
||||
'url': config.get('url'),
|
||||
'height': config.get('height', '300')
|
||||
}
|
||||
|
||||
WIDGET_HANDLERS = {
|
||||
'pihole_status': get_pihole_status,
|
||||
'openmeteo_weather': get_openmeteo_weather,
|
||||
'iframe_widget': get_iframe_widget
|
||||
}
|
||||
|
||||
@app.route('/api/widget_data/<int:widget_id>')
|
||||
def widget_data(widget_id):
|
||||
widget = Widget.query.get_or_404(widget_id)
|
||||
|
||||
try:
|
||||
config = json.loads(widget.config) if widget.config else {}
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({'error': 'Invalid widget configuration format.'}), 500
|
||||
|
||||
handler = WIDGET_HANDLERS.get(widget.type)
|
||||
|
||||
if not handler:
|
||||
return jsonify({'error': f"No handler for widget type '{widget.type}'."}), 404
|
||||
|
||||
data = handler(config)
|
||||
|
||||
if 'error' in data:
|
||||
return jsonify(data), 500
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
app.run(host='0.0.0.0', port=8080)
|
||||
53
app/data.json
Normal file
53
app/data.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"settings": {
|
||||
"title": "Klenzel::Startseite",
|
||||
"bg_image": "bg_wallpaper2.jpg",
|
||||
"columns": "is-one-quarter-desktop",
|
||||
"theme_color": "#3273dc",
|
||||
"overlay_opacity": "0",
|
||||
"header_color": "#767474",
|
||||
"logo_image": "logo_logo_skaliert.png"
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"name": "Infrastruktur",
|
||||
"icon": "fas fa-home",
|
||||
"groups": [
|
||||
{
|
||||
"name": "Switche",
|
||||
"links": [
|
||||
{
|
||||
"name": "WLAN",
|
||||
"url": "http://hkl-01-cl-cams:8081",
|
||||
"icon": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/amazon.png",
|
||||
"subtitle": "Testbeschreibung",
|
||||
"target": "_self"
|
||||
},
|
||||
{
|
||||
"name": "LAN",
|
||||
"url": "172.30.130.251:2376",
|
||||
"icon": "icon_dbdfee9ac94f45adb7e8ced1842d6213.png",
|
||||
"subtitle": "",
|
||||
"target": "_self"
|
||||
},
|
||||
{
|
||||
"name": "Transporth\u00fclle",
|
||||
"url": "http://hkl-01-cl-cams:8081",
|
||||
"icon": "fas fa-link",
|
||||
"subtitle": "Test",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"name": "eth0",
|
||||
"url": "172.25.16.161:2376",
|
||||
"icon": "icon_0bec1ea5e7964532b98d88c567d67266.png",
|
||||
"subtitle": "Test",
|
||||
"target": "_blank"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"settings_configured": true
|
||||
}
|
||||
6
app/requirements.txt
Normal file
6
app/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask
|
||||
Flask-SQLAlchemy
|
||||
Pillow
|
||||
requests
|
||||
|
||||
SQLAlchemy==2.0.29
|
||||
BIN
app/static/uploads/bg_wallpaper2.jpg
Normal file
BIN
app/static/uploads/bg_wallpaper2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
BIN
app/static/uploads/icon_0bec1ea5e7964532b98d88c567d67266.png
Normal file
BIN
app/static/uploads/icon_0bec1ea5e7964532b98d88c567d67266.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
BIN
app/static/uploads/icon_dbdfee9ac94f45adb7e8ced1842d6213.png
Normal file
BIN
app/static/uploads/icon_dbdfee9ac94f45adb7e8ced1842d6213.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
app/static/uploads/logo_logo_skaliert.png
Normal file
BIN
app/static/uploads/logo_logo_skaliert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
875
app/templates/admin.html
Normal file
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
22
app/templates/empty.html
Normal 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
642
app/templates/index.html
Normal 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
52
app/templates/login.html
Normal 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
23
app/templates/media.html
Normal 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>
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
dashboard:
|
||||
build: ./app
|
||||
container_name: custom_dashboard
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- ./app/static/uploads:/app/static/uploads
|
||||
- data:/data
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
|
||||
volumes:
|
||||
data: {}
|
||||
58522
fa_meta.json
Normal file
58522
fa_meta.json
Normal file
File diff suppressed because one or more lines are too long
51
generate_fa_list.py
Normal file
51
generate_fa_list.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
# 1. Generate FA list from metadata
|
||||
try:
|
||||
with open('fa_meta.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
fa_icons = []
|
||||
for name, info in data.items():
|
||||
styles = info.get('styles', [])
|
||||
for style in styles:
|
||||
prefix = 'fa-solid' if style == 'solid' else \
|
||||
'fa-regular' if style == 'regular' else \
|
||||
'fa-brands' if style == 'brands' else \
|
||||
'fa-light'
|
||||
|
||||
if prefix == 'fa-light': continue
|
||||
fa_icons.append(f"{prefix} fa-{name}")
|
||||
|
||||
fa_js = ' const fontawesomeIcons = [\n ' + ', '.join([f'"{icon}"' for icon in fa_icons]) + '\n ]'
|
||||
print(f"Generated {len(fa_icons)} FA icons.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing FA json: {e}")
|
||||
fa_js = ''
|
||||
|
||||
# 2. Read existing Homelab list from file to preserve it
|
||||
homelab_js = ''
|
||||
try:
|
||||
with open('app/templates/admin.html', 'r') as f:
|
||||
content = f.read()
|
||||
# Escape brackets in regex for literal match
|
||||
match = re.search(r'const homelabIcons = \[.*?\].*?;', content, re.DOTALL)
|
||||
if match:
|
||||
homelab_js = match.group(0)
|
||||
print(f"Found existing homelab icons block.")
|
||||
else:
|
||||
print("Did NOT find homelab icons block.")
|
||||
# Fallback for debugging
|
||||
print(content[:500])
|
||||
except Exception as e:
|
||||
print(f"Error reading admin.html: {e}")
|
||||
|
||||
# 3. Write replacement
|
||||
if homelab_js and fa_js:
|
||||
with open('replacement_fa.txt', 'w') as f:
|
||||
f.write(homelab_js + '\n\n' + fa_js)
|
||||
print("Wrote replacement_fa.txt")
|
||||
else:
|
||||
print("Skipping write because data missing.")
|
||||
71
generate_lists.py
Normal file
71
generate_lists.py
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
import json
|
||||
|
||||
# Read homelab icons
|
||||
with open('homelab_icons_list.txt', 'r') as f:
|
||||
homelab_icons = [line.strip().replace(',', '').replace('"', '') for line in f.readlines()]
|
||||
|
||||
# Generate JS array string
|
||||
homelab_js = ' const homelabIcons = [\n ' + ', '.join([f'"{icon}"' for icon in homelab_icons]) + '\n ];'
|
||||
|
||||
# FontAwesome Icons (Expanded List)
|
||||
fa_icons = [
|
||||
# Web Application Icons
|
||||
"fa-solid fa-house", "fa-solid fa-magnifying-glass", "fa-solid fa-user", "fa-solid fa-check", "fa-solid fa-download", "fa-solid fa-image",
|
||||
"fa-solid fa-phone", "fa-solid fa-bars", "fa-solid fa-envelope", "fa-solid fa-star", "fa-solid fa-location-dot", "fa-solid fa-music",
|
||||
"fa-solid fa-wand-magic-sparkles", "fa-solid fa-heart", "fa-solid fa-arrow-right", "fa-solid fa-circle-xmark", "fa-solid fa-bomb",
|
||||
"fa-solid fa-poo", "fa-solid fa-camera-retro", "fa-solid fa-cloud", "fa-solid fa-comment", "fa-solid fa-pen-nib", "fa-solid fa-arrow-up",
|
||||
"fa-solid fa-hippo", "fa-solid fa-face-smile", "fa-solid fa-calendar-days", "fa-solid fa-paperclip", "fa-solid fa-shield-halved",
|
||||
"fa-solid fa-file", "fa-solid fa-bell", "fa-solid fa-cart-shopping", "fa-solid fa-clipboard", "fa-solid fa-filter", "fa-solid fa-circle-info",
|
||||
"fa-solid fa-arrow-trend-up", "fa-solid fa-bolt", "fa-solid fa-car", "fa-solid fa-ghost", "fa-solid fa-mug-hot", "fa-solid fa-circle-user",
|
||||
"fa-solid fa-pen", "fa-solid fa-umbrella", "fa-solid fa-gift", "fa-solid fa-film", "fa-solid fa-list", "fa-solid fa-gear",
|
||||
"fa-solid fa-trash", "fa-solid fa-circle-up", "fa-solid fa-video", "fa-solid fa-chain", "fa-solid fa-gamepad", "fa-solid fa-server",
|
||||
"fa-solid fa-database", "fa-solid fa-network-wired", "fa-solid fa-wifi", "fa-solid fa-microchip", "fa-solid fa-hard-drive",
|
||||
"fa-solid fa-laptop", "fa-solid fa-desktop", "fa-solid fa-mobile", "fa-solid fa-tablet", "fa-solid fa-tv", "fa-solid fa-print",
|
||||
"fa-solid fa-terminal", "fa-solid fa-code", "fa-solid fa-bug", "fa-solid fa-layer-group", "fa-solid fa-users", "fa-solid fa-user-secret",
|
||||
"fa-solid fa-lock", "fa-solid fa-unlock", "fa-solid fa-key", "fa-solid fa-passport", "fa-solid fa-id-card", "fa-solid fa-address-book",
|
||||
"fa-solid fa-globe", "fa-solid fa-earth-americas", "fa-solid fa-earth-europe", "fa-solid fa-earth-africa", "fa-solid fa-earth-asia",
|
||||
"fa-solid fa-sun", "fa-solid fa-moon", "fa-solid fa-cloud-sun", "fa-solid fa-cloud-rain", "fa-solid fa-snowflake", "fa-solid fa-fire",
|
||||
"fa-solid fa-water", "fa-solid fa-wind", "fa-solid fa-tree", "fa-solid fa-seedling", "fa-solid fa-leaf", "fa-solid fa-cannabis",
|
||||
"fa-solid fa-paw", "fa-solid fa-dog", "fa-solid fa-cat", "fa-solid fa-horse", "fa-solid fa-fish", "fa-solid fa-crow",
|
||||
"fa-solid fa-hospital", "fa-solid fa-syringe", "fa-solid fa-capsules", "fa-solid fa-pills", "fa-solid fa-briefcase-medical", "fa-solid fa-stethoscope",
|
||||
"fa-solid fa-book", "fa-solid fa-book-open", "fa-solid fa-book-bookmark", "fa-solid fa-bookmark", "fa-solid fa-graduation-cap", "fa-solid fa-school",
|
||||
"fa-solid fa-money-bill", "fa-solid fa-money-bill-wave", "fa-solid fa-coins", "fa-solid fa-credit-card", "fa-solid fa-wallet", "fa-solid fa-piggy-bank",
|
||||
"fa-solid fa-chart-simple", "fa-solid fa-chart-pie", "fa-solid fa-chart-area", "fa-solid fa-chart-column", "fa-solid fa-chart-line", "fa-solid fa-diagram-project",
|
||||
"fa-solid fa-thumbs-up", "fa-solid fa-thumbs-down", "fa-solid fa-hand-point-up", "fa-solid fa-hand-point-down", "fa-solid fa-hand-point-left", "fa-solid fa-hand-point-right",
|
||||
"fa-solid fa-envelope-open", "fa-solid fa-envelope-open-text", "fa-solid fa-inbox", "fa-solid fa-paper-plane", "fa-solid fa-share", "fa-solid fa-share-nodes",
|
||||
"fa-solid fa-clock", "fa-solid fa-stopwatch", "fa-solid fa-hourglass", "fa-solid fa-hourglass-start", "fa-solid fa-hourglass-half", "fa-solid fa-hourglass-end",
|
||||
"fa-solid fa-calendar", "fa-solid fa-calendar-check", "fa-solid fa-calendar-plus", "fa-solid fa-calendar-minus", "fa-solid fa-calendar-xmark",
|
||||
"fa-solid fa-rss", "fa-solid fa-podcast", "fa-solid fa-radio", "fa-solid fa-microphone", "fa-solid fa-microphone-lines", "fa-solid fa-headphones",
|
||||
"fa-solid fa-sliders", "fa-solid fa-table-columns", "fa-solid fa-table-list", "fa-solid fa-table-cells", "fa-solid fa-table-cells-large",
|
||||
"fa-solid fa-toggle-on", "fa-solid fa-toggle-off", "fa-solid fa-circle-check", "fa-solid fa-circle-pause", "fa-solid fa-circle-stop", "fa-solid fa-circle-play",
|
||||
"fa-solid fa-power-off", "fa-solid fa-plug", "fa-solid fa-plug-circle-plus", "fa-solid fa-plug-circle-minus", "fa-solid fa-plug-circle-check", "fa-solid fa-plug-circle-xmark",
|
||||
"fa-solid fa-ethernet", "fa-solid fa-satellite-dish", "fa-solid fa-satellite", "fa-solid fa-tower-broadcast", "fa-solid fa-tower-cell", "fa-solid fa-signal",
|
||||
|
||||
# Brands
|
||||
"fa-brands fa-github", "fa-brands fa-docker", "fa-brands fa-linux", "fa-brands fa-windows", "fa-brands fa-apple", "fa-brands fa-android",
|
||||
"fa-brands fa-google", "fa-brands fa-google-drive", "fa-brands fa-google-play", "fa-brands fa-aws", "fa-brands fa-cloudflare", "fa-brands fa-digital-ocean",
|
||||
"fa-brands fa-python", "fa-brands fa-js", "fa-brands fa-html5", "fa-brands fa-css3", "fa-brands fa-react", "fa-brands fa-vuejs", "fa-brands fa-angular",
|
||||
"fa-brands fa-node", "fa-brands fa-npm", "fa-brands fa-yarn", "fa-brands fa-php", "fa-brands fa-java", "fa-brands fa-rust", "fa-brands fa-golang",
|
||||
"fa-brands fa-wordpress", "fa-brands fa-joomla", "fa-brands fa-drupal", "fa-brands fa-magento", "fa-brands fa-shopify", "fa-brands fa-squarespace",
|
||||
"fa-brands fa-wix", "fa-brands fa-weebly", "fa-brands fa-medium", "fa-brands fa-tumblr", "fa-brands fa-blogger", "fa-brands fa-ghost",
|
||||
"fa-brands fa-discord", "fa-brands fa-slack", "fa-brands fa-telegram", "fa-brands fa-whatsapp", "fa-brands fa-facebook-messenger", "fa-brands fa-skype",
|
||||
"fa-brands fa-twitter", "fa-brands fa-facebook", "fa-brands fa-instagram", "fa-brands fa-linkedin", "fa-brands fa-youtube", "fa-brands fa-twitch",
|
||||
"fa-brands fa-tiktok", "fa-brands fa-snapchat", "fa-brands fa-pinterest", "fa-brands fa-reddit", "fa-brands fa-quora", "fa-brands fa-stack-overflow",
|
||||
"fa-brands fa-spotify", "fa-brands fa-itunes", "fa-brands fa-soundcloud", "fa-brands fa-bandcamp", "fa-brands fa-steam", "fa-brands fa-xbox",
|
||||
"fa-brands fa-playstation", "fa-brands fa-battle-net", "fa-brands fa-itch-io", "fa-brands fa-unity", "fa-brands fa-unreal-engine",
|
||||
"fa-brands fa-bitcoin", "fa-brands fa-ethereum", "fa-brands fa-paypal", "fa-brands fa-stripe", "fa-brands fa-cc-visa", "fa-brands fa-cc-mastercard",
|
||||
"fa-brands fa-cc-amex", "fa-brands fa-cc-discover", "fa-brands fa-cc-jcb", "fa-brands fa-cc-diners-club", "fa-brands fa-amazon", "fa-brands fa-ebay",
|
||||
"fa-brands fa-alipay", "fa-brands fa-kickstarter", "fa-brands fa-patreon", "fa-brands fa-trello", "fa-brands fa-atlassian", "fa-brands fa-jira",
|
||||
"fa-brands fa-bitbucket", "fa-brands fa-confluence", "fa-brands fa-dropbox", "fa-brands fa-box", "fa-brands fa-evernote", "fa-brands fa-salesforce",
|
||||
"fa-brands fa-hubspot", "fa-brands fa-mailchimp", "fa-brands fa-waze", "fa-brands fa-uber", "fa-brands fa-lyft", "fa-brands fa-airbnb",
|
||||
"fa-brands fa-strava", "fa-brands fa-fitbit", "fa-brands fa-nutritionix", "fa-brands fa-imdb", "fa-brands fa-wikipedia-w", "fa-brands fa-researchgate",
|
||||
"fa-brands fa-chrome", "fa-brands fa-firefox", "fa-brands fa-firefox-browser", "fa-brands fa-edge", "fa-brands fa-safari", "fa-brands fa-opera",
|
||||
"fa-brands fa-internet-explorer", "fa-brands fa-ubuntu", "fa-brands fa-centos", "fa-brands fa-fedora", "fa-brands fa-redhat", "fa-brands fa-suse",
|
||||
"fa-brands fa-raspberry-pi", "fa-brands fa-freebsd"
|
||||
]
|
||||
|
||||
fa_js = ' const fontawesomeIcons = [\n ' + ', '.join([f'"{icon}"' for icon in fa_icons]) + '\n ];'
|
||||
|
||||
with open('replacement.txt', 'w') as f:
|
||||
f.write(homelab_js + '\n\n' + fa_js)
|
||||
2740
homelab_icons_list.txt
Normal file
2740
homelab_icons_list.txt
Normal file
File diff suppressed because it is too large
Load Diff
81967
icon_tree.json
Normal file
81967
icon_tree.json
Normal file
File diff suppressed because it is too large
Load Diff
7
replacement.txt
Normal file
7
replacement.txt
Normal file
File diff suppressed because one or more lines are too long
6
replacement_fa.txt
Normal file
6
replacement_fa.txt
Normal file
File diff suppressed because one or more lines are too long
22
update_admin_fa.py
Normal file
22
update_admin_fa.py
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import re
|
||||
|
||||
html_path = 'app/templates/admin.html'
|
||||
replacement_path = 'replacement_fa.txt'
|
||||
|
||||
with open(html_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
with open(replacement_path, 'r') as f:
|
||||
new_icons_block = f.read()
|
||||
|
||||
# Regex to find the block
|
||||
pattern = r'const homelabIcons = \[.*?\];.*?\s*const fontawesomeIcons = \[.*?\];'
|
||||
|
||||
if re.search(pattern, content, re.DOTALL):
|
||||
new_content = re.sub(pattern, new_icons_block, content, flags=re.DOTALL)
|
||||
with open(html_path, 'w') as f:
|
||||
f.write(new_content)
|
||||
print("Successfully replaced icon lists.")
|
||||
else:
|
||||
print("Could not find the icon lists block to replace.")
|
||||
24
update_admin_html.py
Normal file
24
update_admin_html.py
Normal file
@@ -0,0 +1,24 @@
|
||||
|
||||
import re
|
||||
|
||||
html_path = 'app/templates/admin.html'
|
||||
replacement_path = 'replacement.txt'
|
||||
|
||||
with open(html_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
with open(replacement_path, 'r') as f:
|
||||
new_icons_block = f.read()
|
||||
|
||||
# Regex to find the block
|
||||
# Matches: const homelabIcons = [ ... ]; <whitespace> const fontawesomeIcons = [ ... ];
|
||||
# We use dotall to match newlines.
|
||||
pattern = r'const homelabIcons = \[.*?\];.*?\s*const fontawesomeIcons = \[.*?\];'
|
||||
|
||||
if re.search(pattern, content, re.DOTALL):
|
||||
new_content = re.sub(pattern, new_icons_block, content, flags=re.DOTALL)
|
||||
with open(html_path, 'w') as f:
|
||||
f.write(new_content)
|
||||
print("Successfully replaced icon lists.")
|
||||
else:
|
||||
print("Could not find the icon lists block to replace.")
|
||||
Reference in New Issue
Block a user