Added Layout and UserProfile

This commit is contained in:
2025-11-10 21:19:38 +01:00
parent a547b2b882
commit d302261d07
12 changed files with 249 additions and 87 deletions

View File

@@ -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)

View File

@@ -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);

View File

@@ -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

View File

@@ -1,7 +0,0 @@
class BaseControl:
def __init__(self, session, _id):
self.session = session
self._id = _id
def get_id(self):
return self._id

View File

@@ -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):

View File

@@ -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()

View File

@@ -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:

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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 **")

View File

@@ -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("<function ", "").replace(".<locals>", "")
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):
"""

View File

@@ -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