blux-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blux/__init__.py +10 -0
- blux/__projeto_test/app/api/payments/appypay.py +24 -0
- blux/__projeto_test/app/api/produtos/index.py +18 -0
- blux/__projeto_test/app/assets/globals.css +47 -0
- blux/__projeto_test/app/pages/index.py +68 -0
- blux/__projeto_test/app/pages/sobre.py +16 -0
- blux/__projeto_test/box.config.py +0 -0
- blux/app.py +48 -0
- blux/cli.py +103 -0
- blux/core/api.py +33 -0
- blux/core/elements.py +264 -0
- blux/core/render.py +68 -0
- blux/core/routes.py +199 -0
- blux/core/state.py +26 -0
- blux/core/types.py +0 -0
- blux/utils.py +32 -0
- blux/web/static/css/base.css +17 -0
- blux/web/static/js/app.js +0 -0
- blux/web/static/js/htmx-ext-head.js +1 -0
- blux/web/static/js/htmx.js +1 -0
- blux/web/static/js/idiomorph-ext.min.js +1 -0
- blux/web/templates/_head.html +9 -0
- blux/web/templates/index.html +12 -0
- blux_framework-0.1.0.dist-info/METADATA +100 -0
- blux_framework-0.1.0.dist-info/RECORD +27 -0
- blux_framework-0.1.0.dist-info/WHEEL +4 -0
- blux_framework-0.1.0.dist-info/entry_points.txt +3 -0
blux/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import blux
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
class Payment(BaseModel):
|
|
5
|
+
amount:float
|
|
6
|
+
client_name:str
|
|
7
|
+
client_email: str
|
|
8
|
+
|
|
9
|
+
@blux.api()
|
|
10
|
+
def get_payment(nome:str):
|
|
11
|
+
""" Criar um novo produto"""
|
|
12
|
+
|
|
13
|
+
return {"ok":"ok"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@blux.api(method="POST")
|
|
17
|
+
def create_produtos(nome:str):
|
|
18
|
+
""" Criar um novo produto"""
|
|
19
|
+
|
|
20
|
+
return {"ok":"ok"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import blux
|
|
2
|
+
|
|
3
|
+
@blux.api()
|
|
4
|
+
def get_produtos(nome:str):
|
|
5
|
+
""" Criar um novo produto"""
|
|
6
|
+
|
|
7
|
+
return {"ok":"ok"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@blux.api(method="POST")
|
|
11
|
+
def create_produtos(nome:str):
|
|
12
|
+
""" Criar um novo produto"""
|
|
13
|
+
|
|
14
|
+
return {"ok":"ok"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
.content_app {
|
|
2
|
+
padding: 10px;
|
|
3
|
+
height: 100%;
|
|
4
|
+
|
|
5
|
+
display: flex;
|
|
6
|
+
justify-content: center;
|
|
7
|
+
align-items: center;
|
|
8
|
+
padding: 2rem;
|
|
9
|
+
text-align: center;
|
|
10
|
+
|
|
11
|
+
font-family: Verdana, Geneva, Tahoma, sans-serif;
|
|
12
|
+
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.title {
|
|
16
|
+
font-weight: bold;
|
|
17
|
+
font-size: 2rem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.text {
|
|
21
|
+
font-size: .8rem;
|
|
22
|
+
opacity: .7;
|
|
23
|
+
max-width: 70%;
|
|
24
|
+
margin: auto;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.content {
|
|
28
|
+
display: flex;
|
|
29
|
+
flex-direction: column;
|
|
30
|
+
gap: 10px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.button_count {
|
|
34
|
+
background-color: #141414;
|
|
35
|
+
padding: 10px;
|
|
36
|
+
border: 0;
|
|
37
|
+
border-radius: 10px;
|
|
38
|
+
color: #d2d2d2;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.row2 {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 10px;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import blux
|
|
2
|
+
|
|
3
|
+
num = 0
|
|
4
|
+
|
|
5
|
+
class Index(blux.Page):
|
|
6
|
+
|
|
7
|
+
title = "index"
|
|
8
|
+
styles = ["globals.css"]
|
|
9
|
+
|
|
10
|
+
@blux.action
|
|
11
|
+
def contador(self, request:blux.Request):
|
|
12
|
+
global num
|
|
13
|
+
num += 1
|
|
14
|
+
|
|
15
|
+
def render(self, request:blux.Request):
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
blux.View(
|
|
19
|
+
state=self.state,
|
|
20
|
+
class_name="content_app",
|
|
21
|
+
elements=[
|
|
22
|
+
blux.View(
|
|
23
|
+
class_name="content",
|
|
24
|
+
elements=[
|
|
25
|
+
|
|
26
|
+
blux.Text(content="Bem vindo ao Box", class_name="title"),
|
|
27
|
+
blux.Text(
|
|
28
|
+
content="O framework pensado na produtividade de criar aplicações full-stack com apenas uma base de codigo",
|
|
29
|
+
class_name="text"
|
|
30
|
+
),
|
|
31
|
+
blux.View(
|
|
32
|
+
elements=[
|
|
33
|
+
blux.Button(
|
|
34
|
+
content=f"contador {num}",
|
|
35
|
+
on_click=self.contador,
|
|
36
|
+
class_name="button_count"
|
|
37
|
+
),
|
|
38
|
+
]
|
|
39
|
+
),
|
|
40
|
+
blux.View(
|
|
41
|
+
elements=[
|
|
42
|
+
blux.Link(
|
|
43
|
+
content="Develop",
|
|
44
|
+
href="http://ricardocayoca.onrender.com/",
|
|
45
|
+
target="_blank"
|
|
46
|
+
),
|
|
47
|
+
blux.Link(
|
|
48
|
+
content="Doc box",
|
|
49
|
+
href="/"
|
|
50
|
+
),
|
|
51
|
+
blux.Link(
|
|
52
|
+
content="Swager",
|
|
53
|
+
href="/docs",
|
|
54
|
+
target="_blank"
|
|
55
|
+
),
|
|
56
|
+
blux.Link(
|
|
57
|
+
content="API doc",
|
|
58
|
+
href="/redoc",
|
|
59
|
+
target="_blank"
|
|
60
|
+
)
|
|
61
|
+
],
|
|
62
|
+
class_name="row2"
|
|
63
|
+
)
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
)
|
|
File without changes
|
blux/app.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.staticfiles import StaticFiles
|
|
6
|
+
from fastapi.templating import Jinja2Templates
|
|
7
|
+
|
|
8
|
+
from .core.routes import build_router
|
|
9
|
+
from .utils import install_form_middleware
|
|
10
|
+
|
|
11
|
+
APP_DIR = None
|
|
12
|
+
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
template = Jinja2Templates(
|
|
16
|
+
directory=os.path.join(WEB_DIR, "templates")
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def create_app():
|
|
20
|
+
app = FastAPI()
|
|
21
|
+
|
|
22
|
+
install_form_middleware(app)
|
|
23
|
+
|
|
24
|
+
app.mount("/static", StaticFiles(
|
|
25
|
+
directory=os.path.join(WEB_DIR, "static")
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
for page in Path(os.getcwd()).rglob("**/app"):
|
|
30
|
+
if page.exists():
|
|
31
|
+
APP_DIR = page
|
|
32
|
+
break
|
|
33
|
+
|
|
34
|
+
if not APP_DIR: raise FileNotFoundError(
|
|
35
|
+
f"Diretório de páginas não encontrado: app\n"
|
|
36
|
+
f"Rode o comando a partir da raiz do projeto, ou passe --app-dir."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
app.mount("/assets", StaticFiles(
|
|
42
|
+
directory=APP_DIR.joinpath("assets")
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
for route in build_router(APP_DIR, template):
|
|
46
|
+
app.include_router(route, include_in_schema=True)
|
|
47
|
+
|
|
48
|
+
return app
|
blux/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="CLI do blux — sobe o servidor e escaneia as rotas de app/")
|
|
13
|
+
|
|
14
|
+
FACTORY_TARGET = "blux.app:create_app"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command()
|
|
18
|
+
def dev(
|
|
19
|
+
host: str = typer.Option("127.0.0.1", help="Endereço para escutar"),
|
|
20
|
+
port: int = typer.Option(8000, help="Porta para escutar"),
|
|
21
|
+
):
|
|
22
|
+
"""Sobe o servidor de desenvolvimento com reload automático."""
|
|
23
|
+
typer.echo(f"blux dev -> http://{host}:{port}")
|
|
24
|
+
uvicorn.run(FACTORY_TARGET, factory=True, host=host, port=port, reload=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def init(name:str=typer.Argument(".", help="Diretório")):
|
|
29
|
+
""" Cria um estrutura do projeto """
|
|
30
|
+
|
|
31
|
+
ROOT = Path(name)
|
|
32
|
+
|
|
33
|
+
if ROOT.exists():
|
|
34
|
+
typer.echo(f"O projeto '{ROOT}' Já existe")
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
origem = Path(os.path.dirname(__file__)).joinpath("__projeto_test")
|
|
38
|
+
print(origem)
|
|
39
|
+
|
|
40
|
+
for arquivo in origem.rglob("*"):
|
|
41
|
+
if arquivo.is_file():
|
|
42
|
+
relativo = arquivo.relative_to(origem)
|
|
43
|
+
destino_arquivo = ROOT / relativo
|
|
44
|
+
|
|
45
|
+
destino_arquivo.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
shutil.copy2(arquivo, destino_arquivo)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
typer.echo(f"Projeto {ROOT} inicializado")
|
|
50
|
+
typer.echo(f"use: cd {ROOT}")
|
|
51
|
+
typer.echo("start develop server: blux dev")
|
|
52
|
+
typer.echo("cli help use: blux --help")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def start(
|
|
67
|
+
host: str = typer.Option("0.0.0.0", help="Endereço para escutar"),
|
|
68
|
+
port: int = typer.Option(8000, help="Porta para escutar"),
|
|
69
|
+
app_dir: str = typer.Option("app", "--app-dir", help="Diretório com as páginas"),
|
|
70
|
+
workers: int = typer.Option(1, help="Número de processos worker"),
|
|
71
|
+
):
|
|
72
|
+
"""Sobe o servidor em modo produção (sem reload)."""
|
|
73
|
+
os.environ["blux_APP_DIR"] = app_dir
|
|
74
|
+
typer.echo(f"backpay start -> http://{host}:{port} (páginas em ./{app_dir}, workers={workers})")
|
|
75
|
+
uvicorn.run(FACTORY_TARGET, factory=True, host=host, port=port, workers=workers)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def routes(
|
|
80
|
+
app_dir: str = typer.Option("app", "--app-dir", help="Diretório com as páginas"),
|
|
81
|
+
):
|
|
82
|
+
"""Lista as rotas descobertas em app/, sem subir o servidor."""
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
|
|
85
|
+
from .core.routes import discover_pages
|
|
86
|
+
|
|
87
|
+
pages = discover_pages(Path(app_dir))
|
|
88
|
+
if not pages:
|
|
89
|
+
typer.echo(f"Nenhuma página encontrada em ./{app_dir}")
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
|
|
92
|
+
for page in pages:
|
|
93
|
+
typer.echo(f"GET {page.route_path}")
|
|
94
|
+
base = page.route_path.rstrip("/")
|
|
95
|
+
typer.echo(f"POST {base}/_action/{{component_id}}/{{action_name}}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
app()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
blux/core/api.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from fastapi import (
|
|
3
|
+
APIRouter,
|
|
4
|
+
HTTPException,
|
|
5
|
+
Depends
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def api(
|
|
12
|
+
method:Literal["GET", "POST", "PUT"]="GET",
|
|
13
|
+
middeware:list=[],
|
|
14
|
+
title="",
|
|
15
|
+
):
|
|
16
|
+
|
|
17
|
+
def decorador(f:callable):
|
|
18
|
+
@wraps(f)
|
|
19
|
+
def wrapper(*args, **kwargs):
|
|
20
|
+
|
|
21
|
+
return f(*args, **kwargs)
|
|
22
|
+
wrapper._api = True
|
|
23
|
+
wrapper._method = method
|
|
24
|
+
wrapper._title = title
|
|
25
|
+
return wrapper
|
|
26
|
+
|
|
27
|
+
return decorador
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
blux/core/elements.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from html import escape
|
|
6
|
+
from typing import ClassVar, Optional, Callable, Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
# Tags que não têm fechamento (não podem ter filhos nem content)
|
|
11
|
+
SELF_CLOSING_TAGS = {"input", "img", "br", "hr"}
|
|
12
|
+
ROOT = "root_blux_app_ricardo_cayoca"
|
|
13
|
+
|
|
14
|
+
class Element(BaseModel):
|
|
15
|
+
"""Classe base de todo componente. Cada subclasse define seu próprio tag_name."""
|
|
16
|
+
|
|
17
|
+
# ClassVar = atributo fixo da classe, não é campo do Pydantic (não entra
|
|
18
|
+
# no __init__, não pode ser sobrescrito por instância)
|
|
19
|
+
tag_name: ClassVar[str] = "div"
|
|
20
|
+
|
|
21
|
+
id: Optional[str] = None
|
|
22
|
+
class_name: Optional[str] = None
|
|
23
|
+
content: Optional[str] = None
|
|
24
|
+
elements: list["Element"] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
# atributos HTML livres (ex: {"data-foo": "1", "hx-post": "/x"})
|
|
27
|
+
attrs: dict[str, str] = Field(default_factory=dict)
|
|
28
|
+
on_click:Callable=None
|
|
29
|
+
action_args:dict = None
|
|
30
|
+
state:Any=None
|
|
31
|
+
|
|
32
|
+
def html_attrs(self) -> dict[str, str]:
|
|
33
|
+
result: dict[str, str] = {}
|
|
34
|
+
if self.id:
|
|
35
|
+
result["id"] = self.id
|
|
36
|
+
if self.class_name:
|
|
37
|
+
result["class"] = self.class_name
|
|
38
|
+
|
|
39
|
+
if self.on_click:
|
|
40
|
+
result["hx-target"] = f"#{ROOT}"
|
|
41
|
+
result["hx-post"] = f"/_action/{self.on_click.uuid}"
|
|
42
|
+
result["hx-swap"] = "morph:innerHTML"
|
|
43
|
+
result["hx-push-url"] = "false"
|
|
44
|
+
|
|
45
|
+
if self.action_args:
|
|
46
|
+
result["hx-vals"] = json.dumps(self.action_args)
|
|
47
|
+
|
|
48
|
+
if self.state:
|
|
49
|
+
result["data-state"] = self.state
|
|
50
|
+
result.update(self.attrs)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class View(Element):
|
|
55
|
+
"""<div>"""
|
|
56
|
+
tag_name: ClassVar[str] = "div"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Text(Element):
|
|
60
|
+
"""<span>"""
|
|
61
|
+
tag_name: ClassVar[str] = "span"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Button(Element):
|
|
65
|
+
"""<button>"""
|
|
66
|
+
tag_name: ClassVar[str] = "button"
|
|
67
|
+
type: str = "button" # evita submit acidental dentro de um <form>
|
|
68
|
+
|
|
69
|
+
def html_attrs(self) -> dict[str, str]:
|
|
70
|
+
attrs = super().html_attrs()
|
|
71
|
+
attrs["type"] = self.type
|
|
72
|
+
return attrs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Input(Element):
|
|
76
|
+
"""<input> — self-closing, não aceita content nem elements."""
|
|
77
|
+
tag_name: ClassVar[str] = "input"
|
|
78
|
+
type: str = "text" # text, email, password, number, checkblux, radio...
|
|
79
|
+
name: Optional[str] = None
|
|
80
|
+
value: Optional[str] = None
|
|
81
|
+
placeholder: Optional[str] = None
|
|
82
|
+
required: bool = False
|
|
83
|
+
preserve:bool=False
|
|
84
|
+
|
|
85
|
+
def html_attrs(self) -> dict[str, str]:
|
|
86
|
+
attrs = super().html_attrs()
|
|
87
|
+
attrs["type"] = self.type
|
|
88
|
+
if self.name:
|
|
89
|
+
attrs["name"] = self.name
|
|
90
|
+
if self.value is not None:
|
|
91
|
+
attrs["value"] = self.value
|
|
92
|
+
if self.placeholder:
|
|
93
|
+
attrs["placeholder"] = self.placeholder
|
|
94
|
+
if self.required:
|
|
95
|
+
attrs["required"] = "required"
|
|
96
|
+
if self.preserve:
|
|
97
|
+
if not self.id:
|
|
98
|
+
raise ValueError("Input com preserve=True precisa de um id fixo")
|
|
99
|
+
attrs["hx-preserve"] = "true"
|
|
100
|
+
return attrs
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Textarea(Element):
|
|
104
|
+
"""<textarea>"""
|
|
105
|
+
tag_name: ClassVar[str] = "textarea"
|
|
106
|
+
name: Optional[str] = None
|
|
107
|
+
placeholder: Optional[str] = None
|
|
108
|
+
rows: Optional[int] = None
|
|
109
|
+
|
|
110
|
+
def html_attrs(self) -> dict[str, str]:
|
|
111
|
+
attrs = super().html_attrs()
|
|
112
|
+
if self.name:
|
|
113
|
+
attrs["name"] = self.name
|
|
114
|
+
if self.placeholder:
|
|
115
|
+
attrs["placeholder"] = self.placeholder
|
|
116
|
+
if self.rows is not None:
|
|
117
|
+
attrs["rows"] = str(self.rows)
|
|
118
|
+
return attrs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Image(Element):
|
|
122
|
+
"""<img> — self-closing."""
|
|
123
|
+
tag_name: ClassVar[str] = "img"
|
|
124
|
+
src: str = ""
|
|
125
|
+
alt: str = ""
|
|
126
|
+
|
|
127
|
+
def html_attrs(self) -> dict[str, str]:
|
|
128
|
+
attrs = super().html_attrs()
|
|
129
|
+
attrs["src"] = self.src
|
|
130
|
+
attrs["alt"] = self.alt
|
|
131
|
+
return attrs
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Link(Element):
|
|
135
|
+
"""<a>"""
|
|
136
|
+
tag_name: ClassVar[str] = "a"
|
|
137
|
+
href: str = "#"
|
|
138
|
+
target: Optional[str] = None # ex: "_blank"
|
|
139
|
+
onClick:Callable=None
|
|
140
|
+
|
|
141
|
+
def html_attrs(self) -> dict[str, str]:
|
|
142
|
+
attrs = super().html_attrs()
|
|
143
|
+
attrs["href"] = self.href
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if self.target:
|
|
147
|
+
attrs["target"] = self.target
|
|
148
|
+
|
|
149
|
+
if self.target not in ["_blank"] and self.href != "#":
|
|
150
|
+
attrs["hx-get"] = self.href
|
|
151
|
+
attrs["hx-push-url"] = "true"
|
|
152
|
+
attrs["hx-target"] = f"#{ROOT}"
|
|
153
|
+
|
|
154
|
+
#attrs["hx-swap"]="morph"
|
|
155
|
+
|
|
156
|
+
return attrs
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TableCell(Element):
|
|
160
|
+
"""<td>"""
|
|
161
|
+
tag_name: ClassVar[str] = "td"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TableHeaderCell(Element):
|
|
165
|
+
"""<th>"""
|
|
166
|
+
tag_name: ClassVar[str] = "th"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TableRow(Element):
|
|
170
|
+
"""<tr> — elements deve ser uma lista de TableCell ou TableHeaderCell."""
|
|
171
|
+
tag_name: ClassVar[str] = "tr"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Table(Element):
|
|
175
|
+
"""<table> — elements deve ser uma lista de TableRow.
|
|
176
|
+
|
|
177
|
+
Helper opcional: Table.from_rows(header, rows) monta a tabela inteira
|
|
178
|
+
a partir de listas simples, sem você ter que montar TableRow/TableCell
|
|
179
|
+
manualmente.
|
|
180
|
+
"""
|
|
181
|
+
tag_name: ClassVar[str] = "table"
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def from_rows(cls, header: list[str], rows: list[list[str]], **kwargs) -> "Table":
|
|
185
|
+
header_row = TableRow(elements=[TableHeaderCell(content=h) for h in header])
|
|
186
|
+
body_rows = [
|
|
187
|
+
TableRow(elements=[TableCell(content=str(cell)) for cell in row])
|
|
188
|
+
for row in rows
|
|
189
|
+
]
|
|
190
|
+
return cls(elements=[header_row, *body_rows], **kwargs)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class Modal(Element):
|
|
194
|
+
"""<dialog> — modal nativo do HTML, sem depender de JS extra pro caso
|
|
195
|
+
básico. Alterne `open` entre renders pra abrir/fechar.
|
|
196
|
+
"""
|
|
197
|
+
tag_name: ClassVar[str] = "dialog"
|
|
198
|
+
open: bool = False
|
|
199
|
+
|
|
200
|
+
def html_attrs(self) -> dict[str, str]:
|
|
201
|
+
attrs = super().html_attrs()
|
|
202
|
+
if self.open:
|
|
203
|
+
attrs["open"] = "open"
|
|
204
|
+
return attrs
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Form(Element):
|
|
208
|
+
"""<form> — elements deve conter os campos (Input, Textarea, Button...)."""
|
|
209
|
+
tag_name: ClassVar[str] = "form"
|
|
210
|
+
action:Callable = None
|
|
211
|
+
method: str = "post" # get | post
|
|
212
|
+
|
|
213
|
+
def html_attrs(self) -> dict[str, str]:
|
|
214
|
+
attrs = super().html_attrs()
|
|
215
|
+
if self.action:
|
|
216
|
+
#attrs["action"] = self.action
|
|
217
|
+
attrs["hx-target"] = f"#{ROOT}"
|
|
218
|
+
attrs["hx-post"] = f"/_action/{self.action.uuid}"
|
|
219
|
+
attrs["hx-swap"] = "morph:innerHTML"
|
|
220
|
+
attrs["hx-push-url"] = "false"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
attrs["method"] = self.method
|
|
224
|
+
return attrs
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Raw(Element):
|
|
228
|
+
"""Escape hatch: qualquer tag HTML sem componente próprio ainda.
|
|
229
|
+
Ex: Raw(tag="video", attrs={"controls": "controls", "src": "/v.mp4"})
|
|
230
|
+
"""
|
|
231
|
+
tag: str = "div"
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def tag_name(self) -> str: # type: ignore[override]
|
|
235
|
+
return self.tag
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# Pydantic v2 precisa disso pra resolver `list["Element"]` (referência
|
|
239
|
+
# recursiva declarada antes da classe existir por completo).
|
|
240
|
+
Element.model_rebuild()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def render(element: Element) -> str:
|
|
244
|
+
"""Serializa recursivamente uma árvore de Element em uma string HTML."""
|
|
245
|
+
tag = element.tag_name
|
|
246
|
+
attrs = element.html_attrs()
|
|
247
|
+
|
|
248
|
+
attrs_str = "".join(
|
|
249
|
+
f' {key}="{escape(str(value), quote=True)}"' for key, value in attrs.items()
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if tag in SELF_CLOSING_TAGS:
|
|
253
|
+
return f"<{tag}{attrs_str} />"
|
|
254
|
+
|
|
255
|
+
inner = ""
|
|
256
|
+
if element.content:
|
|
257
|
+
inner += escape(element.content)
|
|
258
|
+
|
|
259
|
+
for child in element.elements:
|
|
260
|
+
inner += render(child)
|
|
261
|
+
|
|
262
|
+
return f"<{tag}{attrs_str}>{inner}</{tag}>"
|
|
263
|
+
|
|
264
|
+
|