diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca07848..0bdad33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,20 +4,20 @@ on: [push, pull_request] jobs: test: - runs-on: debian-latest + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - - name: Install system dependencies - run: | - sudo apt update - sudo apt install -y python3 python3-pip python3-venv git + - uses: actions/checkout@v4 - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies run: | - python3 -m pip install --upgrade pip - python3 -m pip install ".[test]" + python -m pip install --upgrade pip + python -m pip install ".[test]" - name: Run tests run: pytest --cov=flaskpp diff --git a/.gitignore b/.gitignore index a50db39..c00d7da 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,21 @@ instance/ migrations/ translations/ app_configs/ +services/ logs/ flaskpp.egg-info/ dist/ +node/ +node_modules/ [files] .env -messages.pot \ No newline at end of file +.coverage +messages.pot +vite.config.*.js +package.json +package-lock.json +tailwind +tailwind.exe +tailwind.css +tsconfig.json \ No newline at end of file diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..0fef44e --- /dev/null +++ b/DOCS.md @@ -0,0 +1,398 @@ +# Flask++ Documentation + +### App Factory + +The default Flask app factory hasn't changed much. The FlaskPP class does a lot of repetitive work for you. +But the default factory which is also written into **project_root/main.py** by the `fpp init` command looks very similar: + +```python +from flaskpp import FlaskPP + +def create_app(config_name: str = "default"): + app = FlaskPP(__name__, config_name) + # TODO: Extend the Flask++ default setup with your own factory + return app + +app = create_app().to_asgi() +``` + +The FlaskPP class just extended Flask with basic factory tasks like setting up extensions and config. + +### Configuration + +There are two ways of configuring your apps. The first and most important one are app configs, which you can find in project_root/app_configs. +They are named like that: **[app_name].conf** and used by the `fpp run` command to load your apps. (Config variables are passed as environment.) + +With app configs you can control which extensions you want to use and pass secrets, defaults and every other data which you would usually write into your env files. +A basic Flask++ app.conf file looks like that: + +``` +[core] +SERVER_NAME = localhost +SECRET_KEY = supersecret + +[database] +DATABASE_URL = sqlite:///appdata.db + +[redis] +REDIS_URL = redis://redis:6379 + +[babel] +SUPPORTED_LOCALES = en;de + +[security] +SECURITY_PASSWORD_SALT = supersecret + +[mail] +MAIL_SERVER = +MAIL_PORT = 25 +MAIL_USE_TLS = True +MAIL_USE_SSL = False +MAIL_USERNAME = +MAIL_PASSWORD = +MAIL_DEFAULT_SENDER = noreply@example.com + +[jwt] +JWT_SECRET_KEY = supersecret + +[extensions] +EXT_SQLALCHEMY = 1 +EXT_SOCKET = 1 +EXT_BABEL = 0 +EXT_FST = 0 +EXT_AUTHLIB = 0 +EXT_MAILING = 0 +EXT_CACHE = 0 +EXT_API = 0 +EXT_JWT_EXTENDED = 0 + +[features] +FPP_PROCESSING = 1 +FRONTEND_ENGINE = 1 + +[dev] +DB_AUTOUPDATE = 0 + +[modules] +module_name = 1 +HOME_MODULE = module_name +``` + +And can be generated and configured automatically by running `fpp setup` inside your project root. + +The second way of configuring your app, is by using config classes. You may have noticed, that the Flask++ app factory takes an +config name argument. There you can provide your own config name if you like to. We provide a registration function, so you can +plug in your own config files with ease. But of course we also provide a default config which you could just extend by your own config class: + +```python +import os + +from flaskpp.app.config import register_config +# If you want to extend this config, you can import it: +# from flaskpp.app.config.default import DefaultConfig + + +@register_config('default') +class DefaultConfig: + # ------------------------------------------------- + # Core / Flask + # ------------------------------------------------- + SERVER_NAME = os.getenv("SERVER_NAME") + SECRET_KEY = os.getenv("SECRET_KEY", "151ca2beba81560d3fd5d16a38275236") + + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 + MAX_FORM_MEMORY_SIZE = 16 * 1024 * 1024 + + PROXY_FIX = False + PROXY_COUNT = 1 + + # ------------------------------------------------- + # Flask-SQLAlchemy & Flask-Migrate + # ------------------------------------------------- + SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///database.db") + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # ------------------------------------------------- + # Flask-Limiter (Rate Limiting) + # ------------------------------------------------- + RATELIMIT = True + RATELIMIT_STORAGE_URL = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/1" + RATELIMIT_DEFAULT = "500 per day; 100 per hour" + RATELIMIT_STRATEGY = "fixed-window" + + # ------------------------------------------------- + # Flask-SocketIO + # ------------------------------------------------- + SOCKETIO_MESSAGE_QUEUE = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/2" + SOCKETIO_CORS_ALLOWED_ORIGINS = "*" + + # ------------------------------------------------- + # Flask-BabelPlus (i18n/l10n) + # ------------------------------------------------- + BABEL_DEFAULT_LOCALE = "de" + SUPPORTED_LOCALES = os.getenv("SUPPORTED_LOCALES", BABEL_DEFAULT_LOCALE) + BABEL_DEFAULT_TIMEZONE = "Europe/Berlin" + BABEL_TRANSLATION_DIRECTORIES = "translations" + + # ------------------------------------------------- + # Flask-Security-Too + # ------------------------------------------------- + SECURITY_PASSWORD_SALT = os.getenv("SECURITY_PASSWORD_SALT", "8869a5e751c061792cd0be92b5631f25") + SECURITY_REGISTERABLE = True + SECURITY_SEND_REGISTER_EMAIL = False + SECURITY_UNAUTHORIZED_VIEW = None + SECURITY_TWO_FACTOR = False + + # ------------------------------------------------- + # Authlib (OAuth2 / OIDC) + # ------------------------------------------------- + OAUTH_CLIENTS = { + # For example: + # "github": { + # "client_id": os.getenv("GITHUB_CLIENT_ID"), + # "client_secret": os.getenv("GITHUB_CLIENT_SECRET"), + # "api_base_url": "https://api.github.com/", + # "authorize_url": "https://github.com/login/oauth/authorize", + # "access_token_url": "https://github.com/login/oauth/access_token", + # }, + } + + # ------------------------------------------------- + # Flask-Mailman + # ------------------------------------------------- + MAIL_SERVER = os.getenv("MAIL_SERVER", "localhost") + MAIL_PORT = int(os.getenv("MAIL_PORT", 25)) + MAIL_USE_TLS = True + MAIL_USE_SSL = False + MAIL_USERNAME = os.getenv("MAIL_USERNAME") + MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") + MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER", "noreply@example.com") + + # ------------------------------------------------- + # Flask-Caching (Redis) + # ------------------------------------------------- + CACHE_TYPE = "RedisCache" + CACHE_REDIS_URL = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/3" + CACHE_DEFAULT_TIMEOUT = 300 + + # ------------------------------------------------- + # Flask-Smorest (API + Marshmallow) + # ------------------------------------------------- + API_TITLE = "My API" + API_VERSION = "v1" + OPENAPI_VERSION = "3.0.3" + OPENAPI_URL_PREFIX = "/api" + OPENAPI_JSON_PATH = "openapi.json" + OPENAPI_REDOC_PATH = "/redoc" + OPENAPI_REDOC_URL = "https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js" + OPENAPI_SWAGGER_UI_PATH = "/swagger" + OPENAPI_SWAGGER_UI_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" + + # ------------------------------------------------- + # Flask-JWT-Extended + # ------------------------------------------------- + JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "119b385ec26411d271d9db8fd0fdc5c3") + JWT_ACCESS_TOKEN_EXPIRES = 3600 + JWT_REFRESH_TOKEN_EXPIRES = 86400 +``` + +### Full-Stack Features & FPP Magic + +Inside our app.conf example you may have noticed that there are two feature switches, which are set to 1 by default. +The first one is **FPP_PROCESSING** which brings a slightly changed app processor registration to offer you some fueatures +like request logging and socket default events. If you have enabled this, you would have to use the Flask++ +processing utils to overwrite our default processors: + +```python +from flaskpp.app.utils.processing import before_request + +@before_request +def before_app_request(): + # TODO: Handle app request + pass + +# Or if you are using EXT_SOCKET we provide a default handler: +from flaskpp.app.utils.processing import socket_event_handler + +@socket_event_handler # equivalent to socket.on("default_event") +def default_event(sid: str, data): + # TODO: Your own default handling + pass + +# If you decide to use FPP_PROCESSING you can register socket events with: +from flaskpp.app.socket import default_event + +@default_event("my_event") +def handle(data): + # TODO: Handle your default socket event + pass +``` + +And of course we do also have some JavaScript utility that matches with our socket default handlers: + +```javascript +/** + * To use this utility you just need to include this inside the head section of you base template: + * + * + * + * */ + +const socketScript = document.getElementById("fppSocketScript"); + +export function connectSocket() { + const domain = socketScript.dataset.socketDomain; + return io(domain, { + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + timeout: 20000 + }) +} +export let socket = connectSocket(); + + +export function emit(event, data=null, callback=null) { + socket.emit('default_event', { + event: event, + payload: data + }, callback); +} +``` + +Alright, before we talk about some further switch-less Flask++ magic... Let's talk about the second feature switch in our +app.conf file, which is **FRONTEND_ENGINE**. This switch enables you built in Vite engine. Your app and every module you may create has +got a Vite folder inside it, which contains a main.js entrypoint. This is generated by default as a template you can use to +integrate Vite into your Flask project. It will eiter run as `vite dev` if you run your app in debug mode or be built when starting your app +and then integrated using the .vite/manifest.json. If you want to integrate Vite files into your template, simply use: +`{{ vite_main("file.ending") }}` to integrate Vite files from your apps root and `{{ vite("file.ending") }}` inside module templates +to use the vite files of your modules. The framework does the configuration automatically for your. You can either write JavaScript or TypeScript. +And you can also work with Tailwind (Notice that there is a standalone Tailwind integration too. This is intended to +be fully seperated from your vite builds. We highly recommend to keep the autogenerated `@source not "../../vite"` part.), the default structure for +that is autogenerated as well. + +Okay, but now let's come to further Flask++ magic. The biggest switch-less feature is our module system. Modules look like little Flask apps +which can simply be plugged into your app using the app.conf file. This process can be automated, if you install or create your modules before +running `fpp setup`. To work with modules, just use the modules sub-cli: + +```bash +# To install a module use: +fpp modules install module_name --src [path/git-url] +# And in future a module hub is planned, on which you'll be able to share your modules +# as well as install modules from there by their name: +fpp modules install hub_module_name + +# To create a module use: +fpp modules create module_name +``` + +Our next feature is the i18n database (**EXT_BABEL**) with fallback to .po/.mo files for which we will offer an extra module to manage your translation keys +using a graphical web interface. ([FPP_i18n_module](https://github.com/GrowVolution/FPP_i18n_module) - coming soon.) But you can also manage your translations +by using our utility functions: + +```python +from flaskpp.app.data.babel import (add_entry, + remove_entry, remove_entries, + get_entry, get_entries) + +add_entry("en", "WELCOME_TEXT", "Welcome to our new website!") +# TODO: Further work with the i18n database +``` + +And if you use Flask Security Too (**EXT_FST**), you can easily modify and extend the fsqla mixin using our mixin decorators: + +```python +from flaskpp.app.data.fst_base import user_mixin, role_mixin +from flaskpp.app.extensions import db + +@user_mixin +class MyUserMixin: + bio = db.Column(db.String(512)) + + # TODO: Add your own features and functionality +``` + +Your mixin classes extend the user / role model, before the fsqla mixin extension is added. So be careful working with security features and utility. +In future, we'll add a priority feature, which will allow you to define the priority of your mixin when you decide to publish your own modules. + +### Running / Managing your apps + +Attentive readers may have also noticed the `app.to_asgi()` wrapper. (This wrapper automatically wraps your app into the correct format - so it is sensitive to the **EXT_SOCKET** switch.) +This feature is required, if you want to execute your apps with our built-in executing utility, because Flask++ is running your apps using Uvicorn to offer +cross-platform compatibility. You've got two options to run your apps: + +```bash +# To run your apps standalone and straight up: +fpp run [-a/--app] app_name [-p/--port] 5000 [-d/--debug] + +# If you would like to run and manage multiple apps at once: +fpp run [-i/--interactive] +``` + +### App Registry + +If you are system administrator, you can also use our automated app registry (of course also cross-platform compatible): + +```bash +fpp registry register app_name +fpp registry [start/stop] app_name +fpp registry remove app_name +``` + +On NT based systems make sure you have pywin32 installed in your Python environment. + +

Standalone Node & Tailwind implementation

+ +Flask++ provides a native integration of Tailwind CSS and Node.js. + +To use Tailwind, simply integrate: + +```html + + + ... + + {{ tailwind_main }} + + + {{ tailwind }} + + + {{ fpp_tailwind }} + ... + +``` + +into your templates and work with its CSS utility. The app will (re-)generate all **tailwind.css** files based on your **tailwind_raw.css** files (auto generated by +`fpp init` and `fpp modules create [mod_name]` in all **static/css** folders) when it is initialized. + +And if you'd like to work with the native standalone node bundle, you can simply use the Flask++ Node CLI: + +```bash +fpp node [npm/npx] [args] +``` + +Of course, you can use the Tailwind CLI in a similar way: + +```bash +fpp tailwind [args] +``` + +But to be able to use them, you must run `fpp init` at least once, if you are using a fresh installation of Flask++. + +### Get Help + +Of course, you can simply use `fpp [-h/--help]` to get a quick overview on how to use the Flask++ CLI. And if you still +have questions, which haven't been answered in this documentation **feel free to join the [discussions](https://github.com/GrowVolution/FlaskPlusPlus/discussions)**. + +--- + +**Thank you for working with Flask++** diff --git a/README.md b/README.md index 78cb53e..2f25014 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ # πŸ§ͺ Flask++ Tired of setting up Flask from scratch every single time? 🀯 -With **Flask++**, you can spin up and manage multiple apps in **under two minutes** ⚑. +With **Flask++**, you can spin up and manage multiple apps in **under two minutes**. ⚑ + +And most important: This is **still Flask**. You won't have to miss the feeling of developing Flask. +You've got **full control** about how much magic you would like to use and how much this framework should just feel like Flask. +Not only that: If you experience something, which doesn't feel like Flask anymore... Please feel free to raise an issue and we'll fix that for you asap. ✌🏼️ It comes with the most common Flask extensions pre-wired and ready to go. Configuration is dead simple – extensions can be bound or unbound with ease. @@ -38,12 +42,15 @@ fpp setup fpp run [-i/--interactive] # Or straight up: fpp run [-a/--app] myapp [-p/--port] 5000 [-d/--debug] + +# For further assistance use: +fpp --help ``` The setup wizard will guide you through the configuration step by step. 🎯 Once finished, your first app will be running – in less than the time it takes to make coffee. β˜•πŸ”₯ -Tip: In our [example folder](https://github.com/GrowVolution/FlaskPlusPlus/examples) we do also provide complete setup files for [Windows](https://github.com/GrowVolution/FlaskPlusPlus/examples/fpp_project/setup.bat) and [Linux](https://github.com/GrowVolution/FlaskPlusPlus/examples/fpp_project/setup.sh) servers. +Tip: In our [example folder](examples) we do also provide complete setup files for [Windows](examples/fpp_project/setup.bat) and [Linux](examples/fpp_project/setup.sh) servers. If your want to use them, just download the file you need into your project folder and do: ```bash @@ -57,8 +64,10 @@ In this case only on Windows systems you need to install Python before. On Linux ## 🧩 Modules -Inside the example folder you’ll also find an [example module](https://github.com/GrowVolution/FlaskPlusPlus/examples/example_module) to get you started. -Use it as a template to quickly build your own features. πŸ˜‰ +Inside the example folder you’ll also find an [example module](examples/example_module) to get you started. +Use it as an inspiration for your own modules or generate basic modules using the Flask++ CLI. πŸ˜‰ + +`fpp modules create module_name` --- @@ -101,14 +110,19 @@ server { --- -## 🌱 Let it grow +## πŸ“ Documentation +For further information about this framework and how to use it, you may like to read our [documentation](DOCS.md). 🫢🏼 + +--- + +### 🌱 Let it grow If you like this project, feel free to **fork it, open issues, or contribute ideas**. Every improvement makes life easier for the next developer. πŸ’š --- -## πŸ“œ License +### πŸ“œ License Released under the [MIT License](LICENSE). Do whatever you want with it – open-source, commercial, or both. Follow your heart. πŸ’― diff --git a/examples/example_module/__init__.py b/examples/example_module/__init__.py index d5b108d..27619a8 100644 --- a/examples/example_module/__init__.py +++ b/examples/example_module/__init__.py @@ -1,30 +1,9 @@ -from flask import Blueprint -from pathlib import Path - -from flaskpp import FlaskPP -from flaskpp.modules import require_extensions -from .data import init_models - -NAME = Path(__file__).parent.name -bp = Blueprint(NAME, __name__, template_folder="templates", static_folder="static") - - -@bp.context_processor -def context_processor(): - return dict( - NAME=NAME - ) - - -@require_extensions("sqlalchemy") -def register_module(app: FlaskPP, home: bool): - if home: - bp.static_url_path = f"/{NAME}/static" - else: - bp.url_prefix = f"/{NAME}" - - from .routes import init_routes - init_routes(bp) - - app.register_blueprint(bp) - init_models() +from flaskpp import Module + +module = Module( + __file__, + __name__, + [ + "sqlalchemy" + ] +) diff --git a/examples/example_module/data/__init__.py b/examples/example_module/data/__init__.py index 33dcecd..f1f82d0 100644 --- a/examples/example_module/data/__init__.py +++ b/examples/example_module/data/__init__.py @@ -5,8 +5,8 @@ def init_models(): - from .. import NAME + from .. import module for file in _package.rglob("*.py"): if file.stem == "__init__": continue - import_module(f"modules.{NAME}.data.{file.stem}") + import_module(f"{module.import_name}.data.{file.stem}") diff --git a/examples/example_module/handling/your_endpoint.py b/examples/example_module/handling/your_endpoint.py index 60658f8..6e4e273 100644 --- a/examples/example_module/handling/your_endpoint.py +++ b/examples/example_module/handling/your_endpoint.py @@ -1,6 +1,6 @@ from flask import flash, redirect, request, url_for, render_template -from .. import NAME +from .. import module from ..forms import ContactForm from ..data.your_dataset import YourModel from flaskpp.app.utils.translating import t @@ -19,9 +19,9 @@ def handle_request(): add_model(db_entry) flash(t("Thanks! Your message has been received."), "success") - return redirect(url_for(f"{NAME}.endpoint")) + return redirect(url_for(f"{module.safe_name}.endpoint")) if request.method == "POST" and not form.validate(): flash(t("Please check your inputs."), "danger") - return render_template("your_form.html", form=form) + return module.render_template("your_form.html", form=form) diff --git a/examples/example_module/manifest.json b/examples/example_module/manifest.json new file mode 100644 index 0000000..b06a31b --- /dev/null +++ b/examples/example_module/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Example Module", + "description": "Provides a short example on how to get started with Flask++ modules.", + "version": "0.2 Beta", + "author": "John Doe" +} \ No newline at end of file diff --git a/examples/example_module/routes.py b/examples/example_module/routes.py index 5a19165..97ae7b7 100644 --- a/examples/example_module/routes.py +++ b/examples/example_module/routes.py @@ -1,16 +1,24 @@ -from flask import Blueprint +from flask import flash, redirect -from .handling import your_endpoint -from .utils import render_template +from flaskpp import Module from flaskpp.app.utils.auto_nav import autonav_route from flaskpp.app.utils.translating import t +from flaskpp.utils import enabled +from .handling import your_endpoint -def init_routes(bp: Blueprint): - @bp.route("/") +def init_routes(mod: Module): + @mod.route("/") def index(): - return render_template("index.html") + return mod.render_template("index.html") + + @autonav_route(mod, "/vite-index", t("Vite Test")) + def vite_index(): + if not enabled("FRONTEND_ENGINE"): + flash("Vite is not enabled for this app.", "warning") + return redirect("/") + return mod.render_template("vite_index.html") - @autonav_route(bp, "/your-endpoint", t("Your Endpoint"), methods=["GET", "POST"]) + @autonav_route(mod, "/your-endpoint", t("Your Endpoint"), methods=["GET", "POST"]) def endpoint(): return your_endpoint.handle_request() diff --git a/examples/example_module/templates/index.html b/examples/example_module/templates/index.html index 113bc0d..0725d20 100644 --- a/examples/example_module/templates/index.html +++ b/examples/example_module/templates/index.html @@ -1,10 +1,15 @@ {% extends "base_example.html" %} + {% block title %}{{ _('Home') }}{% endblock %} +{% block head %}{{ tailwind }}{% endblock %} + {% block content %} -
-

{{ _('Welcome!') }}

-

{{ _('This is your wonderful new app.') }}

- {{ _('Picture') }} +
+

{{ _('Welcome!') }}

+

{{ _('This is your wonderful new app.') }}

+ + {{ _('Picture') }}
{% endblock %} diff --git a/examples/example_module/templates/vite_index.html b/examples/example_module/templates/vite_index.html new file mode 100644 index 0000000..970f804 --- /dev/null +++ b/examples/example_module/templates/vite_index.html @@ -0,0 +1,6 @@ +{% extends "base_example.html" %} + +{% block title %}{{ _('Home') }}{% endblock %} +{% block head %} + {{ vite('main.js') }} +{% endblock %} diff --git a/examples/example_module/templates/your_form.html b/examples/example_module/templates/your_form.html index a6c645e..46433e8 100644 --- a/examples/example_module/templates/your_form.html +++ b/examples/example_module/templates/your_form.html @@ -3,52 +3,50 @@ {% block title %}{{ _("Your Form") }}{% endblock %} {% block content %} -
-
-
-

{{ _("Contact") }}

- -
- {{ form.hidden_tag() }} - -
- {{ form.name.label(class="form-label") }} - {{ form.name(class="form-control") }} - {% for err in form.name.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.email.label(class="form-label") }} - {{ form.email(class="form-control") }} - {% for err in form.email.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.message.label(class="form-label") }} - {{ form.message(class="form-control", rows=5) }} - {% for err in form.message.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.agree(class="form-check-input") }} - {{ form.agree.label(class="form-check-label") }} - {% for err in form.agree.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.submit(class="btn btn-primary") }} -
- -
-
+
+
+

{{ _("Contact") }}

+ +
+ {{ form.hidden_tag() }} + +
+ {{ form.name.label(class="block mb-1 font-medium") }} + {{ form.name(class="w-full rounded border px-3 py-2") }} + {% for err in form.name.errors %} +
{{ err }}
+ {% endfor %} +
+ +
+ {{ form.email.label(class="block mb-1 font-medium") }} + {{ form.email(class="w-full rounded border px-3 py-2") }} + {% for err in form.email.errors %} +
{{ err }}
+ {% endfor %} +
+ +
+ {{ form.message.label(class="block mb-1 font-medium") }} + {{ form.message(class="w-full rounded border px-3 py-2", rows=5) }} + {% for err in form.message.errors %} +
{{ err }}
+ {% endfor %} +
+ +
+ {{ form.agree(class="w-4 h-4") }} + {{ form.agree.label(class="font-medium") }} + {% for err in form.agree.errors %} +
{{ err }}
+ {% endfor %} +
+ +
+ {{ form.submit(class="w-full bg-primary text-white px-4 py-2 rounded hover:bg-primary/90 cursor-pointer") }} +
+ +
{% endblock %} diff --git a/examples/example_module/utils.py b/examples/example_module/utils.py deleted file mode 100644 index f43db79..0000000 --- a/examples/example_module/utils.py +++ /dev/null @@ -1,7 +0,0 @@ -from flask import render_template as _render_template - -from . import NAME - - -def render_template(template: str, **context) -> str: - return _render_template(f"{NAME}/{template}", **context) diff --git a/examples/fpp_project/setup.bat b/examples/fpp_project/setup.bat index a0220dc..89892d7 100644 --- a/examples/fpp_project/setup.bat +++ b/examples/fpp_project/setup.bat @@ -38,7 +38,8 @@ rem - Flask++ Setup Toolchain - rem -------------------------------------- fpp init -fpp modules install example --src ..\example_module +fpp modules create example +rem fpp modules install example --src ..\example_module rem fpp modules install mymodule -s https://github.com/OrgaOrUser/fpp-module fpp setup fpp run --interactive diff --git a/examples/fpp_project/setup.sh b/examples/fpp_project/setup.sh index c3540ab..0f1c062 100644 --- a/examples/fpp_project/setup.sh +++ b/examples/fpp_project/setup.sh @@ -123,7 +123,8 @@ fi ##################################### fpp init -fpp modules install example --src ../example_module +fpp modules create example +#fpp modules install example --src ../example_module #fpp modules install mymodule -s https://github.com/OrgaOrUser/fpp-module fpp setup fpp run --interactive diff --git a/pyproject.toml b/pyproject.toml index 7489e10..22806ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "flaskpp" -version = "0.1.0" -description = "" +version = "0.2.4" +description = "A Flask based framework for fast and easy app creation. Experience the real power of Flask without boilerplate, but therefore a well balanced mix of magic and the default Flask framework." authors = [ { name = "Pierre", email = "pierre@growv-mail.org" } ] @@ -11,7 +11,6 @@ dependencies = [ "flask", "flask-sqlalchemy", "flask-migrate", - "flask-socketio", "flask-limiter", "flask-babelplus", "flask-mailman", @@ -23,6 +22,7 @@ dependencies = [ "pymysql", "python-dotenv", + "python-socketio[asgi]", "authlib", "uvicorn", "asgiref", @@ -30,6 +30,7 @@ dependencies = [ "redis", "pytz", "gitpython", + "tqdm", "typer" ] @@ -43,3 +44,24 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] where = ["src"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-data] +flaskpp = [ + "babel.cfg", + "app/templates/**/*.html", + "app/static/**/*", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "pytest-mock" +] + +[tool.pytest.ini_options] +pythonpath = ["src"] +addopts = "--disable-warnings" \ No newline at end of file diff --git a/src/flaskpp/__init__.py b/src/flaskpp/__init__.py index 72e865b..d77c864 100644 --- a/src/flaskpp/__init__.py +++ b/src/flaskpp/__init__.py @@ -1,18 +1,22 @@ -from flask import Flask, Blueprint +from flask import Flask, Blueprint, render_template as _render_template, url_for from werkzeug.middleware.proxy_fix import ProxyFix +from markupsafe import Markup from threading import Thread from datetime import datetime from asgiref.wsgi import WsgiToAsgi +from socketio import ASGIApp from pathlib import Path -import os - -from .app.config import CONFIG_MAP -from .app.config.default import DefaultConfig -from .app.utils.processing import handlers -from .app.i18n import init_i18n -from .modules import register_modules -from .utils import enabled -from .utils.debugger import start_session, log +from importlib import import_module +import os, json, re + +from flaskpp.app.config import CONFIG_MAP +from flaskpp.app.config.default import DefaultConfig +from flaskpp.app.utils.processing import handlers +from flaskpp.app.i18n import init_i18n +from flaskpp.modules import register_modules, ManifestError, ModuleError +from flaskpp.tailwind import generate_tailwind_css +from flaskpp.utils import enabled +from flaskpp.utils.debugger import start_session, log, exception _fpp_default = Blueprint("fpp_default", __name__, static_folder=(Path(__file__).parent / "app" / "static").resolve(), @@ -75,8 +79,9 @@ def __init__(self, import_name: str, config_name: str): x_port=count, x_prefix=count) - from .app.extensions import limiter - limiter.init_app(self) + if self.config["RATELIMIT"]: + from flaskpp.app.extensions import limiter + limiter.init_app(self) fpp_processing = enabled("FPP_PROCESSING") if fpp_processing: @@ -85,8 +90,8 @@ def __init__(self, import_name: str, config_name: str): ext_database = enabled("EXT_SQLALCHEMY") db_updater = None if ext_database: - from .app.extensions import db, migrate - from .app.data import init_models + from flaskpp.app.extensions import db, migrate + from flaskpp.app.data import init_models db.init_app(self) migrate.init_app(self, db) init_models() @@ -94,18 +99,14 @@ def __init__(self, import_name: str, config_name: str): if enabled("DB_AUTOUPDATE"): db_updater = Thread(target=db_autoupdate, args=(self,)) - if enabled("EXT_SOCKET"): - from .app.extensions import socket - socket.init_app(self) - - if fpp_processing: - socket.on("default_event")(handlers["socket_event_handler"]) - socket.on_error_default(handlers["handle_socket_error"]) + if enabled("EXT_SOCKET") and fpp_processing: + from flaskpp.app.extensions import socket + socket.on("default_event")(handlers["socket_event_handler"]) if enabled("EXT_BABEL"): - from .app.extensions import babel - from .app.i18n import DBDomain - from .app.utils.translating import set_locale + from flaskpp.app.extensions import babel + from flaskpp.app.i18n import DBDomain + from flaskpp.app.utils.translating import set_locale domain = DBDomain() babel.init_app(self, default_domain=domain) self.extensions["babel_domain"] = domain @@ -116,40 +117,186 @@ def __init__(self, import_name: str, config_name: str): raise RuntimeError("For EXT_FST EXT_SQLALCHEMY extension must be enabled.") from flask_security import SQLAlchemyUserDatastore - from .app.extensions import security, db - from .app.data.fst_base import UserBase, RoleBase + from flaskpp.app.extensions import security, db + from flaskpp.app.data.fst_base import User, Role security.init_app( self, - SQLAlchemyUserDatastore(db, UserBase, RoleBase) + SQLAlchemyUserDatastore(db, User, Role) ) if enabled("EXT_AUTHLIB"): - from .app.extensions import oauth + from flaskpp.app.extensions import oauth oauth.init_app(self) if enabled("EXT_MAILING"): - from .app.extensions import mailer + from flaskpp.app.extensions import mailer mailer.init_app(self) if enabled("EXT_CACHE"): - from .app.extensions import cache + from flaskpp.app.extensions import cache cache.init_app(self) if enabled("EXT_API"): - from .app.extensions import api + from flaskpp.app.extensions import api api.init_app(self) if enabled("EXT_JWT_EXTENDED"): - from .app.extensions import jwt + from flaskpp.app.extensions import jwt jwt.init_app(self) + generate_tailwind_css(self) + self.register_blueprint(_fpp_default) + self.url_prefix = "" register_modules(self) + self.static_url_path = f"{self.url_prefix}/static" + + if enabled("FRONTEND_ENGINE"): + from flaskpp.fpp_node.vite import Frontend + engine = Frontend(self) + self.context_processor(lambda: { + "vite_main": engine.vite + }) + self.frontend_engine = engine init_i18n(self) if db_updater: db_updater.start() - def to_asgi(self) -> WsgiToAsgi: - return WsgiToAsgi(self) + self._asgi_app = None + + def to_asgi(self) -> WsgiToAsgi | ASGIApp: + if self._asgi_app is not None: + return self._asgi_app + + app = WsgiToAsgi(self) + if enabled("EXT_SOCKET"): + from flaskpp.app.extensions import socket + self._asgi_app = ASGIApp(socket, other_asgi_app=app) + return self._asgi_app + self._asgi_app = app + return app + + +class Module(Blueprint): + def __init__(self, file: str, import_name: str, required_extensions: list = None): + if not "modules." in import_name: + raise ModuleError("Modules have to be created in the modules package.") + + self.name = import_name.split(".")[-1] + self.import_name = import_name + self.root_path = Path(file).parent + manifest = self.root_path / "manifest.json" + self.info = self._load_manifest(manifest) + self.safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", self.name) + self.extensions = required_extensions or [] + self.context = { + "NAME": self.safe_name, + } + self.home = False + + from flaskpp.app.extensions import require_extensions + self.enable = require_extensions(*self.extensions)(self._enable) + + super().__init__( + self.safe_name, + import_name, + static_folder=(Path(self.root_path) / "static") + ) + + def __repr__(self): + return f"<{self.info['name']} {self.version}> {self.info.get('description', '')}" + + def _enable(self, app: FlaskPP, home: bool): + if home: + self.static_url_path = "/static" + app.url_prefix = "/app" + self.home = True + else: + self.url_prefix = f"/{self.safe_name}" + self.static_url_path = f"/{self.safe_name}/static" + + try: + routes = import_module(f"{self.import_name}.routes") + init = getattr(routes, "init_routes", None) + if not init: + raise ImportError("Missing init function in routes.") + init(self) + except (ModuleNotFoundError, ImportError, TypeError) as e: + log("warn", f"Failed to register routes for {self.name}: {e}") + + if "sqlalchemy" in self.extensions: + try: + data = import_module(f"{self.import_name}.data") + init = getattr(data, "init_models", None) + if not init: + raise ImportError("Missing init function in data.") + init() + except (ModuleNotFoundError, ImportError, TypeError) as e: + log("warn", f"Failed to initialize models for {self.name}: {e}") + + if enabled("FRONTEND_ENGINE"): + from flaskpp.fpp_node.vite import Frontend + engine = Frontend(self) + self.context["vite"] = engine.vite + self.frontend_engine = engine + + self.context_processor(lambda: dict( + **self.context, + tailwind=Markup(f"") + )) + app.register_blueprint(self) + + def _load_manifest(self, manifest: Path) -> dict: + if not manifest.exists(): + raise FileNotFoundError(f"Manifest file for {self.name} not found.") + + try: + module_data = json.loads(manifest.read_text()) + except json.decoder.JSONDecodeError: + raise ManifestError(f"Invalid format for manifest of {self.name}.") + + if not "name" in module_data: + module_data["name"] = self.name + else: + self.name = module_data["name"] + + if not "description" in module_data: + log("warn", f"Missing description of {module_data['name']}.") + + if not "version" in module_data: + raise ManifestError("Module version not defined.") + + if not "author" in module_data: + log("warn", f"Author of {module_data['name']} not defined.") + + return module_data + + @property + def version(self) -> str: + version_str = self.info.get("version", "").lower().strip() + if not version_str: + raise ManifestError("Module version not defined.") + + if " " in version_str and not (version_str.endswith("alpha") or version_str.endswith("beta")): + raise ManifestError("Invalid version string format.") + + if version_str.startswith("v"): + version_str = version_str[1:] + + try: + v_numbers = version_str.split(" ")[0].split(".") + if len(v_numbers) > 3: + raise ManifestError("Too many version numbers.") + + for v_number in v_numbers: + int(v_number) + except ValueError: + raise ManifestError("Invalid version numbers.") + + return version_str + + def render_template(self, template: str, **context) -> str: + render_name = template if self.home else f"{self.safe_name}/{template}" + return _render_template(render_name, **context) diff --git a/src/flaskpp/__main__.py b/src/flaskpp/__main__.py index 9ae637f..805efa8 100644 --- a/src/flaskpp/__main__.py +++ b/src/flaskpp/__main__.py @@ -1,4 +1,4 @@ -from .cli import main +from flaskpp.cli import main if __name__ == "__main__": main() diff --git a/src/flaskpp/app/config/default.py b/src/flaskpp/app/config/default.py index 829a4a6..492e79c 100644 --- a/src/flaskpp/app/config/default.py +++ b/src/flaskpp/app/config/default.py @@ -1,6 +1,6 @@ import os -from ..config import register_config +from flaskpp.app.config import register_config @register_config('default') @@ -26,6 +26,7 @@ class DefaultConfig: # ------------------------------------------------- # Flask-Limiter (Rate Limiting) # ------------------------------------------------- + RATELIMIT = True RATELIMIT_STORAGE_URL = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/1" RATELIMIT_DEFAULT = "500 per day; 100 per hour" RATELIMIT_STRATEGY = "fixed-window" diff --git a/src/flaskpp/app/data/babel.py b/src/flaskpp/app/data/babel.py index f378c1e..fb3363d 100644 --- a/src/flaskpp/app/data/babel.py +++ b/src/flaskpp/app/data/babel.py @@ -1,5 +1,5 @@ -from . import add_model, delete_model, commit -from ..extensions import db +from flaskpp.app.data import add_model, delete_model, commit +from flaskpp.app.extensions import db class I18nMessage(db.Model): diff --git a/src/flaskpp/app/data/fst_base.py b/src/flaskpp/app/data/fst_base.py index 5c0e0f0..22863c7 100644 --- a/src/flaskpp/app/data/fst_base.py +++ b/src/flaskpp/app/data/fst_base.py @@ -1,6 +1,50 @@ from flask_security.models import fsqla_v3 as fsqla +import inspect + +from flaskpp.app.extensions import db + +_user_mixins: list[type] = [] +_role_mixins: list[type] = [] + + +def _valid_mixin(cls, kind: str): + if not inspect.isclass(cls): + raise TypeError(f"{kind} mixin must be a class.") + if hasattr(cls, "__tablename__"): + raise TypeError(f"{kind} mixins must not define tables.") + + +def user_mixin(cls): + _valid_mixin(cls, "User") + _user_mixins.append(cls) + return cls + + +def role_mixin(cls): + _valid_mixin(cls, "Role") + _role_mixins.append(cls) + return cls + + +def _build_user_model(): + bases = tuple(_user_mixins) + (db.Model, fsqla.FsUserMixin) + + return type( + "User", + bases, + {} + ) + + +def _build_role_model(): + bases = tuple(_role_mixins) + (db.Model, fsqla.FsRoleMixin) + + return type( + "Role", + bases, + {} + ) -from ..extensions import db user_roles = db.Table( "user_roles", @@ -11,9 +55,5 @@ fsqla.FsModels.set_db_info(db) -class UserBase(db.Model, fsqla.FsUserMixin): - pass - - -class RoleBase(db.Model, fsqla.FsRoleMixin): - pass +User = _build_user_model() +Role = _build_role_model() diff --git a/src/flaskpp/app/extensions.py b/src/flaskpp/app/extensions.py index e11f3e9..2d19ae9 100644 --- a/src/flaskpp/app/extensions.py +++ b/src/flaskpp/app/extensions.py @@ -2,7 +2,7 @@ from flask_limiter.util import get_remote_address from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate -from flask_socketio import SocketIO +from socketio import AsyncServer from flask_babelplus import Babel from flask_security import Security from authlib.integrations.flask_client import OAuth @@ -10,11 +10,15 @@ from flask_caching import Cache from flask_smorest import Api from flask_jwt_extended import JWTManager +from functools import wraps + +from flaskpp.utils import enabled +from flaskpp.utils.debugger import log limiter = Limiter(get_remote_address) db = SQLAlchemy() migrate = Migrate() -socket = SocketIO() +socket = AsyncServer(async_mode="asgi", cors_allowed_origins="*") babel = Babel() security = Security() oauth = OAuth() @@ -22,3 +26,20 @@ cache = Cache() api = Api() jwt = JWTManager() + + +def require_extensions(*extensions): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + for ext in extensions: + if not isinstance(ext, str): + log("warn", f"Invalid extension '{ext}'.") + continue + + if not enabled(f"EXT_{ext.upper()}"): + raise RuntimeError(f"Extension '{ext}' is not enabled.") + return func(*args, **kwargs) + + return wrapper + return decorator diff --git a/src/flaskpp/app/i18n.py b/src/flaskpp/app/i18n.py index 0d76340..b4173b2 100644 --- a/src/flaskpp/app/i18n.py +++ b/src/flaskpp/app/i18n.py @@ -2,9 +2,8 @@ from babel.support import Translations from flask import Flask, current_app -from .data.babel import I18nMessage -from .utils.translating import t, tn, get_locale -from ..utils.debugger import debug_msg +from flaskpp.app.data.babel import I18nMessage +from flaskpp.app.utils.translating import t, tn, get_locale class DBMergedTranslations(Translations): @@ -25,20 +24,16 @@ def _db_get(self, msgid): def gettext(self, message): db_val = self._db_get(message) if db_val: - debug_msg(f"[i18n] DB hit: {message!r} β†’ {db_val!r}") return db_val mo_val = self._wrapped.gettext(message) - debug_msg(f"[i18n] MO/fallback: {message!r} β†’ {mo_val!r}") return mo_val def ngettext(self, singular, plural, n): key = plural if n != 1 else singular db_val = self._db_get(key) if db_val: - debug_msg(f"[i18n] DB plural hit: {key!r} β†’ {db_val!r}") return db_val mo_val = self._wrapped.ngettext(singular, plural, n) - debug_msg(f"[i18n] MO plural: {singular!r}/{plural!r} β†’ {mo_val!r}") return mo_val @@ -52,7 +47,6 @@ def get_translations(self): domain=self.domain or "messages" ) - debug_msg(f"domain={self.domain}, locale={locale}, has_wrapped={wrapped is not None}") return DBMergedTranslations(wrapped, domain=self.domain, locale=locale) diff --git a/src/flaskpp/app/static/css/tailwind_raw.css b/src/flaskpp/app/static/css/tailwind_raw.css new file mode 100644 index 0000000..54eb724 --- /dev/null +++ b/src/flaskpp/app/static/css/tailwind_raw.css @@ -0,0 +1,136 @@ +@import "tailwindcss"; + +@theme { + /* ... */ +} + +@layer base { + body { + @apply bg-slate-400 text-black min-h-screen w-screen; + } + + header { + @apply sticky top-0 z-50; + } + + nav { + @apply w-full bg-slate-800 text-white shadow-md; + } + + main { + @apply flex flex-col items-center justify-center; + min-height: 100dvh; + } + + footer { + @apply w-full mt-auto bg-slate-800 text-white text-sm shadow-md; + } +} + +@layer components { + .nav-inner-div { + @apply mx-auto flex max-w-7xl items-center justify-between px-4 py-3; + } + + .nav-brand { + @apply text-xl font-semibold tracking-tight transition hover:opacity-90; + } + + .nav-collapse-btn { + @apply rounded-md border p-2 transition hover:bg-white/10 md:hidden; + } + + .nav-collapse-btn-stripe { + @apply block h-0.5 w-6 rounded-full bg-white; + } + + .nav-collapse { + @apply absolute top-full left-0 w-full flex flex-col gap-1 bg-slate-800 px-4 py-3 shadow-md md:static md:flex md:w-auto md:flex-row md:bg-transparent md:p-0 md:shadow-none; + } + + .nav-link { + @apply block rounded-md px-3 max-md:py-2 transition; + } + + .nav-link.active { + @apply max-md:bg-white/20 font-semibold; + } + + .nav-link.inactive { + @apply max-md:hover:bg-white/30 md:hover:font-semibold; + } + + .flash-container { + @apply fixed top-20 right-4 z-50 w-full max-w-sm space-y-3; + } + + .flash { + @apply flex items-start justify-between gap-3 rounded-lg border-l-4 px-4 py-3 shadow-md bg-white; + } + + .flash-text { + @apply text-sm leading-relaxed; + } + + .flash.success { + @apply border-green-500 text-green-800; + } + + .flash.warning { + @apply border-yellow-500 text-yellow-800; + } + + .flash.danger { + @apply border-red-500 text-red-800; + } + + .flash.info { + @apply border-blue-500 text-blue-800; + } + + .flash.default { + @apply border-slate-400 text-slate-800; + } + + .flash button { + @apply ml-2 text-lg leading-none opacity-60 hover:opacity-100 transition; + } + + .modal { + @apply hidden fixed inset-0 z-50 items-center justify-center bg-black/30 backdrop-blur-sm shadow-md px-4; + } + + .modal-body { + @apply w-full max-w-lg rounded-xl bg-white shadow-xl ring-1 ring-black/5 animate-[fadeIn_0.2s_ease-out]; + } + + .modal-header { + @apply flex items-center justify-between px-5 py-4 border-b; + } + + .modal-header .headline { + @apply text-lg font-semibold text-slate-800; + } + + .modal-header button { + @apply text-slate-400 hover:text-slate-700 transition text-xl leading-none; + } + + .modal-content { + @apply px-5 py-4 text-sm text-slate-700 leading-relaxed; + } + + .modal-footer { + @apply flex justify-end gap-2 px-5 py-3 border-t bg-slate-50 rounded-b-xl; + } + + .footer-inner-div { + @apply max-w-7xl mx-auto px-4 py-3 text-center opacity-80; + } +} + +@layer utilities { + .wrap-center { + @apply flex items-center justify-center; + } +} \ No newline at end of file diff --git a/src/flaskpp/app/static/css/themes/triade_green.css b/src/flaskpp/app/static/css/themes/triade_green.css deleted file mode 100644 index 8724aa3..0000000 --- a/src/flaskpp/app/static/css/themes/triade_green.css +++ /dev/null @@ -1,144 +0,0 @@ -/* CREATED BASED ON PALETTON.COM */ - -:root { - /* Primary */ - --color-primary-light: #8DCF8A; - --color-primary: #5AAC56; - --color-primary-mid: #328A2E; - --color-primary-dark: #156711; - --color-primary-darker: #034500; - - /* Secondary #1 */ - --color-sec1-light: #FFD0AA; - --color-sec1: #D4996A; - --color-sec1-mid: #AA6B39; - --color-sec1-dark: #804415; - --color-sec1-darker: #552600; - - /* Secondary #2 */ - --color-sec2-light: #CB87AF; - --color-sec2: #A95486; - --color-sec2-mid: #872D62; - --color-sec2-dark: #651142; - --color-sec2-darker: #440028; - - /* Contrast */ - --color-light: #f8f9fa; - --color-dark: #212529; - - /* BOOTSTRAP COLOR OVERRIDES */ - - /* Primary theme */ - --bs-primary: var(--color-primary); - --bs-primary-rgb: 90, 172, 86; - - --bs-primary-text-emphasis: var(--color-primary-dark); - --bs-primary-bg-subtle: var(--color-primary-light); - - /* Secondary */ - --bs-secondary: var(--color-sec2-mid); - --bs-secondary-rgb: 135, 45, 98; - - --bs-secondary-text-emphasis: var(--color-sec2-dark); - --bs-secondary-bg-subtle: var(--color-sec2-light); - - /* Further Defaults */ - --bs-success: var(--color-primary-mid); - --bs-warning: var(--color-sec1-light); - --bs-danger: var(--color-sec2-dark); - --bs-info: var(--color-sec2-light); - - /* Buttons */ - --bs-btn-border-radius: 0.45rem; - --bs-btn-padding-y: 0.45rem; - --bs-btn-padding-x: 1rem; -} - - -/* BOOTSTRAP BUTTON OVERRIDES */ - -.btn-primary { - background-color: var(--color-primary); - border-color: var(--color-primary-mid); -} - -.btn-primary:hover, -.btn-primary:focus { - background-color: var(--color-primary-mid); - border-color: var(--color-primary-dark); -} - -.btn-secondary { - background-color: var(--color-sec2-mid); - border-color: var(--color-sec2-dark); -} - -.btn-secondary:hover, -.btn-secondary:focus { - background-color: var(--color-sec2-dark); - border-color: var(--color-sec2-darker); -} - -/* Custom Buttons */ - -.btn-earth { - background-color: var(--color-sec1-mid); - border-color: var(--color-sec1-dark); - color: #fff; -} - -.btn-earth:hover { - background-color: var(--color-sec1-dark); - border-color: var(--color-sec1-darker); -} - -.btn-magenta { - background-color: var(--color-sec2); - border-color: var(--color-sec2-dark); - color: #fff; -} - -.btn-magenta:hover { - background-color: var(--color-sec2-dark); - border-color: var(--color-sec2-darker); -} - - -/* BACKGROUND UTILITY */ - -.bg-primary-light { background-color: var(--color-primary-light) !important; } -.bg-primary-dark { background-color: var(--color-primary-darker) !important; } -.bg-earth { background-color: var(--color-sec1-mid) !important; } -.bg-earth-dark { background-color: var(--color-sec1-dark) !important; } -.bg-magenta { background-color: var(--color-sec2-mid) !important; } -.bg-magenta-dark { background-color: var(--color-sec2-dark) !important; } - - -/* TEXT UTILITY */ - -.text-primary-dark { color: var(--color-primary-dark) !important; } -.text-earth { color: var(--color-sec1-mid) !important; } -.text-magenta { color: var(--color-sec2-mid) !important; } -.text-magenta-dark { color: var(--color-sec2-dark) !important; } - - -/* GENERAL UI & FURTHER UTILITY */ - -.form-control:focus { - border-color: var(--color-primary-mid); - box-shadow: 0 0 0 0.2rem rgba(90, 172, 86, 0.25); -} - -.card { - border: 1px solid var(--color-primary-light); - border-radius: 0.75rem; -} - -.card-header { - background-color: var(--color-primary-light); - color: var(--color-primary-dark); -} - -.card-title { - color: var(--color-primary-mid); -} diff --git a/src/flaskpp/app/static/favicon.png b/src/flaskpp/app/static/img/favicon.png similarity index 100% rename from src/flaskpp/app/static/favicon.png rename to src/flaskpp/app/static/img/favicon.png diff --git a/src/flaskpp/app/static/js/base.js b/src/flaskpp/app/static/js/base.js index 6508fcc..5d4cd10 100644 --- a/src/flaskpp/app/static/js/base.js +++ b/src/flaskpp/app/static/js/base.js @@ -1,31 +1,87 @@ -import { socket, emit } from "/static/js/socket.js"; +import { socket, emit } from "/fpp-static/js/socket.js"; -const flashContainer = document.getElementById('flashContainer'); -const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal')); +function getFocusable(elem) { + return ( + elem.querySelector( + "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])" + ) + ); +} + +function showModal(elem) { + elem._trigger = document.activeElement; + + elem.classList.remove("hidden"); + elem.classList.add("flex"); + elem.removeAttribute("inert"); + + const focusable = getFocusable(elem); + focusable?.focus(); +} + +function hideModal(elem) { + elem.classList.add("hidden"); + elem.classList.remove("flex"); + elem.setAttribute("inert", ""); + + if (elem._trigger) { + elem._trigger.focus(); + elem._trigger = null; + } +} + +function bindModalCloseEvents(modalElem) { + modalElem.querySelectorAll("[data-modal-close]").forEach(btn => { + btn.addEventListener("click", () => hideModal(modalElem)); + }); + + modalElem.addEventListener("mousedown", (ev) => { + if (ev.target === modalElem) hideModal(modalElem); + }); +} + +document.addEventListener("keydown", ev => { + if (ev.key !== "Escape") return; + + const openModal = document.querySelector(".modal:not(.hidden)"); + if (openModal) hideModal(openModal); +}); + + +const confirmModal = document.getElementById('confirmModal'); +const confirmTitle = document.getElementById('dialogConfirmTitle'); const confirmText = document.getElementById('dialogConfirmText'); +const confirmBody = document.getElementById('dialogConfirmBody'); const confirmBtn = document.getElementById('dialogConfirmBtn'); const dismissBtn = document.getElementById('dialogDismissBtn'); -const infoModal = new bootstrap.Modal(document.getElementById('infoModal')); +const infoModal = document.getElementById('infoModal'); const infoTitle = document.getElementById('infoModalTitle'); const infoText = document.getElementById('infoModalText'); const infoBody = document.getElementById('infoModalBody'); -export function flash(message, category) { - flashContainer.innerHTML = ` - - ` -} - - -export async function confirmDialog(message, category) { +export async function confirmDialog(title, message, html, category) { confirmText.innerHTML = message.replace(/\n/g, "
"); - confirmBtn.className = `btn btn-${category}`; + confirmBtn.className = + `inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-semibold + focus:outline-none focus:ring-2 focus:ring-primary/40 transition text-white + ${category === 'danger' ? 'bg-red-600 hover:bg-red-700' : ''} + ${category === 'success' ? 'bg-green-600 hover:bg-green-700' : ''} + ${category === 'info' ? 'bg-blue-600 hover:bg-blue-700' : ''} + ${category === 'warning' ? 'bg-yellow-600 hover:bg-yellow-700' : ''} + `; + + if (message) { + confirmBody.classList.add('hidden'); + confirmText.classList.remove('hidden'); + confirmText.textContent = message; + } else { + confirmText.classList.add('hidden'); + confirmBody.classList.remove('hidden'); + confirmBody.innerHTML = html; + } return new Promise((resolve) => { function onConfirm() { @@ -41,29 +97,46 @@ export async function confirmDialog(message, category) { function cleanup() { confirmBtn.removeEventListener('click', onConfirm); dismissBtn.removeEventListener('click', onDismiss); - confirmModal.hide(); + hideModal(confirmModal); } confirmBtn.addEventListener('click', onConfirm); dismissBtn.addEventListener('click', onDismiss); - confirmModal.show(); + showModal(confirmModal); }); } - export function showInfo(title, message, html) { infoTitle.textContent = title; if (message) { - infoBody.classList.add('d-none'); - infoText.classList.remove('d-none') + infoBody.classList.add('hidden'); + infoText.classList.remove('hidden'); infoText.textContent = message; } else { - infoText.classList.add('d-none'); - infoBody.classList.remove('d-none'); + infoText.classList.add('hidden'); + infoBody.classList.remove('hidden'); infoBody.innerHTML = html; } - infoModal.show(); + + showModal(infoModal); +} + + +const flashContainer = document.getElementById('flashContainer'); + +export function flash(message, category) { + flashContainer.innerHTML = ` +
+ + ${message} + + +
+ ` } @@ -89,7 +162,6 @@ export async function _(key) { }); } - export async function _n(singular, plural, count) { return new Promise((resolve) => { emit("_n", { @@ -133,4 +205,26 @@ socket.on('error', async (message) => { const errLabel = await _("Error Message:"); showInfo(title, `${errMsg}${errLabel} "${message}".`); +}); + + +window.FPP = { + confirmDialog: confirmDialog, + showInfo: showInfo, + flash: flash, + safe_: safe_, + _: _, + _n: _n, + socketHtmlInject: socketHtmlInject, + socket: socket, + emit: emit +} + + +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll(".modal").forEach(modal => { + modal.setAttribute("inert", ""); + hideModal(modal); + bindModalCloseEvents(modal); + }); }); \ No newline at end of file diff --git a/src/flaskpp/app/static/js/socket.js b/src/flaskpp/app/static/js/socket.js index 25aaeac..5c15971 100644 --- a/src/flaskpp/app/static/js/socket.js +++ b/src/flaskpp/app/static/js/socket.js @@ -1,4 +1,4 @@ -const socketScript = document.getElementById("socketScript"); +const socketScript = document.getElementById("fppSocketScript"); export function connectSocket() { const domain = socketScript.dataset.socketDomain; diff --git a/src/flaskpp/app/templates/404.html b/src/flaskpp/app/templates/404.html index d9284d7..60d0697 100644 --- a/src/flaskpp/app/templates/404.html +++ b/src/flaskpp/app/templates/404.html @@ -1,7 +1,19 @@ {% extends "base_example.html" %} {% block title %}{{ _('Not found') }}{% endblock %} {% block content %} -

{{ _('404 - Page not found') }}

-

{{ _("We are sorry, but the requested page doesn't exist.") }}

-

{{ _('Back Home') }}

+
+
+

404

+

+ {{ _("We are sorry, but the requested page doesn't exist.") }} +

+ + + {{ _("Back Home") }} + +
+
{% endblock %} diff --git a/src/flaskpp/app/templates/base_example.html b/src/flaskpp/app/templates/base_example.html index 74cfcb1..71bfd2e 100644 --- a/src/flaskpp/app/templates/base_example.html +++ b/src/flaskpp/app/templates/base_example.html @@ -1,86 +1,109 @@ - + - + - - + {{ fpp_tailwind }} - + {% block title %}Flask++ App{% endblock %} - {% block title %}Flask App{% endblock %} + {% if enabled("EXT_SOCKET") %} + + - - - - - - + + {% endif %} {% block head %}{% endblock %} - + +
+ - {% include "flashing.html" %} -
+ {% include "flashing.html" %} + +
+ {% block content %}{% endblock %} +
-
- {% block content %}{% endblock %} -
- - -
- © {{ current_year|default(2025) }} -
- -{% include "modals/info_modal.html" %} -{% include "modals/confirm_modal.html" %} - - +
+ +
+ + {% if enabled("EXT_SOCKET") %} + {% include "modals/confirm_modal.html" %} + {% include "modals/info_modal.html" %} + {% endif %} + + -{% block scripts %}{% endblock %} + {% block scripts %}{% endblock %} diff --git a/src/flaskpp/app/templates/base_modal.html b/src/flaskpp/app/templates/base_modal.html index 86e6126..dea7c7f 100644 --- a/src/flaskpp/app/templates/base_modal.html +++ b/src/flaskpp/app/templates/base_modal.html @@ -1,16 +1,28 @@ -