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