From d302261d07ad17debbfac58b8329852aefbb06d3 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Mon, 10 Nov 2025 21:19:38 +0100 Subject: [PATCH] Added Layout and UserProfile --- src/app.py | 18 +++++- src/myfasthtml/assets/myfasthtml.css | 1 + src/myfasthtml/auth/routes.py | 4 +- src/myfasthtml/controls/BaseControl.py | 7 -- src/myfasthtml/controls/Layout.py | 86 ++++++++----------------- src/myfasthtml/controls/UserProfile.py | 46 +++++++++++++ src/myfasthtml/controls/helpers.py | 4 +- src/myfasthtml/core/bindings.py | 4 +- src/myfasthtml/core/commands.py | 4 +- src/myfasthtml/core/instances.py | 89 ++++++++++++++++++++++++++ src/myfasthtml/core/utils.py | 55 +++++++++++++++- src/myfasthtml/myfastapp.py | 18 +++--- 12 files changed, 249 insertions(+), 87 deletions(-) delete mode 100644 src/myfasthtml/controls/BaseControl.py create mode 100644 src/myfasthtml/controls/UserProfile.py create mode 100644 src/myfasthtml/core/instances.py diff --git a/src/app.py b/src/app.py index fb19b99..df35d40 100644 --- a/src/app.py +++ b/src/app.py @@ -1,6 +1,7 @@ import logging from fasthtml import serve +from fasthtml.components import * from myfasthtml.controls.Layout import Layout from myfasthtml.myfastapp import create_app @@ -11,13 +12,26 @@ logging.basicConfig( datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format ) -app, rt = create_app(protect_routes=False, mount_auth_app=True, pico=False, title="MyFastHtml" ) +app, rt = create_app(protect_routes=True, + mount_auth_app=True, + pico=False, + title="MyFastHtml", + live=True, + base_url="http://localhost:5003") + @rt("/") def index(session): - layout = Layout(session, "Testing Layout", right_drawer=False) + layout = Layout(session, "Testing Layout") layout.set_footer("Goodbye World") + for i in range(1000): + layout.left_drawer.append(Div(f"Left Drawer Item {i}")) + + content = tuple([Div(f"Content {i}") for i in range(1000)]) + layout.set_main(content) return layout + if __name__ == "__main__": + # debug_routes(app) serve(port=5003) diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index aec4333..01aab0d 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -39,6 +39,7 @@ grid-area: header; display: flex; align-items: center; + justify-content: space-between; /* put one item on each side */ gap: 1rem; padding: 0 1rem; background-color: var(--color-base-300); diff --git a/src/myfasthtml/auth/routes.py b/src/myfasthtml/auth/routes.py index 38cf3e9..9c99494 100644 --- a/src/myfasthtml/auth/routes.py +++ b/src/myfasthtml/auth/routes.py @@ -70,7 +70,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", b session['refresh_token'] = auth_data['refresh_token'] # Get user info and store in session - user_info = get_user_info(auth_data['access_token']) + user_info = get_user_info(auth_data['access_token'], base_url=base_url) if user_info: session['user_info'] = user_info @@ -117,7 +117,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", b return RegisterPage(error_message="Password must be at least 8 characters long.") # Attempt registration - result = register_user(email, username, password) + result = register_user(email, username, password, base_url=base_url) if result: # Registration successful - show success message and auto-login diff --git a/src/myfasthtml/controls/BaseControl.py b/src/myfasthtml/controls/BaseControl.py deleted file mode 100644 index 185b4bb..0000000 --- a/src/myfasthtml/controls/BaseControl.py +++ /dev/null @@ -1,7 +0,0 @@ -class BaseControl: - def __init__(self, session, _id): - self.session = session - self._id = _id - - def get_id(self): - return self._id diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index 9c66607..c1e302f 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -5,15 +5,15 @@ This component provides a responsive layout with fixed header/footer, optional collapsible left/right drawers, and a scrollable main content area. """ import logging -import uuid from typing import Literal from fasthtml.common import * from myfasthtml.controls.BaseCommands import BaseCommands -from myfasthtml.controls.BaseControl import BaseControl -from myfasthtml.controls.helpers import mk +from myfasthtml.controls.UserProfile import UserProfile +from myfasthtml.controls.helpers import mk, Ids from myfasthtml.core.commands import Command +from myfasthtml.core.instances import MultipleInstance, InstancesManager from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon @@ -23,6 +23,7 @@ logger = logging.getLogger("LayoutControl") @dataclass class LayoutState: left_drawer_open: bool = True + right_drawer_open: bool = False class Commands(BaseCommands): @@ -30,7 +31,7 @@ class Commands(BaseCommands): return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, "left") -class Layout(BaseControl): +class Layout(MultipleInstance): """ A responsive layout component with header, footer, main content area, and optional collapsible side drawers. @@ -41,7 +42,19 @@ class Layout(BaseControl): right_drawer (bool): Whether to include a right drawer """ - def __init__(self, session, app_name, left_drawer=True, right_drawer=True): + class DrawerContent: + def __init__(self, owner, side: Literal["left", "right"]): + self._owner = owner + self.side = side + self._content = [] + + def append(self, content): + self._content.append(content) + + def get_content(self): + return self._content + + def __init__(self, session, app_name): """ Initialize the Layout component. @@ -50,28 +63,17 @@ class Layout(BaseControl): left_drawer (bool): Enable left drawer. Default is True. right_drawer (bool): Enable right drawer. Default is True. """ - super().__init__(session, f"mf-layout-{str(uuid.uuid4())}") + super().__init__(session, Ids.Layout) self.app_name = app_name - self.left_drawer = left_drawer - self.right_drawer = right_drawer # Content storage self._header_content = None self._footer_content = None self._main_content = None - self._left_drawer_content = None - self._right_drawer_content = None self._state = LayoutState() self.commands = Commands(self) - - # def set_header(self, content): - # """ - # Set the header content. - # - # Args: - # content: FastHTML component(s) or content for the header - # """ - # self._header_content = content + self.left_drawer = self.DrawerContent(self, "left") + self.right_drawer = self.DrawerContent(self, "right") def set_footer(self, content): """ @@ -91,26 +93,6 @@ class Layout(BaseControl): """ self._main_content = content - def set_left_drawer(self, content): - """ - Set the left drawer content. - - Args: - content: FastHTML component(s) or content for the left drawer - """ - if self.left_drawer: - self._left_drawer_content = content - - def set_right_drawer(self, content): - """ - Set the right drawer content. - - Args: - content: FastHTML component(s) or content for the right drawer - """ - if self.right_drawer: - self._right_drawer_content = content - def toggle_drawer(self, side: Literal["left", "right"]): logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}") if side == "left": @@ -118,7 +100,7 @@ class Layout(BaseControl): return self._mk_left_drawer_icon(), self._mk_left_drawer() elif side == "right": self._state.right_drawer_open = not self._state.right_drawer_open - return Div(), self._mk_right_drawer() + return self._mk_left_drawer_icon(), self._mk_right_drawer() else: raise ValueError("Invalid drawer side") @@ -131,6 +113,7 @@ class Layout(BaseControl): """ return Header( self._mk_left_drawer_icon(), + InstancesManager.get(self._session, Ids.UserProfile, UserProfile), cls="mf-layout-header" ) @@ -167,17 +150,10 @@ class Layout(BaseControl): Returns: Div or None: FastHTML Div component for left drawer, or None if disabled """ - if not self.left_drawer: - return None - - print(f"{self._state.left_drawer_open=}") - - drawer_content = self._left_drawer_content if self._left_drawer_content else "" return Div( - drawer_content, + *self.left_drawer.get_content(), id=f"{self._id}_ld", cls=f"mf-layout-drawer mf-layout-left-drawer {'collapsed' if not self._state.left_drawer_open else ''}", - **{"data-side": "left"} ) def _mk_right_drawer(self): @@ -187,15 +163,10 @@ class Layout(BaseControl): Returns: Div or None: FastHTML Div component for right drawer, or None if disabled """ - if not self.right_drawer: - return None - - drawer_content = self._right_drawer_content if self._right_drawer_content else "" return Div( - drawer_content, - cls="mf-layout-drawer mf-layout-right-drawer", + *self.right_drawer.get_content(), + cls=f"mf-layout-drawer mf-layout-right-drawer {'collapsed' if not self._state.right_drawer_open else ''}", id=f"{self._id}_rd", - **{"data-side": "right"} ) def _mk_left_drawer_icon(self): @@ -218,13 +189,8 @@ class Layout(BaseControl): self._mk_main(), self._mk_right_drawer(), self._mk_footer(), - Script(f"initLayout('{self._id}');"), id=self._id, cls="mf-layout", - **{ - "data-left-drawer": str(self.left_drawer).lower(), - "data-right-drawer": str(self.right_drawer).lower() - } ) def __ft__(self): diff --git a/src/myfasthtml/controls/UserProfile.py b/src/myfasthtml/controls/UserProfile.py new file mode 100644 index 0000000..caf159d --- /dev/null +++ b/src/myfasthtml/controls/UserProfile.py @@ -0,0 +1,46 @@ +from fasthtml.components import * + +from myfasthtml.controls.helpers import Ids, mk +from myfasthtml.core.instances import SingleInstance +from myfasthtml.core.utils import get_user_info +from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp +from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled + + +class UserProfile(SingleInstance): + def __init__(self, session): + super().__init__(session, Ids.UserProfile) + + def render(self): + user_info = get_user_info(self._session) + return Div( + Div(user_info['username'], + tabindex="0", + role="button", + cls="btn btn-xs"), + Div( + Div(mk.icon(person_outline_sharp, cls="mr-1"), user_info['username'], cls="flex m-1"), + Div(mk.icon(alternate_email_filled, cls="mr-1"), user_info['email'], cls="flex m-1"), + Div(mk.icon(dark_mode_filled, cls="mr-1"), self.mk_dark_mode(), cls="flex m-1"), + Div(A("Logout", cls="btn btn-xs mr-1", href="/logout"), cls="flex justify-center items-center"), + tabindex="-1", + cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl" + ), + cls="dropdown dropdown-end" + ) + + @staticmethod + def mk_dark_mode(): + return Label( + Input(type="checkbox", + name='theme', + aria_label='Dark', + value='dark', + cls='theme-controller'), + light_mode_filled, + dark_mode_filled, + cls="toggle text-base-content" + ) + + def __ft__(self): + return self.render() diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 4af75c4..095fa71 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -3,7 +3,9 @@ from fasthtml.components import * from myfasthtml.core.bindings import Binding from myfasthtml.core.commands import Command from myfasthtml.core.utils import merge_classes - +class Ids: + Layout = "mf-layout" + UserProfile = "mf-user-profile" class mk: diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py index dca0be0..6ef71cf 100644 --- a/src/myfasthtml/core/bindings.py +++ b/src/myfasthtml/core/bindings.py @@ -270,10 +270,10 @@ class Binding: return self def get_htmx_params(self): - return self.htmx_extra | { + return { "hx-post": f"{ROUTE_ROOT}{Routes.Bindings}", "hx-vals": f'{{"b_id": "{self.id}"}}', - } + } | self.htmx_extra def init(self): """ diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index b3577f6..f90fcd2 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -34,11 +34,11 @@ class BaseCommand: CommandsManager.register(self) def get_htmx_params(self): - return self._htmx_extra | { + return { "hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-swap": "outerHTML", "hx-vals": f'{{"c_id": "{self.id}"}}', - } + } | self._htmx_extra def execute(self): raise NotImplementedError diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py new file mode 100644 index 0000000..f9d7123 --- /dev/null +++ b/src/myfasthtml/core/instances.py @@ -0,0 +1,89 @@ +import uuid + + +class DuplicateInstanceError(Exception): + def __init__(self, instance): + self.instance = instance + + +class BaseInstance: + """ + Base class for all instances (manageable by InstancesManager) + """ + + def __init__(self, session: dict, _id: str): + self._session = session + self._id = _id + InstancesManager.register(session, self) + + def get_id(self): + return self._id + + def get_session(self): + return self._session + + +class SingleInstance(BaseInstance): + """ + Base class for instances that can only have one instance at a time. + """ + + def __init__(self, session: dict, prefix: str): + super().__init__(session, prefix) + self._instance = None + + +class MultipleInstance(BaseInstance): + """ + Base class for instances that can have multiple instances at a time. + """ + + def __init__(self, session: dict, prefix: str): + super().__init__(session, f"{prefix}-{str(uuid.uuid4())}") + self._instance = None + + +class InstancesManager: + instances = {} + + @staticmethod + def register(session: dict, instance: BaseInstance): + """ + Register an instance in the manager, so that it can be retrieved later. + :param session: + :param instance: + :return: + """ + key = (InstancesManager._get_session_id(session), instance.get_id()) + + if isinstance(instance, SingleInstance) and key in InstancesManager.instances: + raise DuplicateInstanceError(instance) + + InstancesManager.instances[key] = instance + return instance + + @staticmethod + def get(session: dict, instance_id: str, instance_type: type = None, *args, **kwargs): + """ + Get or create an instance of the given type (from its id) + :param session: + :param instance_id: + :param instance_type: + :param args: + :param kwargs: + :return: + """ + try: + key = (InstancesManager._get_session_id(session), instance_id) + + return InstancesManager.instances[key] + except KeyError: + return instance_type(session, *args, **kwargs) # it will be automatically registered + + @staticmethod + def _get_session_id(session): + if not session: + return "** NOT LOGGED IN **" + if "user_info" not in session: + return "** UNKNOWN USER **" + return session["user_info"].get("id", "** INVALID SESSION **") diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index bae9259..3850528 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -3,6 +3,8 @@ import logging from bs4 import Tag from fastcore.xml import FT from fasthtml.fastapp import fast_app +from rich.console import Console +from rich.table import Table from starlette.routing import Mount from myfasthtml.core.constants import Routes, ROUTE_ROOT @@ -60,15 +62,46 @@ def merge_classes(*args): def debug_routes(app): + routes = [] + + def _clean_endpoint(endpoint): + res = str(endpoint).replace("", "") + return res.split(" at ")[0] + def _debug_routes(_app, _route, prefix=""): if isinstance(_route, Mount): for sub_route in _route.app.router.routes: _debug_routes(_app, sub_route, prefix=_route.path) else: - print(f"path={prefix}{_route.path}, methods={_route.methods}, endpoint={_route.endpoint}") + routes.append({ + "number": len(routes), + "app": str(_app), + "name": _route.name, + "path": _route.path, + "full_path": prefix + _route.path, + "endpoint": _clean_endpoint(_route.endpoint), + "methods": _route.methods if hasattr(_route, "methods") else [], + "path_format": _route.path_format, + "path_regex": str(_route.path_regex), + }) for route in app.router.routes: _debug_routes(app, route) + + if not routes: + print("No routes found.") + return + + table = Table(show_header=True, expand=True, header_style="bold") + columns = ["number", "name", "full_path", "endpoint", "methods"] # routes[0].keys() + for column in columns: + table.add_column(column) + + for route in routes: + table.add_row(*[str(route[column]) for column in columns]) + + console = Console() + console.print(table) def mount_utils(app): @@ -158,6 +191,26 @@ def quoted_str(s): return str(s) +def get_user_info(session: dict): + if not session: + return { + "id": "** NOT LOGGED IN **", + "email": "** NOT LOGGED IN **", + "username": "** NOT LOGGED IN **", + "role": [], + } + + if "user_info" not in session: + return { + "id": "** UNKNOWN USER **", + "email": "** UNKNOWN USER **", + "username": "** UNKNOWN USER **", + "role": [], + } + + return session["user_info"] + + @utils_rt(Routes.Commands) def post(session, c_id: str): """ diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index b139c5a..813a19f 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -32,6 +32,7 @@ def get_asset_content(filename): def create_app(daisyui: Optional[bool] = True, protect_routes: Optional[bool] = True, mount_auth_app: Optional[bool] = False, + base_url: Optional[str] = None, **kwargs) -> Any: """ Creates and configures a FastHtml application with optional support for daisyUI themes and @@ -39,16 +40,11 @@ def create_app(daisyui: Optional[bool] = True, :param daisyui: Flag to enable or disable inclusion of daisyUI-related assets for styling. Defaults to False. - :type daisyui: Optional[bool] - :param protect_routes: Flag to enable or disable routes protection based on authentication. Defaults to True. - :type protect_routes: Optional[bool] - :param mount_auth_app: Flag to enable or disable mounting of authentication routes. Defaults to False. - :type mount_auth_app: Optional[bool] - + :param base_url: Url to use for the application (used by the auth APIs) :param kwargs: Arbitrary keyword arguments forwarded to the application initialization logic. :return: A tuple containing the FastHtml application instance and the associated router. @@ -70,11 +66,12 @@ def create_app(daisyui: Optional[bool] = True, app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs) # remove the global static files routes - static_route_exts_get = app.routes.pop(0) + original_routes = app.routes[:] + app.routes.clear() # Serve assets @app.get("/myfasthtml/{filename:path}.{ext:static}") - def serve_asset(filename: str, ext: str): + def serve_assets(filename: str, ext: str): path = filename + "." + ext try: content = get_asset_content(path) @@ -89,13 +86,14 @@ def create_app(daisyui: Optional[bool] = True, return Response(f"Asset not found: {path}", status_code=404) # and put it back after the myfasthtml static files routes - app.routes.append(static_route_exts_get) + for r in original_routes: + app.routes.append(r) # route the commands and the bindings app.mount("/myfasthtml", utils_app) if mount_auth_app: # Setup authentication routes - setup_auth_routes(app, rt) + setup_auth_routes(app, rt, base_url=base_url) return app, rt