Convert to Flask app with password-protected admin panel
Links now live in data/links.json instead of hardcoded HTML, editable via /admin (add/rename/reorder/delete sections and links, per-link color and half-width side-by-side layout). New pages can be added without code changes. Docker image only needs rebuilding on code changes now, not on content changes (data/ is volume-mounted). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56dc37843a
commit
61c4701115
13 files changed
+797
-173
No files matched your search
@@ -0,0 +1,3 @@
|
||||
ADMIN_PASSWORD=change-me
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
COOKIE_SECURE=true
|
||||
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,249 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, redirect, render_template, request, session, url_for
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "true").lower() != "false"
|
||||
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
|
||||
DATA_FILE = Path(os.environ.get("DATA_FILE", "data/links.json"))
|
||||
|
||||
COLORS = ["blue", "green", "orange", "pink"]
|
||||
|
||||
|
||||
def load_data():
|
||||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_data(data):
|
||||
tmp = DATA_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
tmp.replace(DATA_FILE)
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not session.get("admin"):
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def get_page_or_404(data, page_key):
|
||||
if page_key not in data:
|
||||
abort(404)
|
||||
return data[page_key]
|
||||
|
||||
|
||||
def get_section_or_404(page, section_id):
|
||||
for section in page["sections"]:
|
||||
if section["id"] == section_id:
|
||||
return section
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
data = load_data()
|
||||
return render_template("page.html", page=data["index"])
|
||||
|
||||
|
||||
@app.route("/<slug>.html")
|
||||
def page(slug):
|
||||
data = load_data()
|
||||
page_data = data.get(slug)
|
||||
if not page_data:
|
||||
abort(404)
|
||||
return render_template("page.html", page=page_data)
|
||||
|
||||
|
||||
@app.route("/admin", methods=["GET"])
|
||||
def admin_dashboard():
|
||||
if not session.get("admin"):
|
||||
return render_template("admin_login.html", error=None)
|
||||
data = load_data()
|
||||
return render_template("admin.html", data=data, colors=COLORS)
|
||||
|
||||
|
||||
@app.route("/admin/login", methods=["POST"])
|
||||
def admin_login():
|
||||
if not ADMIN_PASSWORD:
|
||||
return render_template(
|
||||
"admin_login.html", error="ADMIN_PASSWORD ist nicht gesetzt (siehe .env)."
|
||||
), 500
|
||||
if secrets.compare_digest(request.form.get("password", ""), ADMIN_PASSWORD):
|
||||
session["admin"] = True
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
return render_template("admin_login.html", error="Falsches Passwort"), 401
|
||||
|
||||
|
||||
@app.route("/admin/logout", methods=["POST"])
|
||||
def admin_logout():
|
||||
session.clear()
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/header", methods=["POST"])
|
||||
@login_required
|
||||
def edit_header(page_key):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
page["title"] = request.form.get("title", page.get("title", "")).strip()
|
||||
page["subtitle"] = request.form.get("subtitle", page.get("subtitle", "")).strip()
|
||||
page["subtitle2"] = request.form.get("subtitle2", page.get("subtitle2", "")).strip()
|
||||
page["footer_label"] = request.form.get("footer_label", page.get("footer_label", "")).strip()
|
||||
page["footer_href"] = request.form.get("footer_href", page.get("footer_href", "")).strip()
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/page/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_page():
|
||||
data = load_data()
|
||||
title = request.form.get("title", "").strip()
|
||||
slug = request.form.get("slug", "").strip().lower()
|
||||
slug = re.sub(r"[^a-z0-9-]+", "-", slug).strip("-")
|
||||
if slug and title and slug not in data:
|
||||
data[slug] = {
|
||||
"title": title,
|
||||
"subtitle": "",
|
||||
"subtitle2": "",
|
||||
"footer_label": "",
|
||||
"footer_href": "",
|
||||
"sections": [],
|
||||
}
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/page/<page_key>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_page(page_key):
|
||||
if page_key == "index":
|
||||
abort(400)
|
||||
data = load_data()
|
||||
data.pop(page_key, None)
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/section/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_section(page_key):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
name = request.form.get("name", "").strip()
|
||||
if name:
|
||||
page["sections"].append({"id": secrets.token_hex(4), "name": name, "links": []})
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/section/<section_id>/rename", methods=["POST"])
|
||||
@login_required
|
||||
def rename_section(page_key, section_id):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
section = get_section_or_404(page, section_id)
|
||||
name = request.form.get("name", "").strip()
|
||||
if name:
|
||||
section["name"] = name
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/section/<section_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_section(page_key, section_id):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
page["sections"] = [s for s in page["sections"] if s["id"] != section_id]
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/section/<section_id>/link/add", methods=["POST"])
|
||||
@login_required
|
||||
def add_link(page_key, section_id):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
section = get_section_or_404(page, section_id)
|
||||
title = request.form.get("title", "").strip()
|
||||
url = request.form.get("url", "").strip()
|
||||
if title and url:
|
||||
section["links"].append(
|
||||
{
|
||||
"id": secrets.token_hex(4),
|
||||
"title": title,
|
||||
"note": request.form.get("note", "").strip(),
|
||||
"url": url,
|
||||
"color": request.form.get("color", "blue"),
|
||||
"half": bool(request.form.get("half")),
|
||||
}
|
||||
)
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/link/<link_id>/edit", methods=["POST"])
|
||||
@login_required
|
||||
def edit_link(page_key, link_id):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
for section in page["sections"]:
|
||||
for link in section["links"]:
|
||||
if link["id"] == link_id:
|
||||
link["title"] = request.form.get("title", link["title"]).strip()
|
||||
link["note"] = request.form.get("note", link["note"]).strip()
|
||||
link["url"] = request.form.get("url", link["url"]).strip()
|
||||
link["color"] = request.form.get("color", link["color"])
|
||||
link["half"] = bool(request.form.get("half"))
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/link/<link_id>/delete", methods=["POST"])
|
||||
@login_required
|
||||
def delete_link(page_key, link_id):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
for section in page["sections"]:
|
||||
section["links"] = [l for l in section["links"] if l["id"] != link_id]
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
@app.route("/admin/<page_key>/link/<link_id>/move", methods=["POST"])
|
||||
@login_required
|
||||
def move_link(page_key, link_id):
|
||||
data = load_data()
|
||||
page = get_page_or_404(data, page_key)
|
||||
direction = request.form.get("direction")
|
||||
for section in page["sections"]:
|
||||
ids = [l["id"] for l in section["links"]]
|
||||
if link_id in ids:
|
||||
idx = ids.index(link_id)
|
||||
new_idx = idx - 1 if direction == "up" else idx + 1
|
||||
if 0 <= new_idx < len(section["links"]):
|
||||
section["links"][idx], section["links"][new_idx] = (
|
||||
section["links"][new_idx],
|
||||
section["links"][idx],
|
||||
)
|
||||
save_data(data)
|
||||
return redirect(url_for("admin_dashboard"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=80)
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
{
|
||||
"index": {
|
||||
"title": "Honey Services for Cinnamon",
|
||||
"subtitle": "Welcome!",
|
||||
"subtitle2": "Here are all my important links for you!",
|
||||
"footer_label": "Cinnamon Version",
|
||||
"footer_href": "honey.html",
|
||||
"sections": [
|
||||
{
|
||||
"id": "s_important",
|
||||
"name": "Important Stuff",
|
||||
"links": [
|
||||
{
|
||||
"id": "l_crochet",
|
||||
"title": "CrochetMeMaybe🧶",
|
||||
"note": "Crochet Website Counting",
|
||||
"url": "https://crochetmemaybe.asianxpolish.duckdns.org",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_pixelpaper1",
|
||||
"title": "PixelPaper📖",
|
||||
"note": "EBook and Manga Server",
|
||||
"url": "https://pixelpaper.asianxpolish.duckdns.org",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_ticket1",
|
||||
"title": "Ticket System 🎆",
|
||||
"note": "Tickets an Honey",
|
||||
"url": "https://hot.asianxpolish.duckdns.org",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_learn1",
|
||||
"title": "Learning Tracker✒️",
|
||||
"note": "Tracker für das lernen und rewards!",
|
||||
"url": "https://learn.asianxpolish.duckdns.org",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_live",
|
||||
"title": "Livestream!👾",
|
||||
"note": "Dein Persönlicher Livestream",
|
||||
"url": "https://live.asianxpolish.duckdns.org",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s_working",
|
||||
"name": "Working Stuff",
|
||||
"links": [
|
||||
{
|
||||
"id": "l_honeycloud",
|
||||
"title": "Honeycloud🍯",
|
||||
"note": "The HoneyCloud Service",
|
||||
"url": "https://honeyycloud.duckdns.org",
|
||||
"color": "green",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_pocket1",
|
||||
"title": "Pocket-ID📓",
|
||||
"note": "Dein User in all den Systemen (Passkey)",
|
||||
"url": "https://pocket.asianxpolish.duckdns.org",
|
||||
"color": "green",
|
||||
"half": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s_tools1",
|
||||
"name": "Tools",
|
||||
"links": [
|
||||
{
|
||||
"id": "l_pdf1",
|
||||
"title": "PDF Tool📝",
|
||||
"note": "PDF Editieren und Zusammenfügen",
|
||||
"url": "https://pdf.asianxpolish.duckdns.org",
|
||||
"color": "orange",
|
||||
"half": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"honey": {
|
||||
"title": "My Links",
|
||||
"subtitle": "",
|
||||
"subtitle2": "Some Stuff",
|
||||
"footer_label": "Honey Version",
|
||||
"footer_href": "index.html",
|
||||
"sections": [
|
||||
{
|
||||
"id": "s_pangolin",
|
||||
"name": "Public with Pangolin",
|
||||
"links": [
|
||||
{
|
||||
"id": "l_pixelpaper2",
|
||||
"title": "PixelPaper📖",
|
||||
"note": "EBook and Manga Server",
|
||||
"url": "https://pixelpaper.asianxpolish.duckdns.org",
|
||||
"color": "orange",
|
||||
"half": true
|
||||
},
|
||||
{
|
||||
"id": "l_catto",
|
||||
"title": "Catto🐈⬛",
|
||||
"note": "",
|
||||
"url": "https://catto.asianxpolish.duckdns.org",
|
||||
"color": "orange",
|
||||
"half": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s_tools2",
|
||||
"name": "Tools",
|
||||
"links": [
|
||||
{
|
||||
"id": "l_pdf2",
|
||||
"title": "PDF Tool📝",
|
||||
"note": "PDF Editieren und Zusammenfügen",
|
||||
"url": "https://pdf.asianxpolish.duckdns.org",
|
||||
"color": "pink",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_pocket2",
|
||||
"title": "Pocket-ID",
|
||||
"note": "OIDC Passkey",
|
||||
"url": "https://pocket.asianxpolish.duckdns.org",
|
||||
"color": "green",
|
||||
"half": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "s_cinnamon",
|
||||
"name": "For Cinnamon",
|
||||
"links": [
|
||||
{
|
||||
"id": "l_crochet2",
|
||||
"title": "CrochetMeMaybe🧶",
|
||||
"note": "Crochet Website Counting",
|
||||
"url": "https://crochetmemaybe.asianxpolish.duckdns.org",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_ticket2",
|
||||
"title": "Ticket System 🎆",
|
||||
"note": "",
|
||||
"url": "https://hot.asianxpolish.duckdns.org/admin.html",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "l_learn2",
|
||||
"title": "Learning Tracker✒️",
|
||||
"note": "",
|
||||
"url": "https://learn.asianxpolish.duckdns.org",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
},
|
||||
{
|
||||
"id": "32be2487",
|
||||
"title": "Admin Page",
|
||||
"note": "",
|
||||
"url": "https://asianxpolish.duckdns.org/admin",
|
||||
"color": "blue",
|
||||
"half": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
honey-links:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
# Set to "false" only for local http-only testing without a TLS proxy in front.
|
||||
- COOKIE_SECURE=${COOKIE_SECURE:-true}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
+12
-7
@@ -1,16 +1,21 @@
|
||||
# Use the official Python image
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the HTML files into the container
|
||||
COPY index.html /app
|
||||
COPY honey.html /app
|
||||
COPY styles.css /app
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the application into the container
|
||||
COPY app.py .
|
||||
COPY templates/ templates/
|
||||
COPY static/ static/
|
||||
COPY data/ data/
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start the Python HTTP server on port 80
|
||||
CMD ["python3", "-m", "http.server", "80"]^
|
||||
# Start the app via a production WSGI server
|
||||
CMD ["waitress-serve", "--host=0.0.0.0", "--port=80", "app:app"]
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Honey | Links</title>
|
||||
<link rel="icon" type="image/x-icon" href="/images/favicon/favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-glow"></div>
|
||||
|
||||
<!-- Fireflies container -->
|
||||
<div class="fireflies">
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content on top -->
|
||||
<img src="https://c.tenor.com/rfib5QqA0ncAAAAd/tenor.gif" alt="Profile Picture" class="profile-pic" />
|
||||
<h1 class="fade-in">My Links</h1>
|
||||
<p class="bio2 fade-in">Some Stuff</p>
|
||||
|
||||
|
||||
<h2 class="section-title">Public with Pangolin</h2>
|
||||
|
||||
<a href="https://pixelpaper.asianxpolish.duckdns.org" class="link animated-link orange-glow" target="_blank">
|
||||
<div class="link-title">PixelPaper📖</div>
|
||||
<div class="link-note">EBook and Manga Server</div>
|
||||
</a>
|
||||
|
||||
<a href="https://catto.asianxpolish.duckdns.org" class="link animated-link orange-glow" target="_blank">
|
||||
<div class="link-title">Catto🐈⬛</div>
|
||||
</a>
|
||||
|
||||
<h2 class="section-title">Tools</h2>
|
||||
|
||||
<a href="https://pdf.asianxpolish.duckdns.org" class="link animated-link pink-glow" target="_blank">
|
||||
<div class="link-title">PDF Tool📝</div>
|
||||
<div class="link-note">PDF Editieren und Zusammenfügen</div>
|
||||
</a>
|
||||
|
||||
<a href="https://pocket.asianxpolish.duckdns.org" class="link animated-link green-glow" target="_blank">
|
||||
<div class="link-title">Pocket-ID</div>
|
||||
<div class="link-note">OIDC Passkey</div>
|
||||
</a>
|
||||
|
||||
|
||||
<h2 class="section-title">For Cinnamon</h2>
|
||||
|
||||
<a href="https://crochetmemaybe.asianxpolish.duckdns.org" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">CrochetMeMaybe🧶</div>
|
||||
<div class="link-note">Crochet Website Counting</div>
|
||||
</a>
|
||||
|
||||
<a href="https://hot.asianxpolish.duckdns.org/admin.html" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">Ticket System 🎆</div>
|
||||
</a>
|
||||
|
||||
<a href="https://learn.asianxpolish.duckdns.org" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">Learning Tracker✒️</div>
|
||||
</a>
|
||||
|
||||
|
||||
<footer>
|
||||
<a href="index.html"target="_self" title="Switch to Cinnamon Version" class="link-footer">
|
||||
<div>Honey Version</div>
|
||||
</a>
|
||||
© 2025 Marc Luv
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Honey | Links</title>
|
||||
<link rel="icon" type="image/x-icon" href="/images/favicon/favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-glow"></div>
|
||||
|
||||
<!-- Fireflies container -->
|
||||
<div class="fireflies">
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content on top -->
|
||||
<img src="https://c.tenor.com/rfib5QqA0ncAAAAd/tenor.gif" alt="Profile Picture" class="profile-pic" />
|
||||
<h1 class="fade-in">Honey Services for Cinnamon</h1>
|
||||
<p class="bio fade-in">Welcome!</p>
|
||||
<p class="bio2 fade-in">Here are all my important links for you!</p>
|
||||
|
||||
|
||||
<h2 class="section-title">Important Stuff</h2>
|
||||
|
||||
<a href="https://crochetmemaybe.asianxpolish.duckdns.org" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">CrochetMeMaybe🧶</div>
|
||||
<div class="link-note">Crochet Website Counting</div>
|
||||
</a>
|
||||
|
||||
<a href="https://pixelpaper.asianxpolish.duckdns.org" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">PixelPaper📖</div>
|
||||
<div class="link-note">EBook and Manga Server</div>
|
||||
</a>
|
||||
|
||||
<a href="https://hot.asianxpolish.duckdns.org" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">Ticket System 🎆</div>
|
||||
<div class="link-note">Tickets an Honey</div>
|
||||
</a>
|
||||
|
||||
<a href="https://learn.asianxpolish.duckdns.org" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">Learning Tracker✒️</div>
|
||||
<div class="link-note">Tracker für das lernen und rewards!</div>
|
||||
</a>
|
||||
|
||||
<a href="https://live.asianxpolish.duckdns.org" class="link animated-link blue-glow" target="_blank">
|
||||
<div class="link-title">Livestream!👾</div>
|
||||
<div class="link-note">Dein Persönlicher Livestream</div>
|
||||
</a>
|
||||
|
||||
<h2 class="section-title">Working Stuff</h2>
|
||||
|
||||
<a href="https://honeyycloud.duckdns.org" class="link animated-link green-glow" target="_blank">
|
||||
<div class="link-title">Honeycloud🍯</div>
|
||||
<div class="link-note">The HoneyCloud Service</div>
|
||||
</a>
|
||||
|
||||
<a href="https://pocket.asianxpolish.duckdns.org" class="link animated-link green-glow" target="_blank">
|
||||
<div class="link-title">Pocket-ID📓</div>
|
||||
<div class="link-note">Dein User in all den Systemen (Passkey)</div>
|
||||
</a>
|
||||
|
||||
<h2 class="section-title">Tools</h2>
|
||||
|
||||
<a href="https://pdf.asianxpolish.duckdns.org" class="link animated-link orange-glow" target="_blank">
|
||||
<div class="link-title">PDF Tool📝</div>
|
||||
<div class="link-note">PDF Editieren und Zusammenfügen</div>
|
||||
</a>
|
||||
|
||||
<footer>
|
||||
<a href="honey.html"target="_self" title="Switch to Honey Version" class="link-footer">
|
||||
<div>Cinnamon Version</div>
|
||||
</a>
|
||||
© 2025 Marc Luv
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
Flask>=3.0,<4
|
||||
waitress>=3.0,<4
|
||||
@@ -144,11 +144,18 @@ animation: pulseGlow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
.links-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 6px 0;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
flex: 0 0 100%;
|
||||
padding: 14px 24px;
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
color: #f3f4f6;
|
||||
@@ -160,6 +167,12 @@ font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Opt-in: two links next to each other instead of full width */
|
||||
.link.half {
|
||||
flex: 0 0 calc(50% - 5px);
|
||||
padding: 14px 14px;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
background-color: rgba(255, 255, 255, 0.07); /* softer highlight */
|
||||
color: #e0e7ff; /* gentle light blue tint */
|
||||
@@ -307,6 +320,10 @@ will-change: opacity, transform, filter;
|
||||
font-size: 14px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.link.half {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Admin – Honey Links</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
<style>
|
||||
body {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
overflow-x: visible;
|
||||
}
|
||||
h1 { text-align: left; }
|
||||
h2.section-title { animation: none; }
|
||||
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.admin-page { margin-bottom: 40px; border: 1px solid #374151; border-radius: 10px; padding: 20px; }
|
||||
.admin-page h2 { color: #a5f3fc; margin-bottom: 16px; }
|
||||
.admin-section { border: 1px solid #374151; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
|
||||
.section-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
|
||||
.admin-link-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; padding: 8px 0; border-bottom: 1px solid #1f2937; }
|
||||
.admin-link-row:last-of-type { border-bottom: none; }
|
||||
.admin-link-row form.edit-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.admin-link-row .link-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
form.inline { display: inline-flex; gap: 6px; align-items: center; }
|
||||
input[type=text], input[type=url], input[type=password] {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #4b5563;
|
||||
background: #0f172a;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
select {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: #0f172a;
|
||||
color: #f3f4f6;
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #4b5563;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #f3f4f6;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover { background: rgba(255, 255, 255, 0.12); }
|
||||
.btn-danger { border-color: #b91c1c; color: #fca5a5; }
|
||||
.new-form { margin-top: 12px; padding-top: 12px; border-top: 1px dashed #374151; }
|
||||
label.checkbox { display: flex; align-items: center; gap: 4px; font-size: 13px; color: #9ca3af; white-space: nowrap; }
|
||||
.field-title { flex: 1 1 130px; min-width: 100px; }
|
||||
.field-note { flex: 1 1 150px; min-width: 100px; }
|
||||
.field-url { flex: 2 1 180px; min-width: 140px; }
|
||||
.quick-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.quick-nav a {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #4b5563;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #a5f3fc;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
.quick-nav a:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
.view-link { font-size: 13px; color: #21e4d3; text-decoration: none; margin-left: 10px; white-space: nowrap; }
|
||||
.view-link:hover { color: #24ffed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<h1>Admin</h1>
|
||||
<form method="post" action="{{ url_for('admin_logout') }}"><button type="submit">Logout</button></form>
|
||||
</div>
|
||||
|
||||
<div class="quick-nav">
|
||||
{% for page_key in data.keys() %}
|
||||
<a href="#page-{{ page_key }}">↓ {{ page_key }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="admin-page">
|
||||
<h2>Neue Seite anlegen</h2>
|
||||
<form method="post" action="{{ url_for('add_page') }}" style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center;">
|
||||
<input type="text" name="title" placeholder="Titel der Seite" required>
|
||||
<input type="text" name="slug" placeholder="url-slug (z.B. gaming)" pattern="[a-zA-Z0-9-]+" required>
|
||||
<button type="submit">+ Seite anlegen</button>
|
||||
</form>
|
||||
<p style="color:#9ca3af; font-size:13px; margin-top:8px;">Erreichbar danach unter <code>/<slug>.html</code>, z.B. <code>/gaming.html</code>.</p>
|
||||
</div>
|
||||
|
||||
{% for page_key, page in data.items() %}
|
||||
<div class="admin-page" id="page-{{ page_key }}">
|
||||
<div class="section-head">
|
||||
<h2 style="margin-bottom:0;">
|
||||
Seite: {{ page_key }} — erreichbar unter {% if page_key == 'index' %}<code>/</code>{% else %}<code>/{{ page_key }}.html</code>{% endif %}
|
||||
<a class="view-link" href="{{ '/' if page_key == 'index' else '/' + page_key + '.html' }}" target="_blank">🔗 ansehen</a>
|
||||
</h2>
|
||||
{% if page_key != 'index' %}
|
||||
<form method="post" action="{{ url_for('delete_page', page_key=page_key) }}" onsubmit="return confirm('Seite \'{{ page_key }}\' komplett inkl. aller Sections/Links löschen?');">
|
||||
<button type="submit" class="btn-danger">Seite löschen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('edit_header', page_key=page_key) }}" style="margin-bottom: 20px; display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div>
|
||||
<label class="checkbox">Titel</label>
|
||||
<input type="text" name="title" value="{{ page.title }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="checkbox">Untertitel 1</label>
|
||||
<input type="text" name="subtitle" value="{{ page.subtitle or '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="checkbox">Untertitel 2</label>
|
||||
<input type="text" name="subtitle2" value="{{ page.subtitle2 or '' }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="checkbox">Footer-Link-Ziel</label>
|
||||
<input type="text" name="footer_href" value="{{ page.footer_href or '' }}" placeholder="z.B. honey.html">
|
||||
</div>
|
||||
<div>
|
||||
<label class="checkbox">Footer-Link-Text</label>
|
||||
<input type="text" name="footer_label" value="{{ page.footer_label or '' }}" placeholder="z.B. Honey Version">
|
||||
</div>
|
||||
<button type="submit">Header speichern</button>
|
||||
</form>
|
||||
|
||||
{% for section in page.sections %}
|
||||
<div class="admin-section">
|
||||
<div class="section-head">
|
||||
<form method="post" action="{{ url_for('rename_section', page_key=page_key, section_id=section.id) }}" class="inline">
|
||||
<input type="text" name="name" value="{{ section.name }}">
|
||||
<button type="submit">Umbenennen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('delete_section', page_key=page_key, section_id=section.id) }}" onsubmit="return confirm('Section \'{{ section.name }}\' inkl. aller Links wirklich löschen?');">
|
||||
<button type="submit" class="btn-danger">Section löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% for link in section.links %}
|
||||
<div class="admin-link-row">
|
||||
<form method="post" action="{{ url_for('edit_link', page_key=page_key, link_id=link.id) }}" class="edit-form">
|
||||
<input class="field-title" type="text" name="title" value="{{ link.title }}">
|
||||
<input class="field-note" type="text" name="note" value="{{ link.note or '' }}" placeholder="Notiz">
|
||||
<input class="field-url" type="url" name="url" value="{{ link.url }}">
|
||||
<select name="color">
|
||||
{% for c in colors %}
|
||||
<option value="{{ c }}" {% if link.color == c %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="checkbox"><input type="checkbox" name="half" {% if link.half %}checked{% endif %}> nebeneinander</label>
|
||||
<div class="link-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="link-actions">
|
||||
<form method="post" action="{{ url_for('move_link', page_key=page_key, link_id=link.id) }}" class="inline">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<button type="submit" title="nach oben">↑</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('move_link', page_key=page_key, link_id=link.id) }}" class="inline">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<button type="submit" title="nach unten">↓</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('delete_link', page_key=page_key, link_id=link.id) }}" class="inline" onsubmit="return confirm('Link \'{{ link.title }}\' wirklich löschen?');">
|
||||
<button type="submit" class="btn-danger">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<form method="post" action="{{ url_for('add_link', page_key=page_key, section_id=section.id) }}" class="new-form" style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center;">
|
||||
<input class="field-title" type="text" name="title" placeholder="Titel" required>
|
||||
<input class="field-note" type="text" name="note" placeholder="Notiz (optional)">
|
||||
<input class="field-url" type="url" name="url" placeholder="https://..." required>
|
||||
<select name="color">
|
||||
{% for c in colors %}
|
||||
<option value="{{ c }}">{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label class="checkbox"><input type="checkbox" name="half"> nebeneinander (halbe Breite)</label>
|
||||
<button type="submit">+ Link hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<form method="post" action="{{ url_for('add_section', page_key=page_key) }}" style="margin-top: 12px; display: flex; gap: 8px;">
|
||||
<input type="text" name="name" placeholder="Neue Section" required>
|
||||
<button type="submit">+ Section hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Admin Login</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
<style>
|
||||
.admin-box {
|
||||
max-width: 320px;
|
||||
margin: 80px auto;
|
||||
padding: 24px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.admin-box label { display: block; margin-bottom: 6px; color: #9ca3af; font-size: 13px; }
|
||||
.admin-box input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #4b5563;
|
||||
background: #0f172a;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.admin-box button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: #21e4d3;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-box button:hover { background: #24ffed; }
|
||||
.admin-error { color: #fca5a5; margin-bottom: 12px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-box">
|
||||
<h1 style="font-size: 20px; margin-bottom: 16px; text-align: left;">Admin Login</h1>
|
||||
{% if error %}<div class="admin-error">{{ error }}</div>{% endif %}
|
||||
<form method="post" action="{{ url_for('admin_login') }}">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" autofocus required>
|
||||
<button type="submit">Einloggen</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Honey | Links</title>
|
||||
<link rel="icon" type="image/x-icon" href="/images/favicon/favicon.ico">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-glow"></div>
|
||||
|
||||
<!-- Fireflies container -->
|
||||
<div class="fireflies">
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
<div class="firefly"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content on top -->
|
||||
<img src="https://c.tenor.com/rfib5QqA0ncAAAAd/tenor.gif" alt="Profile Picture" class="profile-pic" />
|
||||
<h1 class="fade-in">{{ page.title }}</h1>
|
||||
{% if page.subtitle %}<p class="bio fade-in">{{ page.subtitle }}</p>{% endif %}
|
||||
{% if page.subtitle2 %}<p class="bio2 fade-in">{{ page.subtitle2 }}</p>{% endif %}
|
||||
|
||||
{% for section in page.sections %}
|
||||
<h2 class="section-title">{{ section.name }}</h2>
|
||||
<div class="links-grid">
|
||||
{% for link in section.links %}
|
||||
<a href="{{ link.url }}" class="link animated-link {{ link.color }}-glow{% if link.half %} half{% endif %}" target="_blank" style="--i: {{ loop.index0 }}">
|
||||
<div class="link-title">{{ link.title }}</div>
|
||||
{% if link.note %}<div class="link-note">{{ link.note }}</div>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<footer>
|
||||
<a href="{{ page.footer_href }}" target="_self" title="Switch Version" class="link-footer">
|
||||
<div>{{ page.footer_label }}</div>
|
||||
</a>
|
||||
© 2025 Marc Luv
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user