Added Commands Management
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.core.commands import Command
|
||||
|
||||
|
||||
def mk_button(element, command: Command = None, **kwargs):
|
||||
if command is None:
|
||||
return Button(element, **kwargs)
|
||||
|
||||
htmx = command.get_htmx_params()
|
||||
return Button(element, **htmx, **kwargs)
|
||||
@@ -0,0 +1,122 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.core.utils import mount_if_not_exists
|
||||
|
||||
commands_app, commands_rt = fast_app()
|
||||
logger = logging.getLogger("Commands")
|
||||
|
||||
|
||||
class BaseCommand:
|
||||
"""
|
||||
Represents the base command class for defining executable actions.
|
||||
|
||||
This class serves as a foundation for commands that can be registered,
|
||||
executed, and utilized within a system. Each command has a unique identifier,
|
||||
a name, and a description. Commands should override the `execute` method
|
||||
to provide specific functionality.
|
||||
|
||||
:ivar id: A unique identifier for the command.
|
||||
:type id: uuid.UUID
|
||||
:ivar name: The name of the command.
|
||||
:type name: str
|
||||
:ivar description: A brief description of the command's functionality.
|
||||
:type description: str
|
||||
"""
|
||||
|
||||
def __init__(self, name, description):
|
||||
self.id = uuid.uuid4()
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
# register the command
|
||||
CommandsManager.register(self)
|
||||
|
||||
def get_htmx_params(self):
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||
"hx-vals": f'{{"c_id": "{self.id}"}}',
|
||||
}
|
||||
|
||||
def execute(self):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Represents a command that encapsulates a callable action with parameters.
|
||||
|
||||
This class is designed to hold a defined action (callback) alongside its arguments
|
||||
and keyword arguments.
|
||||
|
||||
:ivar name: The name of the command.
|
||||
:type name: str
|
||||
:ivar description: A brief description of the command.
|
||||
:type description: str
|
||||
:ivar callback: The function or callable to be executed.
|
||||
:type callback: Callable
|
||||
:ivar args: Positional arguments to be passed to the callback.
|
||||
:type args: tuple
|
||||
:ivar kwargs: Keyword arguments to be passed to the callback.
|
||||
:type kwargs: dict
|
||||
"""
|
||||
|
||||
def __init__(self, name, description, callback, *args, **kwargs):
|
||||
super().__init__(name, description)
|
||||
self.callback = callback
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def execute(self):
|
||||
return self.callback(*self.args, **self.kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
|
||||
|
||||
class CommandsManager:
|
||||
commands = {}
|
||||
|
||||
@staticmethod
|
||||
def register(command: BaseCommand):
|
||||
CommandsManager.commands[str(command.id)] = command
|
||||
|
||||
@staticmethod
|
||||
def get_command(command_id: str) -> Optional[BaseCommand]:
|
||||
return CommandsManager.commands.get(command_id)
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
return CommandsManager.commands.clear()
|
||||
|
||||
|
||||
@commands_rt(Routes.Commands)
|
||||
def post(session: str, c_id: str):
|
||||
"""
|
||||
Default routes for all commands.
|
||||
:param session:
|
||||
:param c_id:
|
||||
:return:
|
||||
"""
|
||||
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
|
||||
command = CommandsManager.get_command(c_id)
|
||||
if command:
|
||||
return command.execute()
|
||||
|
||||
raise ValueError(f"Command with ID '{c_id}' not found.")
|
||||
|
||||
|
||||
def mount_commands(app):
|
||||
"""
|
||||
Mounts the commands_app to the given application instance if the route does not already exist.
|
||||
|
||||
:param app: The application instance to which the commands_app will be mounted.
|
||||
:type app: Any
|
||||
:return: Returns the result of the mount operation performed by mount_if_not_exists.
|
||||
:rtype: Any
|
||||
"""
|
||||
return mount_if_not_exists(app, ROUTE_ROOT, commands_app)
|
||||
@@ -0,0 +1,4 @@
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
class Routes:
|
||||
Commands = "/commands"
|
||||
@@ -0,0 +1,463 @@
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from fastcore.xml import FT, to_xml
|
||||
from fasthtml.common import FastHTML
|
||||
from starlette.responses import Response
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from myfasthtml.core.commands import mount_commands
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyFT:
|
||||
tag: str
|
||||
attrs: dict
|
||||
|
||||
|
||||
class TestableElement:
|
||||
"""
|
||||
Represents an HTML element that can be interacted with in tests.
|
||||
|
||||
This class will be used for future interactions like clicking elements
|
||||
or verifying element properties.
|
||||
"""
|
||||
|
||||
def __init__(self, client, source):
|
||||
"""
|
||||
Initialize a testable element.
|
||||
|
||||
Args:
|
||||
client: The MyTestClient instance.
|
||||
ft: The FastHTML element representation.
|
||||
"""
|
||||
self.client = client
|
||||
if isinstance(source, str):
|
||||
self.html_fragment = source
|
||||
tag = BeautifulSoup(source, 'html.parser').find()
|
||||
self.ft = MyFT(tag.name, tag.attrs)
|
||||
elif isinstance(source, Tag):
|
||||
self.html_fragment = str(source)
|
||||
self.ft = MyFT(source.name, source.attrs)
|
||||
elif isinstance(source, FT):
|
||||
self.ft = source
|
||||
self.html_fragment = to_xml(source).strip()
|
||||
else:
|
||||
raise ValueError(f"Invalid source '{source}' for TestableElement.")
|
||||
|
||||
def click(self):
|
||||
"""Click the element (to be implemented)."""
|
||||
return self._send_htmx_request()
|
||||
|
||||
def matches(self, ft):
|
||||
"""Check if element matches given FastHTML element (to be implemented)."""
|
||||
pass
|
||||
|
||||
def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response:
|
||||
"""
|
||||
Simulates an HTMX request in Python for unit testing.
|
||||
|
||||
This function reads the 'hx-*' attributes from the FastHTML object
|
||||
to determine the HTTP method, URL, headers, and body of the request,
|
||||
then executes it via the TestClient.
|
||||
|
||||
Args:
|
||||
data: (Optional) A dict for form data
|
||||
(sends as 'application/x-www-form-urlencoded').
|
||||
json_data: (Optional) A dict for JSON data
|
||||
(sends as 'application/json').
|
||||
Takes precedence over 'hx_vals'.
|
||||
|
||||
Returns:
|
||||
The Response object from the simulated request.
|
||||
"""
|
||||
|
||||
# The essential header for FastHTML (and HTMX) to identify the request
|
||||
headers = {"HX-Request": "true"}
|
||||
method = "GET" # HTMX defaults to GET if not specified
|
||||
url = None
|
||||
|
||||
verbs = {
|
||||
'hx_get': 'GET',
|
||||
'hx_post': 'POST',
|
||||
'hx_put': 'PUT',
|
||||
'hx_delete': 'DELETE',
|
||||
'hx_patch': 'PATCH',
|
||||
}
|
||||
|
||||
# .props contains the kwargs passed to the object (e.g., hx_post="/url")
|
||||
element_attrs = self.ft.attrs or {}
|
||||
|
||||
# Build the attributes
|
||||
for key, value in element_attrs.items():
|
||||
|
||||
# sanitize the key
|
||||
key = key.lower().strip()
|
||||
if key.startswith('hx-'):
|
||||
key = 'hx_' + key[3:]
|
||||
|
||||
if key in verbs:
|
||||
# Verb attribute: defines the method and URL
|
||||
method = verbs[key]
|
||||
url = str(value)
|
||||
|
||||
elif key == 'hx_vals':
|
||||
# hx_vals defines the JSON body, if not already provided by the test
|
||||
if json_data is None:
|
||||
if isinstance(value, str):
|
||||
json_data = json.loads(value)
|
||||
elif isinstance(value, dict):
|
||||
json_data = value
|
||||
|
||||
elif key.startswith('hx_'):
|
||||
# Any other hx_* attribute is converted to an HTTP header
|
||||
# e.g.: 'hx_target' -> 'HX-Target'
|
||||
header_name = '-'.join(p.capitalize() for p in key.split('_'))
|
||||
headers[header_name] = str(value)
|
||||
|
||||
# Sanity check
|
||||
if url is None:
|
||||
raise ValueError(
|
||||
f"The <{self.ft.tag}> element has no HTMX verb attribute "
|
||||
"(e.g., hx_get, hx_post) to define a URL."
|
||||
)
|
||||
|
||||
# Send the request
|
||||
return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data)
|
||||
|
||||
|
||||
class MyTestClient:
|
||||
"""
|
||||
A test client helper for FastHTML applications that provides
|
||||
a more user-friendly API for testing HTML responses.
|
||||
|
||||
This class wraps Starlette's TestClient and provides methods
|
||||
to verify page content in a way similar to NiceGui's test fixtures.
|
||||
"""
|
||||
|
||||
def __init__(self, app: FastHTML, parent_levels: int = 1):
|
||||
"""
|
||||
Initialize the test client.
|
||||
|
||||
Args:
|
||||
app: The FastHTML application to test.
|
||||
parent_levels: Number of parent levels to show in error messages (default: 1).
|
||||
"""
|
||||
self.app = app
|
||||
self.client = TestClient(app)
|
||||
self._content = None
|
||||
self._soup = None
|
||||
self._session = str(uuid.uuid4())
|
||||
self.parent_levels = parent_levels
|
||||
|
||||
# make sure that the commands are mounted
|
||||
mount_commands(self.app)
|
||||
|
||||
def open(self, path: str):
|
||||
"""
|
||||
Open a page and store its content for subsequent assertions.
|
||||
|
||||
Args:
|
||||
path: The URL path to request (e.g., '/home', '/api/users').
|
||||
|
||||
Returns:
|
||||
self: Returns the client instance for method chaining.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the response status code is not 200.
|
||||
"""
|
||||
|
||||
res = self.client.get(path)
|
||||
assert res.status_code == 200, (
|
||||
f"Failed to open '{path}'. "
|
||||
f"status code={res.status_code} : reason='{res.text}'"
|
||||
)
|
||||
|
||||
self.set_content(res.text)
|
||||
return self
|
||||
|
||||
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
|
||||
json_data['session'] = self._session
|
||||
res = self.client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
data=data, # For form data
|
||||
json=json_data # For JSON bodies (e.g., from hx_vals)
|
||||
)
|
||||
|
||||
assert res.status_code == 200, (
|
||||
f"Failed to send request '{method=}', {url=}. "
|
||||
f"status code={res.status_code} : reason='{res.text}'"
|
||||
)
|
||||
|
||||
self.set_content(res.text)
|
||||
return self
|
||||
|
||||
def should_see(self, text: str):
|
||||
"""
|
||||
Assert that the given text is present in the visible page content.
|
||||
|
||||
This method parses the HTML and searches only in the visible text,
|
||||
ignoring HTML tags and attributes.
|
||||
|
||||
Args:
|
||||
text: The text string to search for (case-sensitive).
|
||||
|
||||
Returns:
|
||||
self: Returns the client instance for method chaining.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the text is not found in the page content.
|
||||
ValueError: If no page has been opened yet.
|
||||
"""
|
||||
if self._content is None:
|
||||
raise ValueError(
|
||||
"No page content available. Call open() before should_see()."
|
||||
)
|
||||
|
||||
visible_text = self._soup.get_text()
|
||||
|
||||
if text not in visible_text:
|
||||
# Provide a snippet of the actual content for debugging
|
||||
snippet_length = 200
|
||||
content_snippet = (
|
||||
visible_text[:snippet_length] + "..."
|
||||
if len(visible_text) > snippet_length
|
||||
else visible_text
|
||||
)
|
||||
raise AssertionError(
|
||||
f"Expected to see '{text}' in page content but it was not found.\n"
|
||||
f"Visible content (first {snippet_length} chars): {content_snippet}"
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def should_not_see(self, text: str):
|
||||
"""
|
||||
Assert that the given text is NOT present in the visible page content.
|
||||
|
||||
This method parses the HTML and searches only in the visible text,
|
||||
ignoring HTML tags and attributes.
|
||||
|
||||
Args:
|
||||
text: The text string that should not be present (case-sensitive).
|
||||
|
||||
Returns:
|
||||
self: Returns the client instance for method chaining.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the text is found in the page content.
|
||||
ValueError: If no page has been opened yet.
|
||||
"""
|
||||
if self._content is None:
|
||||
raise ValueError(
|
||||
"No page content available. Call open() before should_not_see()."
|
||||
)
|
||||
|
||||
visible_text = self._soup.get_text()
|
||||
|
||||
if text in visible_text:
|
||||
element = self._find_visible_text_element(self._soup, text)
|
||||
|
||||
if element:
|
||||
context = self._format_element_with_context(element, self.parent_levels)
|
||||
error_msg = (
|
||||
f"Expected NOT to see '{text}' in page content but it was found.\n"
|
||||
f"Found in:\n{context}"
|
||||
)
|
||||
else:
|
||||
error_msg = (
|
||||
f"Expected NOT to see '{text}' in page content but it was found.\n"
|
||||
f"Unable to locate the element containing this text."
|
||||
)
|
||||
|
||||
raise AssertionError(error_msg)
|
||||
|
||||
return self
|
||||
|
||||
def find_element(self, selector: str):
|
||||
"""
|
||||
Find a single HTML element using a CSS selector.
|
||||
|
||||
This method searches for elements matching the given CSS selector.
|
||||
It expects to find exactly one matching element.
|
||||
|
||||
Args:
|
||||
selector: A CSS selector string (e.g., '#my-id', '.my-class', 'button.primary').
|
||||
|
||||
Returns:
|
||||
TestableElement: A testable element wrapping the HTML fragment.
|
||||
|
||||
Raises:
|
||||
ValueError: If no page has been opened yet.
|
||||
AssertionError: If no element or multiple elements match the selector.
|
||||
|
||||
Examples:
|
||||
element = client.open('/').find_element('#login-button')
|
||||
element = client.find_element('button.primary')
|
||||
"""
|
||||
if self._content is None:
|
||||
raise ValueError(
|
||||
"No page content available. Call open() before find_element()."
|
||||
)
|
||||
|
||||
results = self._soup.select(selector)
|
||||
|
||||
if len(results) == 0:
|
||||
raise AssertionError(
|
||||
f"No element found matching selector '{selector}'."
|
||||
)
|
||||
elif len(results) == 1:
|
||||
return TestableElement(self, results[0])
|
||||
else:
|
||||
raise AssertionError(
|
||||
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
|
||||
)
|
||||
|
||||
def get_content(self) -> str:
|
||||
"""
|
||||
Get the raw HTML content of the last opened page.
|
||||
|
||||
Returns:
|
||||
The HTML content as a string, or None if no page has been opened.
|
||||
"""
|
||||
return self._content
|
||||
|
||||
def set_content(self, content: str):
|
||||
"""
|
||||
Set the HTML content and parse it with BeautifulSoup.
|
||||
|
||||
Args:
|
||||
content: The HTML content string to set.
|
||||
"""
|
||||
self._content = content
|
||||
self._soup = BeautifulSoup(content, 'html.parser')
|
||||
|
||||
@staticmethod
|
||||
def _find_visible_text_element(soup, text: str):
|
||||
"""
|
||||
Find the first element containing the visible text.
|
||||
|
||||
This method traverses the BeautifulSoup tree to find the first element
|
||||
whose visible text content (including descendants) contains the search text.
|
||||
|
||||
Args:
|
||||
soup: BeautifulSoup object representing the parsed HTML.
|
||||
text: The text to search for.
|
||||
|
||||
Returns:
|
||||
BeautifulSoup element containing the text, or None if not found.
|
||||
"""
|
||||
# Traverse all elements in the document
|
||||
for element in soup.descendants:
|
||||
# Skip NavigableString nodes, we want Tag elements
|
||||
if not isinstance(element, Tag):
|
||||
continue
|
||||
|
||||
# Get visible text of this element and its descendants
|
||||
element_text = element.get_text()
|
||||
|
||||
# Check if our search text is in this element's visible text
|
||||
if text in element_text:
|
||||
# Found it! But we want the smallest element containing the text
|
||||
# So let's check if any of its children also contain the text
|
||||
found_in_child = False
|
||||
|
||||
for child in element.children:
|
||||
if isinstance(child, Tag) and text in child.get_text():
|
||||
found_in_child = True
|
||||
break
|
||||
|
||||
# If no child contains the text, this is our target element
|
||||
if not found_in_child:
|
||||
return element
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _indent_html(html_str: str, indent: int = 2):
|
||||
"""
|
||||
Add indentation to HTML string.
|
||||
|
||||
Args:
|
||||
html_str: HTML string to indent.
|
||||
indent: Number of spaces for indentation.
|
||||
|
||||
Returns:
|
||||
str: Indented HTML string.
|
||||
"""
|
||||
lines = html_str.split('\n')
|
||||
indented_lines = [' ' * indent + line for line in lines if line.strip()]
|
||||
return '\n'.join(indented_lines)
|
||||
|
||||
def _format_element_with_context(self, element, parent_levels: int):
|
||||
"""
|
||||
Format an element with its parent context for display.
|
||||
|
||||
Args:
|
||||
element: BeautifulSoup element to format.
|
||||
parent_levels: Number of parent levels to include.
|
||||
|
||||
Returns:
|
||||
str: Formatted HTML string with indentation.
|
||||
"""
|
||||
# Collect the element and its parents
|
||||
elements_to_show = [element]
|
||||
current = element
|
||||
|
||||
for _ in range(parent_levels):
|
||||
if current.parent and current.parent.name: # Skip NavigableString parents
|
||||
elements_to_show.insert(0, current.parent)
|
||||
current = current.parent
|
||||
else:
|
||||
break
|
||||
|
||||
# Format the top-level element with proper indentation
|
||||
if len(elements_to_show) == 1:
|
||||
return self._indent_html(str(element), indent=2)
|
||||
|
||||
# Build the nested structure
|
||||
result = self._build_nested_context(elements_to_show, element)
|
||||
return self._indent_html(result, indent=2)
|
||||
|
||||
def _build_nested_context(self, elements_chain, target_element):
|
||||
"""
|
||||
Build nested HTML context showing parents and target element.
|
||||
|
||||
Args:
|
||||
elements_chain: List of elements from outermost parent to target.
|
||||
target_element: The element that contains the searched text.
|
||||
|
||||
Returns:
|
||||
str: Nested HTML structure.
|
||||
"""
|
||||
if len(elements_chain) == 1:
|
||||
return str(target_element)
|
||||
|
||||
# Get the outermost element
|
||||
outer = elements_chain[0]
|
||||
|
||||
# Start with opening tag
|
||||
result = f"<{outer.name}"
|
||||
if outer.attrs:
|
||||
attrs = ' '.join(f'{k}="{v}"' if not isinstance(v, list) else f'{k}="{" ".join(v)}"'
|
||||
for k, v in outer.attrs.items())
|
||||
result += f" {attrs}"
|
||||
result += ">\n"
|
||||
|
||||
# Add nested content
|
||||
if len(elements_chain) == 2:
|
||||
# This is the target element
|
||||
result += self._indent_html(str(target_element), indent=2) + "\n"
|
||||
else:
|
||||
# Recursive call for deeper nesting
|
||||
nested = self._build_nested_context(elements_chain[1:], target_element)
|
||||
result += self._indent_html(nested, indent=2) + "\n"
|
||||
|
||||
# Closing tag
|
||||
result += f"</{outer.name}>"
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,19 @@
|
||||
from starlette.routing import Mount
|
||||
|
||||
|
||||
def mount_if_not_exists(app, path: str, sub_app):
|
||||
"""
|
||||
Mounts a sub-application only if no Mount object already exists
|
||||
at the specified path in the main application's router.
|
||||
"""
|
||||
is_mounted = False
|
||||
|
||||
for route in app.router.routes:
|
||||
|
||||
if isinstance(route, Mount):
|
||||
if route.path == path:
|
||||
is_mounted = True
|
||||
break
|
||||
|
||||
if not is_mounted:
|
||||
app.mount(path, app=sub_app)
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generate Icons
|
||||
|
||||
Create the icons files for FastHtml applications.
|
||||
|
||||
## How to generate the svg files
|
||||
```sh
|
||||
npm i -D @sicons/fa
|
||||
npm i -D @sicons/fluent
|
||||
npm i -D @sicons/ionicons4
|
||||
npm i -D @sicons/ionicons5
|
||||
npm i -D @sicons/antd
|
||||
npm i -D @sicons/material
|
||||
npm i -D @sicons/tabler
|
||||
npm i -D @sicons/carbon
|
||||
```
|
||||
Update the root folder in `update_icons.py` to point to the root folder of the icons.
|
||||
|
||||
##
|
||||
```sh
|
||||
python update_icons.py
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
|
||||
root_folder = "/home/kodjo/Dev/MyDocManager/src/frontend/node_modules/@sicons"
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||
# Insert underscore before capital letters (except the first one)
|
||||
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
def create_icons(file, icon_folder):
|
||||
for filename in os.listdir(f"{root_folder}/{icon_folder}"):
|
||||
print("#", end='')
|
||||
if not filename.endswith(".svg"):
|
||||
continue
|
||||
|
||||
with open(f"{root_folder}/{icon_folder}/{filename}", "r") as f_read:
|
||||
svg_content = f_read.read().strip()
|
||||
icon_name = "icon_" + pascal_to_snake(filename.split('.')[0])
|
||||
file.write(f"{icon_name} = NotStr('''{svg_content}''')\n")
|
||||
|
||||
print("")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for folder in ["antd", "material", "carbon", "fa", "fluent", "ionicons4", "ionicons5", "tabler"]:
|
||||
# for folder in ["antd"]:
|
||||
print(f"Processing icons for {folder}")
|
||||
with open(f"{folder}.py", "w") as f_write:
|
||||
|
||||
# Add README.md content to the top of the file
|
||||
if os.path.exists(f"{root_folder}/{folder}/README.md"):
|
||||
with open(f"{root_folder}/{folder}/README.md", "r") as f_readme:
|
||||
for line in f_readme:
|
||||
if line.startswith("#"):
|
||||
f_write.write(line)
|
||||
else:
|
||||
f_write.write(f"# {line}")
|
||||
f_write.write("\n\n")
|
||||
|
||||
# Add imports
|
||||
f_write.write("from fastcore.basics import NotStr\n\n")
|
||||
|
||||
# Add icons
|
||||
create_icons(f_write, folder)
|
||||
@@ -0,0 +1,86 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
|
||||
class LoginPage:
|
||||
def __init__(self, settings_manager, error_message=None, success_message=None):
|
||||
self.settings_manager = settings_manager
|
||||
self.error_message = error_message
|
||||
self.success_message = success_message
|
||||
|
||||
def render(self):
|
||||
message_alert = None
|
||||
if self.error_message:
|
||||
message_alert = Div(
|
||||
P(self.error_message, cls="text-sm"),
|
||||
cls="bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
|
||||
)
|
||||
elif self.success_message:
|
||||
message_alert = Div(
|
||||
P(self.success_message, cls="text-sm"),
|
||||
cls="bg-success border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
|
||||
)
|
||||
|
||||
return Div(
|
||||
# Page title
|
||||
H1("Sign In", cls="text-3xl font-bold text-center mb-6"),
|
||||
|
||||
# Login Form
|
||||
Div(
|
||||
# Message alert
|
||||
message_alert if message_alert else "",
|
||||
|
||||
# Email login form
|
||||
Form(
|
||||
# Email field
|
||||
Div(
|
||||
Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
|
||||
Input(
|
||||
type="email",
|
||||
id="email",
|
||||
name="email",
|
||||
placeholder="you@example.com",
|
||||
required=True,
|
||||
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
|
||||
),
|
||||
cls="mb-4"
|
||||
),
|
||||
|
||||
# Password field
|
||||
Div(
|
||||
Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
|
||||
Input(
|
||||
type="password",
|
||||
id="password",
|
||||
name="password",
|
||||
placeholder="Your password",
|
||||
required=True,
|
||||
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
|
||||
),
|
||||
cls="mb-6"
|
||||
),
|
||||
|
||||
# Submit button
|
||||
Button(
|
||||
"Sign In",
|
||||
type="submit",
|
||||
cls="btn w-full font-bold py-2 px-4 rounded"
|
||||
),
|
||||
|
||||
action=ROUTE_ROOT + Routes.LoginByEmail,
|
||||
method="post",
|
||||
cls="mb-6"
|
||||
),
|
||||
|
||||
# Registration link
|
||||
Div(
|
||||
P(
|
||||
"Don't have an account? ",
|
||||
A("Register here", href="/register", cls="text-blue-600 hover:underline"),
|
||||
cls="text-sm text-gray-600 text-center"
|
||||
)
|
||||
),
|
||||
|
||||
cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
|
||||
)
|
||||
|
||||
)
|
||||
Reference in New Issue
Block a user