From 655e20a47692f66f58d775b006384205c726f455 Mon Sep 17 00:00:00 2001 From: Marius Robert Macamete Date: Wed, 10 Sep 2025 14:56:26 +0300 Subject: [PATCH] Initial commit for LaunchPad app --- app.py | 275 ++++++++++++++++++ launchpad.db | Bin 0 -> 12288 bytes requirements.txt | 2 + static/styles.css | 27 ++ ...ne-notification-1_20250910113657245202.png | Bin 0 -> 22960 bytes templates/app_form.html | 42 +++ templates/apps_list.html | 38 +++ templates/base.html | 33 +++ templates/login.html | 14 + wsgi.py | 2 + 10 files changed, 433 insertions(+) create mode 100644 app.py create mode 100644 launchpad.db create mode 100644 requirements.txt create mode 100644 static/styles.css create mode 100644 static/uploads/bell-icon-with-one-notification-1_20250910113657245202.png create mode 100644 templates/app_form.html create mode 100644 templates/apps_list.html create mode 100644 templates/base.html create mode 100644 templates/login.html create mode 100644 wsgi.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..b037c7b --- /dev/null +++ b/app.py @@ -0,0 +1,275 @@ +import os +import sqlite3 +from datetime import datetime +from pathlib import Path +from urllib.parse import urlparse + +from flask import ( + Flask, render_template, request, redirect, url_for, flash, session, jsonify, abort +) + +# ---------------------------------------------------------------------------- +# Config +# ---------------------------------------------------------------------------- +BASE_DIR = Path(__file__).resolve().parent +DB_PATH = BASE_DIR / "launchpad.db" +UPLOADS_DIR = BASE_DIR / "static" / "uploads" +ALLOWED_IMAGE_EXT = {"png", "jpg", "jpeg", "webp", "gif"} + + +def create_app(): + app = Flask(__name__, template_folder=str(BASE_DIR / "templates"), static_folder=str(BASE_DIR / "static")) + + # Minimal secrets; override in .env or environment + app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-change-me") + app.config["ADMIN_USER"] = os.getenv("ADMIN_USER", "admin") + app.config["ADMIN_PASS"] = os.getenv("ADMIN_PASS", "admin123") + app.config["PUBLIC_BASE_URL"] = os.getenv("PUBLIC_BASE_URL", "").rstrip("/") + + # Ensure DB exists + init_db() + # Ensure uploads dir exists + UPLOADS_DIR.mkdir(parents=True, exist_ok=True) + + # --------------------------- + # Helpers + # --------------------------- + def get_db(): + return sqlite3.connect(DB_PATH) + + def login_required(fn): + from functools import wraps + @wraps(fn) + def wrapper(*args, **kwargs): + if not session.get("user"): + return redirect(url_for("login", next=request.path)) + return fn(*args, **kwargs) + return wrapper + + def validate_url(u: str) -> bool: + try: + p = urlparse(u) + return p.scheme in {"http", "https"} and bool(p.netloc) + except Exception: + return False + + def make_absolute(img_path_or_url: str) -> str: + """Return an absolute https/http URL for images. If already absolute, return as is. + If it's a /static/... path, prefix with PUBLIC_BASE_URL if set, otherwise request.url_root. + """ + if not img_path_or_url: + return img_path_or_url + low = img_path_or_url.lower() + if low.startswith("http://") or low.startswith("https://"): + return img_path_or_url + # Use configured public base if provided; else request.url_root (requires request context) + base = app.config.get("PUBLIC_BASE_URL") or request.url_root.rstrip('/') + if not img_path_or_url.startswith('/'): + img_path_or_url = '/' + img_path_or_url + return f"{base}{img_path_or_url}" + + def allowed_image(filename: str) -> bool: + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXT + + def save_image(file_storage) -> str | None: + """Save uploaded image to static/uploads and return its web path beginning with /static/...""" + from werkzeug.utils import secure_filename + if not file_storage or not file_storage.filename: + return None + if not allowed_image(file_storage.filename): + return None + name = secure_filename(file_storage.filename) + # unique filename + ts = datetime.utcnow().strftime("%Y%m%d%H%M%S%f") + ext = name.rsplit(".", 1)[1].lower() + base = name.rsplit(".", 1)[0] + final_name = f"{base}_{ts}.{ext}" + dest = UPLOADS_DIR / final_name + file_storage.save(dest) + return f"/static/uploads/{final_name}" + + # --------------------------- + # Auth + # --------------------------- + @app.route("/login", methods=["GET", "POST"]) + def login(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if username == app.config["ADMIN_USER"] and password == app.config["ADMIN_PASS"]: + session["user"] = username + flash("Welcome!", "success") + return redirect(request.args.get("next") or url_for("apps_list")) + flash("Invalid credentials", "danger") + return render_template("login.html") + + @app.route("/logout") + def logout(): + session.clear() + flash("Logged out", "info") + return redirect(url_for("login")) + + # --------------------------- + # UI Pages + # --------------------------- + @app.route("/") + @login_required + def apps_list(): + query = "SELECT id, name, image, url, enabled, created_at FROM apps ORDER BY priority DESC, name ASC" + with get_db() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute(query).fetchall() + return render_template("apps_list.html", apps=rows) + + @app.route("/apps/new", methods=["GET", "POST"]) + @login_required + def app_create(): + if request.method == "POST": + name = request.form.get("name", "").strip() + image_file = request.files.get("image_file") # NEW + image = request.form.get("image", "").strip() + url = request.form.get("url", "").strip() + enabled = 1 if request.form.get("enabled") == "on" else 0 + priority = int(request.form.get("priority", 0) or 0) + + if not name: + flash("Name is required", "danger") + return render_template("app_form.html", form_action=url_for("app_create")) + + # Prefer uploaded file over URL + saved_image_path = save_image(image_file) if image_file else None + if saved_image_path: + image = saved_image_path + elif image and not validate_url(image): + flash("Image must be a valid http(s) URL or upload a file", "danger") + return render_template("app_form.html", form_action=url_for("app_create")) + + if not validate_url(url): + flash("App URL must be a valid http(s) URL", "danger") + return render_template("app_form.html", form_action=url_for("app_create")) + + # Ensure image stored as absolute URL + image = make_absolute(image) if image else "" + + with get_db() as conn: + conn.execute( + "INSERT INTO apps (name, image, url, enabled, priority, created_at) VALUES (?,?,?,?,?,?)", + (name, image, url, enabled, priority, datetime.utcnow().isoformat(timespec='seconds')), + ) + conn.commit() + flash("App added", "success") + return redirect(url_for("apps_list")) + + return render_template("app_form.html", form_action=url_for("app_create")) + + @app.route("/apps//edit", methods=["GET", "POST"]) + @login_required + def app_edit(app_id: int): + with get_db() as conn: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT id, name, image, url, enabled, priority FROM apps WHERE id=?", (app_id,)).fetchone() + if not row: + abort(404) + if request.method == "POST": + name = request.form.get("name", "").strip() + image_file = request.files.get("image_file") # NEW + url = request.form.get("url", "").strip() + enabled = 1 if request.form.get("enabled") == "on" else 0 + priority = int(request.form.get("priority", 0) or 0) + + # Prefer uploaded file > new URL > keep old value + new_image = row["image"] + saved_image_path = save_image(image_file) if image_file else None + if saved_image_path: + new_image = saved_image_path + else: + form_image = request.form.get("image", "").strip() + if form_image: + if not validate_url(form_image): + flash("Image must be a valid http(s) URL or upload a file", "danger") + return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) + new_image = form_image + + if not name: + flash("Name is required", "danger") + return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) + if not validate_url(url): + flash("App URL must be a valid http(s) URL", "danger") + return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) + + new_image = make_absolute(new_image) if new_image else "" + + with get_db() as conn: + conn.execute( + "UPDATE apps SET name=?, image=?, url=?, enabled=?, priority=? WHERE id=?", + (name, new_image, url, enabled, priority, app_id), + ) + conn.commit() + flash("App updated", "success") + return redirect(url_for("apps_list")) + return render_template("app_form.html", form_action=url_for("app_edit", app_id=app_id), data=row) + + @app.route("/apps//delete", methods=["POST"]) + @login_required + def app_delete(app_id: int): + with get_db() as conn: + conn.execute("DELETE FROM apps WHERE id=?", (app_id,)) + conn.commit() + flash("App deleted", "warning") + return redirect(url_for("apps_list")) + + # --------------------------- + # API Endpoint (public or protected — here public for simplicity) + # --------------------------- + @app.route("/api/apps", methods=["GET"]) + def api_apps(): + with get_db() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT id, name, image, url FROM apps WHERE enabled=1 ORDER BY priority DESC, name ASC" + ).fetchall() + data = [] + for r in rows: + d = dict(r) + img = d.get("image") or "" + d["image"] = make_absolute(img) if img else "" + data.append(d) + return jsonify({ + "version": 1, + "updated_at": datetime.utcnow().isoformat(timespec='seconds') + "Z", + "apps": data, + }) + + return app + + +# ---------------------------------------------------------------------------- +# DB bootstrap +# ---------------------------------------------------------------------------- +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS apps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + image TEXT, + url TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + priority INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); +""" + + +def init_db(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(DB_PATH) as conn: + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute(SCHEMA_SQL) + conn.commit() + + +# ---------------------------------------------------------------------------- +# Dev entrypoint +# ---------------------------------------------------------------------------- +if __name__ == "__main__": + app = create_app() + app.run(host="0.0.0.0", port=int(os.getenv("PORT", 5000)), debug=True) \ No newline at end of file diff --git a/launchpad.db b/launchpad.db new file mode 100644 index 0000000000000000000000000000000000000000..fb490ada97722069b8dcc628bdd3c37a1b60ce87 GIT binary patch literal 12288 zcmeI&L2uJA6bEo8MT|~jLx^fub6d5gN!oSOdLn}`Riy3cT#25lG;vppG|3W26UV`g zkHQz+g#*r_f;JEwcH!`Uij&xR&rW_fOF4h<3CZaq$yUCk9rBFmI(bDYAw*ZLrrJ6* zb@OQ5QP;Bnuh2>B>x-u$gO!N(?gYOCHYD z7XQv$Z8pojxwie0r~?532tWV=5P$##AOHafKmY;|fWX5AuAggl(!74zuRrOGvXE!= zG@%A%gL5;Nz7&C3q*3CBxjE-iWQZV%jVmFSMiO%)PNZ0fK&7csd)BtvUF+DkY`b&V zJ!&6x)j%taFP2iKdCxTEDsRO}CYPZf7jvE`3)$jDS!GzqhHWw1?sX1(-J@#%e?zu! n9{z!t0Rj+!00bZa0SG_<0uX=z1Rwx`|4Lv(t2OH7uMIx{h9;iu literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b2e4076 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.3 +python-dotenv==1.0.1 \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..5cd7977 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,27 @@ +:root { --gap: 12px; --pad: 16px; --accent: #2b6cb0; --danger: #c53030; } +* { box-sizing: border-box; } +body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; background:#f7fafc; color:#1a202c; } +.topbar { display:flex; justify-content:space-between; align-items:center; padding: var(--pad); background:#fff; border-bottom:1px solid #e2e8f0; position:sticky; top:0; } +.topbar .brand { font-weight:700; } +.topbar nav a { margin-left: 10px; text-decoration:none; color:#2d3748; } +.container { max-width: 980px; margin: 24px auto; padding: 0 var(--pad); } +.flash { margin-bottom: var(--pad); } +.flash-item { padding: 10px; border-radius:6px; margin-bottom:8px; background:#e6fffa; } +.flash-item.success { background:#c6f6d5; } +.flash-item.info { background:#bee3f8; } +.flash-item.danger { background:#fed7d7; } +.header-row { display:flex; justify-content:space-between; align-items:center; margin-bottom: var(--pad); } +.btn { display:inline-block; padding:8px 12px; border-radius:6px; background:#edf2f7; color:#1a202c; text-decoration:none; border:1px solid #cbd5e0; } +.btn.primary { background:#3182ce; color:white; border-color:#2b6cb0; } +.btn.danger { background:#e53e3e; color:white; border-color:#c53030; } +.btn.small { padding:6px 8px; font-size: 0.9rem; } +.table { width:100%; border-collapse: collapse; background:#fff; border:1px solid #e2e8f0; } +.table th, .table td { padding:10px; border-bottom:1px solid #edf2f7; vertical-align: middle; } +.table thead th { background:#f1f5f9; text-align:left; } +.icon { width: 32px; height: 32px; border-radius: 6px; object-fit: cover; } +.form { display:grid; gap: var(--gap); max-width: 600px; } +.form label { display:grid; gap:6px; } +.form input[type=text], .form input[type=password], .form input[type=url], .form input[type=number] { padding:10px; border:1px solid #cbd5e0; border-radius:6px; background:#fff; } +.form .checkbox { display:flex; align-items:center; gap:10px; } +.form-actions { display:flex; gap:10px; margin-top: 8px; } +.actions { white-space: nowrap; } \ No newline at end of file diff --git a/static/uploads/bell-icon-with-one-notification-1_20250910113657245202.png b/static/uploads/bell-icon-with-one-notification-1_20250910113657245202.png new file mode 100644 index 0000000000000000000000000000000000000000..85aa02e87f77b6192bd525c70155b4d6f7da00d5 GIT binary patch literal 22960 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 literal 0 HcmV?d00001 diff --git a/templates/app_form.html b/templates/app_form.html new file mode 100644 index 0000000..48d0284 --- /dev/null +++ b/templates/app_form.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit App' if data else 'Add App' }} – LaunchPad Admin{% endblock %} +{% block content %} +

{{ 'Edit App' if data else 'Add App' }}

+
+ + + + + + + {% if data and data.image %} +
+ Current image:
+ {{ data.name }} +
+ {% endif %} + + + + + + + +
+ Cancel + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/apps_list.html b/templates/apps_list.html new file mode 100644 index 0000000..616f28c --- /dev/null +++ b/templates/apps_list.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Apps – LaunchPad Admin{% endblock %} +{% block content %} +
+

Apps

+ + Add App +
+ +{% if apps %} + + + + + + + + {% for a in apps %} + + + + + + + + + + {% endfor %} + +
IDIconNameURLEnabledCreatedActions
{{ a.id }}{% if a.image %}{{ a.name }}{% else %}-{% endif %}{{ a.name }}{{ a.url }}{{ 'Yes' if a.enabled else 'No' }}{{ a.created_at }} + Edit +
+ +
+
+{% else %} +

No apps yet. Click “Add App” to create your first one.

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..bd3f7b8 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,33 @@ + + + + + + {% block title %}LaunchPad Admin{% endblock %} + + + +
+
LaunchPad Admin
+ +
+ +
+ {% with messages = get_flashed_messages(with_categories=True) %} + {% if messages %} +
+ {% for cat, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..77824bc --- /dev/null +++ b/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Login – LaunchPad Admin{% endblock %} +{% block content %} +

Login

+
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..b3c1b8f --- /dev/null +++ b/wsgi.py @@ -0,0 +1,2 @@ +from app import create_app +app = create_app() \ No newline at end of file