commit 655e20a47692f66f58d775b006384205c726f455 Author: Marius Robert Macamete Date: Wed Sep 10 14:56:26 2025 +0300 Initial commit for LaunchPad app 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 0000000..fb490ad Binary files /dev/null and b/launchpad.db differ 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 0000000..85aa02e Binary files /dev/null and b/static/uploads/bell-icon-with-one-notification-1_20250910113657245202.png differ 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