Initial commit
This commit is contained in:
@@ -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
@@ -0,0 +1,11 @@
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
Flask
|
||||
Flask-SQLAlchemy
|
||||
Pillow
|
||||
requests
|
||||
|
||||
SQLAlchemy==2.0.29
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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.")
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
+81967
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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.")
|
||||
@@ -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