wip
This commit is contained in:
parent
a70c24398a
commit
e181f57a91
30 changed files with 2458 additions and 197 deletions
118
alembic.ini
Normal file
118
alembic.ini
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
revision_id = alembic.util.rev_id()
|
||||
project = htmx-patterns
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
; sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
|
@ -5,11 +5,15 @@ from htmx_patterns.__about__ import __version__
|
|||
from htmx_patterns.boosted.router import boosted_router
|
||||
from htmx_patterns.config import get_config
|
||||
from htmx_patterns.infinite.router import infinite_router
|
||||
from htmx_patterns.toast.router import toast_router
|
||||
from htmx_patterns.websocket.router import websocket_router
|
||||
|
||||
|
||||
def set_prefers(
|
||||
request: Request,
|
||||
request: Request = None,
|
||||
):
|
||||
if request is None:
|
||||
return
|
||||
hx_request_header = request.headers.get("hx-request")
|
||||
user_agent = request.headers.get("user-agent", "").lower()
|
||||
if hx_request_header:
|
||||
|
|
@ -38,6 +42,8 @@ config = get_config()
|
|||
|
||||
app.include_router(infinite_router)
|
||||
app.include_router(boosted_router)
|
||||
app.include_router(toast_router)
|
||||
app.include_router(websocket_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
|
@ -63,7 +69,19 @@ async def app_css(request: Request):
|
|||
return FileResponse("templates/app.css")
|
||||
|
||||
|
||||
@app.get("/htmx")
|
||||
async def htmx(request: Request):
|
||||
@app.get("/htmx.js")
|
||||
async def htmx_js(request: Request):
|
||||
"use a proper static file server like nginx or apache in production"
|
||||
return config.templates.TemplateResponse("htmx.js", {"request": request})
|
||||
|
||||
|
||||
@app.get("/ws.js")
|
||||
async def ws_js(request: Request):
|
||||
"use a proper static file server like nginx or apache in production"
|
||||
return config.templates.TemplateResponse("ws.js", {"request": request})
|
||||
|
||||
|
||||
@app.get("/tailwind.js")
|
||||
async def tailwind_js(request: Request):
|
||||
"use a proper static file server like nginx or apache in production"
|
||||
return config.templates.TemplateResponse("tailwind.js", {"request": request})
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
from datetime import date, datetime
|
||||
from typing import List, Union
|
||||
|
||||
from faker import Faker
|
||||
from polyfactory.factories.pydantic_factory import ModelFactory
|
||||
from pydantic import UUID4, BaseModel
|
||||
|
||||
faker = Faker()
|
||||
|
||||
|
||||
class Person(BaseModel):
|
||||
id: UUID4
|
||||
name: str
|
||||
birthday: Union[datetime, date]
|
||||
phone_number: str
|
||||
|
||||
|
||||
class PersonFactory(ModelFactory):
|
||||
name = faker.name
|
||||
phone_number = faker.phone_number
|
||||
__model__ = Person
|
||||
|
||||
|
||||
# result = PersonFactory.build()
|
||||
|
|
@ -1,24 +1,42 @@
|
|||
import asyncio
|
||||
import time
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from fastapi.requests import Request
|
||||
from sqlmodel import Session
|
||||
|
||||
boosted_router = APIRouter(prefix="/boosted", tags=["Shots Methods"])
|
||||
|
||||
from htmx_patterns.boosted.models import PersonFactory
|
||||
from htmx_patterns.config import get_config
|
||||
from htmx_patterns.config import get_config, get_session
|
||||
from htmx_patterns.models import Person
|
||||
|
||||
boosted_router = APIRouter(prefix="/boosted", tags=["Boosted"])
|
||||
|
||||
config = get_config()
|
||||
|
||||
|
||||
@boosted_router.get("/")
|
||||
@boosted_router.get("")
|
||||
async def boosted(request: Request, id: int = 0):
|
||||
# simulate getting a person by id
|
||||
person = PersonFactory.build()
|
||||
async def boosted(
|
||||
request: Request,
|
||||
id: int = 1,
|
||||
session: Session = Depends(get_session),
|
||||
user_agent: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
# person = PersonFactory.build()
|
||||
person = Person.get_by_id(session, id)
|
||||
|
||||
if id > 0:
|
||||
if person is None:
|
||||
return config.templates.TemplateResponse(
|
||||
"boosted/person.html",
|
||||
{
|
||||
"request": request,
|
||||
"person": None,
|
||||
"person_id": id,
|
||||
"prev_id": None,
|
||||
"next_id": 1,
|
||||
},
|
||||
)
|
||||
|
||||
if id > 1:
|
||||
prev_id = id - 1
|
||||
next_id = id + 1
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import alembic
|
||||
import typer
|
||||
import uvicorn
|
||||
from alembic.config import Config
|
||||
from rich.console import Console
|
||||
|
||||
from htmx_patterns.config import get_config
|
||||
|
|
@ -7,7 +9,6 @@ from htmx_patterns.config import get_config
|
|||
api_app = typer.Typer()
|
||||
|
||||
|
||||
|
||||
@api_app.callback()
|
||||
def api():
|
||||
"model cli"
|
||||
|
|
@ -37,6 +38,10 @@ def run(
|
|||
):
|
||||
config = get_config(env)
|
||||
Console().print(config.api_server)
|
||||
Console().print(config.database_url)
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", config.database_url)
|
||||
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
|
||||
uvicorn.run(**config.api_server.dict())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
import os
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache, partial
|
||||
from typing import Any, Optional
|
||||
from functools import lru_cache
|
||||
import os
|
||||
from typing import Optional
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import jinja2
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import jinja2
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from rich.console import Console
|
||||
from sqlalchemy import create_engine
|
||||
from sqlmodel import Session
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from htmx_patterns import models
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
__all__ = ["models"]
|
||||
|
||||
console = Console()
|
||||
|
||||
|
|
@ -32,7 +40,6 @@ class ApiServer(BaseModel):
|
|||
proxy_headers: bool = True
|
||||
|
||||
|
||||
|
||||
@pass_context
|
||||
def url_for_query(context: dict, name: str, **params: dict) -> str:
|
||||
request = context["request"]
|
||||
|
|
@ -89,9 +96,10 @@ def get_templates(config: BaseSettings) -> Jinja2Templates:
|
|||
|
||||
|
||||
class Config(BaseSettings):
|
||||
env: str
|
||||
env: str = "local"
|
||||
the_templates: Optional[Jinja2Templates] = Field(None, exclude=True)
|
||||
api_server: ApiServer = ApiServer()
|
||||
database_url: str = "sqlite:///database.db"
|
||||
|
||||
@property
|
||||
def templates(self) -> Jinja2Templates:
|
||||
|
|
@ -102,6 +110,36 @@ class Config(BaseSettings):
|
|||
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, config: Config) -> None:
|
||||
self.config = config
|
||||
|
||||
self.db_conf = {}
|
||||
if "sqlite" in self.config.database_url:
|
||||
self.db_conf = {
|
||||
"connect_args": {"check_same_thread": False},
|
||||
"pool_recycle": 3600,
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
# if os.environ.get("ENV") == "test":
|
||||
# self._engine = create_engine(
|
||||
# "sqlite://",
|
||||
# connect_args={"check_same_thread": False},
|
||||
# poolclass=StaticPool,
|
||||
# )
|
||||
# else:
|
||||
self._engine = create_engine(self.config.database_url, **self.db_conf)
|
||||
|
||||
@property
|
||||
def engine(self) -> "Engine":
|
||||
return self._engine
|
||||
|
||||
@property
|
||||
def session(self) -> "Session":
|
||||
return Session(self.engine)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_config(env: Optional[str] = None):
|
||||
if env is None:
|
||||
|
|
@ -110,3 +148,12 @@ def get_config(env: Optional[str] = None):
|
|||
config = Config()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
config = get_config()
|
||||
database = Database(config)
|
||||
|
||||
|
||||
def get_session() -> "Session":
|
||||
with Session(database.engine) as session:
|
||||
yield session
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
from datetime import date, datetime
|
||||
from typing import List, Union
|
||||
|
||||
from faker import Faker
|
||||
from polyfactory.factories.pydantic_factory import ModelFactory
|
||||
from pydantic import UUID4, BaseModel
|
||||
|
||||
faker = Faker()
|
||||
|
||||
|
||||
class Person(BaseModel):
|
||||
id: UUID4
|
||||
name: str
|
||||
birthday: Union[datetime, date]
|
||||
phone_number: str
|
||||
|
||||
|
||||
class PersonFactory(ModelFactory):
|
||||
name = faker.name
|
||||
phone_number = faker.phone_number
|
||||
__model__ = Person
|
||||
|
||||
|
||||
# result = PersonFactory.build()
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import asyncio
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.requests import Request
|
||||
|
||||
infinite_router = APIRouter(prefix="/infinite", tags=["Shots Methods"])
|
||||
|
||||
from htmx_patterns.config import get_config
|
||||
from htmx_patterns.infinite.models import PersonFactory
|
||||
from htmx_patterns.models import PersonFactory
|
||||
|
||||
infinite_router = APIRouter(prefix="/infinite", tags=["Infinite"])
|
||||
|
||||
config = get_config()
|
||||
|
||||
|
|
|
|||
60
htmx_patterns/models.py
Normal file
60
htmx_patterns/models.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from faker import Faker
|
||||
from polyfactory.factories.pydantic_factory import ModelFactory
|
||||
from pydantic import UUID4
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
faker = Faker()
|
||||
|
||||
|
||||
class PersonBase(SQLModel, table=False):
|
||||
id: UUID4
|
||||
name: str
|
||||
birthday: datetime
|
||||
phone_number: str
|
||||
|
||||
def get_by_id(session, id):
|
||||
return session.get(Person, id)
|
||||
|
||||
def save(self, session):
|
||||
session.add(self)
|
||||
session.commit()
|
||||
session.refresh(self)
|
||||
return self
|
||||
|
||||
def delete(self, session):
|
||||
session.delete(self)
|
||||
session.commit()
|
||||
return self
|
||||
|
||||
def update(self, session):
|
||||
session.merge(self)
|
||||
session.commit()
|
||||
session.refresh(self)
|
||||
return self
|
||||
|
||||
def all(session):
|
||||
return session.query(Person).all()
|
||||
|
||||
def paginate(session, page=1, page_size=10):
|
||||
return session.query(Person).offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
|
||||
class Person(PersonBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
||||
|
||||
class PersonCreate(PersonBase):
|
||||
pass
|
||||
|
||||
|
||||
class PersonRead(PersonBase):
|
||||
id: int = Field(default=None, primary_key=True)
|
||||
|
||||
|
||||
class PersonFactory(ModelFactory):
|
||||
name = faker.name
|
||||
phone_number = faker.phone_number
|
||||
__model__ = Person
|
||||
49
htmx_patterns/toast/router.py
Normal file
49
htmx_patterns/toast/router.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from fastapi import APIRouter
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
|
||||
from htmx_patterns.config import get_config
|
||||
|
||||
toast_router = APIRouter(prefix="/toast", tags=["toast"])
|
||||
|
||||
config = get_config()
|
||||
|
||||
|
||||
@toast_router.get("/")
|
||||
@toast_router.get("")
|
||||
async def get_toast(
|
||||
request: Request,
|
||||
):
|
||||
return config.templates.TemplateResponse(
|
||||
"toast/toast.html",
|
||||
{
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@toast_router.post("/")
|
||||
@toast_router.post("")
|
||||
async def post_toast(
|
||||
request: Request,
|
||||
):
|
||||
return config.templates.TemplateResponse(
|
||||
"toast/toast-message.html",
|
||||
{
|
||||
"request": request,
|
||||
"message": "Submitted",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@toast_router.get("/null/")
|
||||
@toast_router.get("/null")
|
||||
@toast_router.delete("/null/")
|
||||
@toast_router.delete("/null")
|
||||
@toast_router.post("/null/")
|
||||
@toast_router.post("/null")
|
||||
async def null(
|
||||
request: Request,
|
||||
):
|
||||
return PlainTextResponse("")
|
||||
22
htmx_patterns/websocket/dependencies.py
Normal file
22
htmx_patterns/websocket/dependencies.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Class defining socket events"""
|
||||
|
||||
def __init__(self):
|
||||
"""init method, keeping track of connections"""
|
||||
self.active_connections = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
"""connect event"""
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
async def send_personal_message(self, message: str, websocket: WebSocket):
|
||||
"""Direct Message"""
|
||||
await websocket.send_text(message)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
"""disconnect event"""
|
||||
self.active_connections.remove(websocket)
|
||||
39
htmx_patterns/websocket/router.py
Normal file
39
htmx_patterns/websocket/router.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from htmx_patterns.websocket.dependencies import ConnectionManager
|
||||
|
||||
from htmx_patterns.config import get_config
|
||||
|
||||
|
||||
websocket_router = APIRouter(prefix="/websocket", tags=["Websocket"])
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
config = get_config()
|
||||
|
||||
|
||||
@websocket_router.get("/")
|
||||
def websocket_index(request: Request):
|
||||
return config.templates.TemplateResponse(
|
||||
"websocket/index.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
@websocket_router.websocket("/")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
):
|
||||
await manager.connect(websocket)
|
||||
await manager.send_personal_message("Hello", websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await manager.send_personal_message(f"Received:{data}", websocket)
|
||||
# import time
|
||||
#
|
||||
# time.sleep(1)
|
||||
# data = "hello"
|
||||
# await manager.send_personal_message(f"Received:{data}", websocket)
|
||||
#
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
await manager.send_personal_message("Bye!!!", websocket)
|
||||
17
justfile
17
justfile
|
|
@ -4,7 +4,24 @@ default:
|
|||
build-image:
|
||||
podman build -t docker.io/waylonwalker/htmx-patterns-waylonwalker-com:$(hatch version) .
|
||||
|
||||
tag-wayl-one:
|
||||
podman tag docker.io/waylonwalker/htmx-patterns-waylonwalker-com:$(hatch version) registry.wayl.one/htmx-patterns-waylonwalker-com:latest
|
||||
podman tag docker.io/waylonwalker/htmx-patterns-waylonwalker-com:$(hatch version) registry.wayl.one/htmx-patterns-waylonwalker-com:$(hatch version)
|
||||
push-wayl-one:
|
||||
podman push registry.wayl.one/htmx-patterns-waylonwalker-com:latest
|
||||
podman push registry.wayl.one/htmx-patterns-waylonwalker-com:$(hatch version)
|
||||
|
||||
push-image:
|
||||
podman push docker.io/waylonwalker/htmx-patterns-waylonwalker-com:$(hatch version)
|
||||
|
||||
shell:
|
||||
hatch shell
|
||||
|
||||
run:
|
||||
uv run htmx-patterns api run
|
||||
|
||||
lint:
|
||||
ruff format htmx_patterns
|
||||
ruff check --fix htmx_patterns
|
||||
|
||||
|
||||
|
|
|
|||
90
migrations/env.py
Normal file
90
migrations/env.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
# connectable = engine_from_config(
|
||||
# config.get_section(config.config_ini_section, {}),
|
||||
# prefix="sqlalchemy.",
|
||||
# poolclass=pool.NullPool,
|
||||
# )
|
||||
from htmx_patterns.config import Database, get_config
|
||||
|
||||
project_config = get_config()
|
||||
database = Database(project_config)
|
||||
|
||||
config.set_main_option("sqlalchemy.url", project_config.database_url)
|
||||
# connectable = engine_from_config(
|
||||
# config.get_section(config.config_ini_section, {}),
|
||||
# prefix="sqlalchemy.",
|
||||
# poolclass=pool.NullPool,
|
||||
# )
|
||||
|
||||
with database.engine.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
version_table=f'{config.get_main_option("project")}_alembic_version',
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
27
migrations/script.py.mako
Normal file
27
migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
39
migrations/versions/bf7df4c30cea_init.py
Normal file
39
migrations/versions/bf7df4c30cea_init.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""init
|
||||
|
||||
Revision ID: bf7df4c30cea
|
||||
Revises:
|
||||
Create Date: 2024-04-07 09:47:07.245218
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "bf7df4c30cea"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"person",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("birthday", sa.DateTime(), nullable=False),
|
||||
sa.Column("phone_number", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table("person")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -36,6 +36,7 @@ dependencies = [
|
|||
"jinja2",
|
||||
"uvicorn[standard]",
|
||||
"typer",
|
||||
"alembic",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -8,47 +8,46 @@
|
|||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
height: .5rem;
|
||||
width: .5rem;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: #450a0a;
|
||||
background-color: #002600;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-track {
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: #450a0a;
|
||||
background-color: #002600;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
||||
background-color: #1aff1a;
|
||||
background-color: #00b300;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: #f87171;
|
||||
background-color: #dc2626
|
||||
background-color: #1aff1a;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb {
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
||||
background-color: #00b300;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar-thumb:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: #f87171;
|
||||
background-color: #dc2626
|
||||
background-color: #1aff1a;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +1,155 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
{% block head %}
|
||||
<title>
|
||||
{% block title %}HTMX Patterns{% endblock %}
|
||||
</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="og:title" name="og:title" content="HTMX Patterns from the hypermedia.systems book" />
|
||||
<meta name="twitter:title" name="twitter:title" content="HTMX Patterns" />
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="og:image" name="og:image"
|
||||
content="https://shots.wayl.one/shot/?url={{ request.url }}&height=600&width=1200&scaled_width=1200&scaled_height=600&selectors=" />
|
||||
<meta name="twitter:image" name="twitter:image"
|
||||
content="https://shots.wayl.one/shot/?url={{ request.url }}&height=640&width=1280&scaled_width=1280&scaled_height=640&selectors=" />
|
||||
<meta name="og:image:height" content="640" />
|
||||
<meta name="og:image:width" content="1280" />
|
||||
<meta name="og:url" name="og:url" content="{{ request.url }}" />
|
||||
<meta name="description" name="description" content="HTMX Patterns from the hypermedia.systems book" />
|
||||
<meta name="og:description" name="Check if my kids can play outside"
|
||||
content="HTMX Patterns from the hypermedia.systems book" />
|
||||
<meta name="twitter:description" name="twitter:description"
|
||||
content="HTMX Patterns from the hypermedia.systems book" />
|
||||
<head>
|
||||
{% block head %}
|
||||
<title>
|
||||
{% block title %}HTMX Patterns{% endblock %}
|
||||
</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="og:title" name="og:title" content="HTMX Patterns from the hypermedia.systems book" />
|
||||
<meta name="twitter:title" name="twitter:title" content="HTMX Patterns" />
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="og:image" name="og:image"
|
||||
content="https://shots.wayl.one/shot/?url={{ request.url }}&height=600&width=1200&scaled_width=1200&scaled_height=600&selectors=" />
|
||||
<meta name="twitter:image" name="twitter:image"
|
||||
content="https://shots.wayl.one/shot/?url={{ request.url }}&height=640&width=1280&scaled_width=1280&scaled_height=640&selectors=" />
|
||||
<meta name="og:image:height" content="640" />
|
||||
<meta name="og:image:width" content="1280" />
|
||||
<meta name="og:url" name="og:url" content="{{ request.url }}" />
|
||||
<meta name="description" name="description" content="HTMX Patterns from the hypermedia.systems book" />
|
||||
<meta name="og:description" name="Check if my kids can play outside"
|
||||
content="HTMX Patterns from the hypermedia.systems book" />
|
||||
<meta name="twitter:description" name="twitter:description"
|
||||
content="HTMX Patterns from the hypermedia.systems book" />
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('favicon') }}" />
|
||||
<link href="{{ url_for('app_css') }}" rel="stylesheet" />
|
||||
<script src="{{ url_for('htmx') }}"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
{% if DEBUG %}
|
||||
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('favicon') }}" />
|
||||
<link href="{{ url_for('app_css') }}" rel="stylesheet" />
|
||||
<script src="{{ url_for('htmx_js') }}"></script>
|
||||
<script src="{{ url_for('ws_js') }}"></script>
|
||||
<script src="{{ url_for('tailwind_js') }}"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
clifford: '#da373d',
|
||||
//terminal
|
||||
'terminal-100': '#e6ffe6',
|
||||
'terminal-200': '#b3ffb3',
|
||||
'terminal-300': '#80ff80',
|
||||
'terminal-400': '#4dff4d',
|
||||
'terminal-500': '#1aff1a',
|
||||
'terminal-600': '#00e600',
|
||||
'terminal-700': '#00b300',
|
||||
'terminal-800': '#008000',
|
||||
'terminal-900': '#004d00',
|
||||
'terminal-950': '#002600',
|
||||
|
||||
{% set links = {
|
||||
"HTMX-PATTERNS": url_for("index"),
|
||||
"Boosted Links": url_for('boosted'),
|
||||
"Infinite Scroll": url_for('infinite'),
|
||||
} %}
|
||||
//
|
||||
// // aqua
|
||||
// 'terminal-50': '#f0f9ff',
|
||||
// 'terminal-100': '#e0f2fe',
|
||||
// 'terminal-200': '#bae6fd',
|
||||
// 'terminal-300': '#7dd3fc',
|
||||
// 'terminal-400': '#38bdf8',
|
||||
// 'terminal-500': '#0ea5e9',
|
||||
// 'terminal-600': '#0284c7',
|
||||
// 'terminal-700': '#0369a1',
|
||||
// 'terminal-800': '#075985',
|
||||
// 'terminal-900': '#0c4a6e',
|
||||
// 'terminal-950': '#042c47',
|
||||
//
|
||||
// //rainbow
|
||||
// 'terminal-50': '#ffecf6',
|
||||
// 'terminal-100': '#fbb6ce',
|
||||
// 'terminal-200': '#f687b3',
|
||||
// 'terminal-300': '#ed64a6',
|
||||
// 'terminal-400': '#d53f8c',
|
||||
// 'terminal-500': '#b83280',
|
||||
// 'terminal-600': '#97266d',
|
||||
// 'terminal-700': '#702459',
|
||||
// 'terminal-800': '#521b41',
|
||||
// 'terminal-900': '#36162e',
|
||||
// 'terminal-950': '#1c0e1f',
|
||||
//
|
||||
// // sherbert lemon
|
||||
// 'terminal-50': '#fffdf1',
|
||||
// 'terminal-100': '#fffbda',
|
||||
// 'terminal-200': '#fff7b3',
|
||||
// 'terminal-300': '#fff280',
|
||||
// 'terminal-400': '#ffea4d',
|
||||
// 'terminal-500': '#ffe01a',
|
||||
// 'terminal-600': '#e6c900',
|
||||
// 'terminal-700': '#b3a300',
|
||||
// 'terminal-800': '#806c00',
|
||||
// 'terminal-900': '#4d4700',
|
||||
// 'terminal-950': '#262300',
|
||||
//
|
||||
// //cobalt
|
||||
// 'terminal-50': '#f0f5f9',
|
||||
// 'terminal-100': '#d9e5f2',
|
||||
// 'terminal-200': '#a6c0e6',
|
||||
// 'terminal-300': '#739bda',
|
||||
// 'terminal-400': '#4b7fce',
|
||||
// 'terminal-400': '#ffea4d',
|
||||
// 'terminal-500': '#315fb6',
|
||||
// 'terminal-600': '#e6c900',
|
||||
// 'terminal-700': '#b3a300',
|
||||
// 'terminal-800': '#1c3b67',
|
||||
// 'terminal-900': '#4d4700',
|
||||
// 'terminal-950': '#0e1d30',
|
||||
//
|
||||
boxShadow: {
|
||||
xlc: "0 0 60px 15px rgba(0, 0, 0, 0.3)",
|
||||
lgc: "0 0 20px 0px #80ff80",
|
||||
},
|
||||
}
|
||||
|
||||
<body
|
||||
class="justify-center items-center min-h-screen bg-gray-900 bg-no-repeat bg-cover bg-gradient-to-b from-pink-950/50 min-w-screen text-shadow-xl text-shadow-zinc-950 w-screen h-screen">
|
||||
<div id="grit"
|
||||
class="absolute top-0 right-0 bottom-0 left-0 justify-center items-center min-w-full bg-repeat bg-cover"
|
||||
style="background-image: url(https://fokais.com/grit.svg), url(https://fokais.com/grit-light.svg); animation: pulse 10s cubic-bezier(0.4, 0, 0.6, 1) infinite; pointer-events: none">
|
||||
</div>
|
||||
<div id="content" class="flex flex-col items-center min-h-screen min-w-screen text-white border-b">
|
||||
<nav
|
||||
class="flex flex-col sm:flex-row flex-wrap w-screen gap-x-8 gap-y-2 justify-center items-center w-full p-4 bg-black border-b-4 border-gray-800 mb-8">
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% if DEBUG %}
|
||||
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
||||
{% set links = {
|
||||
"HTMX-PATTERNS": url_for("index"),
|
||||
"Boosted Links": url_for('boosted'),
|
||||
"Infinite Scroll": url_for('infinite'),
|
||||
"Toast": url_for('get_toast'),
|
||||
"WebSocket": url_for('websocket_index'),
|
||||
} %}
|
||||
|
||||
<body
|
||||
class="justify-center items-center min-h-screen bg-gray-900 bg-no-repeat bg-cover bg-gradient-to-b from-terminal-950/20 min-w-screen text-shadow-xl text-shadow-zinc-950 w-screen h-screen overflow-x-hidden">
|
||||
|
||||
<div id="grit"
|
||||
class="absolute top-0 right-0 bottom-0 left-0 justify-center items-center min-w-full bg-repeat bg-cover"
|
||||
style="background-image: url(https://fokais.com/grit.svg), url(https://fokais.com/grit-light.svg); animation: pulse 10s cubic-bezier(0.4, 0, 0.6, 1) infinite; pointer-events: none">
|
||||
</div>
|
||||
<div id="content" class="flex flex-col items-center min-h-screen min-w-screen text-white border-b">
|
||||
<nav
|
||||
class="flex flex-col sm:flex-row flex-wrap w-screen gap-x-8 gap-y-2 justify-center items-center w-full p-4 bg-black border-b-2 border-terminal-400 mb-8 font-mono">
|
||||
<!-- <a href="/" class="text-3xl gap-4 font-bold">HTMX PATTERNS</a> -->
|
||||
<!-- <a href="/infinite" class="text-3xl font-bold text-yellow-400">INFINITE</a> -->
|
||||
{% for link, url in links.items() %}
|
||||
<a href="{{ url }}"
|
||||
class="text-xl sm:text-3xl font-bold uppercase {% if not loop.first %}text-yellow-400{% endif %}">{{
|
||||
link
|
||||
}}</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% block content %}
|
||||
{{ body | safe }}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
{% for link, url in links.items() %}
|
||||
<a href="{{ url }}"
|
||||
class="text-xl sm:text-xl font-thin uppercase {% if not loop.first %}text-terminal-600{% endif %}">
|
||||
{{ link }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% block content %}
|
||||
{{ body | safe }}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<section id='toast' class="fixed bottom-4 right-4">
|
||||
</section>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,60 +2,62 @@
|
|||
{% block title %}Contact - {{ person_id }} - {{ person.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1 id="title"
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-red-600 via-pink-500 to-yellow-400 ring-red-500 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-thin text-transparent bg-clip-text bg-gradient-to-r from-terminal-600 via-terminal-500 to-terminal-900 ring-red-700 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
HTMX PATTERNS - BOOSTED
|
||||
</h1>
|
||||
|
||||
<p class='text-3xl font-bold mt-0 mb-16 max-w-xl text-center prose-xl'>
|
||||
<p class='text-3xl px-2 mt-0 mb-16 max-w-xl text-center prose-xl text-terminal-500 font-extralight'>
|
||||
Contact - {{ person_id }}
|
||||
</p>
|
||||
|
||||
<p class='text-3xl font-bold mt-0 mb-16 max-w-2xl text-center prose-xl'>
|
||||
{{ person.name }} -
|
||||
{% if person is not none %}
|
||||
<p
|
||||
class='max-w-xl container text-xl font-light px-2 mt-0 mb-4 py-2 text-center text-terminal-500 bg-terminal-950 prose-xl ring-1 ring-terminal-500 rounded-xl shadow-lg shadow-terminal-300/20'>
|
||||
<span class='font-normal'>{{ person.name.upper() }}</span> -
|
||||
{{ person.phone_number }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p
|
||||
class='max-w-xl container text-xl font-light px-2 mt-0 mb-4 py-2 text-center text-terminal-500 bg-terminal-950 prose-xl ring-1 ring-terminal-500 rounded-xl shadow-lg shadow-terminal-300/20'>
|
||||
Person not found
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 class='text-3xl font-bold mt-0 max-w-xl text-center prose-xl mt-8'>
|
||||
{% macro link(id, text, boosted=false) -%}
|
||||
<a
|
||||
class="
|
||||
{% if id is none %}
|
||||
pointer-events-none bg-terminal-950 text-terminal-900 ring-terminal-900
|
||||
{% else %}
|
||||
bg-terminal-950 hover:bg-terminal-900 hover:text-terminal-400 text-terminal-500 shadow-lg shadow-terminal-300/20 hover:shadow-terminal-300/30 ring-terminal-300
|
||||
{% endif %}
|
||||
cursor-pointer block text-center font-bold py-2 px-4 rounded w-full ring-1
|
||||
"
|
||||
{% if id is not none %}
|
||||
href="{{ url_for('boosted', id=id) }}"
|
||||
{% endif %}
|
||||
{% if boosted %}
|
||||
hx-boost="true"
|
||||
{% endif %}>
|
||||
{{ text }}
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
|
||||
<h2 class='text-3xl font-light mt-0 max-w-xl text-center prose-xl mt-8 text-terminal-500'>
|
||||
Boosted Links
|
||||
</h2>
|
||||
|
||||
<div class='flex flex-row gap-4'>
|
||||
{% if prev_id is not none %}
|
||||
<a href="{{ url_for('boosted', id=prev_id) }}"
|
||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded" hx-boost='true'>
|
||||
Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="pointer-events-none bg-gray-500 text-white font-bold py-2 px-4 rounded">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('boosted', id=next_id) }}"
|
||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded" hx-boost='true'>
|
||||
Next
|
||||
</a>
|
||||
{{ link(prev_id, 'Previous', boosted=True) }}
|
||||
{{ link(next_id, 'Next', boosted=True) }}
|
||||
</div>
|
||||
|
||||
<h2 class='text-3xl font-bold mt-0 max-w-xl text-center prose-xl mt-8'>
|
||||
<h2 class='text-3xl font-light mt-0 max-w-xl text-center prose-xl mt-8 text-terminal-500'>
|
||||
Normal Links
|
||||
</h2>
|
||||
|
||||
<div class='flex flex-row gap-4'>
|
||||
{% if prev_id is not none %}
|
||||
<a href="{{ url_for('boosted', id=prev_id) }}"
|
||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||
Previous
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="pointer-events-none bg-gray-500 text-white font-bold py-2 px-4 rounded">
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('boosted', id=next_id) }}"
|
||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||
Next
|
||||
</a>
|
||||
{{ link(prev_id, 'Previous', boosted=False) }}
|
||||
{{ link(next_id, 'Next', boosted=False) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1 id="title"
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-red-600 via-pink-500 to-yellow-400 ring-red-500 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-thin text-transparent bg-clip-text bg-gradient-to-r from-terminal-600 via-terminal-500 to-terminal-900 ring-red-700 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
HTMX PATTERNS
|
||||
</h1>
|
||||
|
||||
<p class='text-3xl px-2 font-bold mt-0 mb-16 max-w-xl text-center prose-xl'>
|
||||
<p class='text-3xl px-2 mt-0 mb-16 max-w-xl text-center prose-xl text-terminal-500 font-extralight'>
|
||||
A collection of HTMX patterns
|
||||
</p>
|
||||
|
||||
<p class='text-xl px-2 mt-0 mb-16 max-w-xl prose-xl text-terminal-500 font-extralight'>
|
||||
These are patterns that I have written based on content from the <a
|
||||
href="https://hypermedia.systems/htmx-in-action/">hypermedia.systems</a>
|
||||
book. There is lots of code duplication as each pattern is meant to be standalone.
|
||||
</p>
|
||||
|
||||
<p class='text-xl px-2 mt-0 mb-16 max-w-xl prose-xl text-terminal-500 font-extralight'>
|
||||
I currently make use of htmx with fastapi, sqlmodel, sqlite, and tailwindcss
|
||||
for many of my projects. These patterns are here to serve for reference to
|
||||
myself implemented using this stack in the most pure way possible to remain
|
||||
simple and understandable. Sometimes real projects get complicated and are
|
||||
hard to bring in new features correctly. This is a playground with completely
|
||||
separate routers, models, and templates for each project.
|
||||
</p>
|
||||
|
||||
<ul id="patterns" class="flex flex-col sm:grid grid-cols-3 gap-4">
|
||||
{% for link, url in links.items() %}
|
||||
<li class='w-full'>
|
||||
<a href="{{ url }}"
|
||||
class="cursor-pointer block bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded w-full">
|
||||
class="cursor-pointer block text-center bg-terminal-950 hover:bg-terminal-900 hover:text-terminal-400 text-terminal-500 font-bold py-2 px-4 rounded w-full ring-terminal-300 ring-1 shadow-lg shadow-terminal-300/20 hover:shadow-terminal-300/30">
|
||||
{{ link }}
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -2,22 +2,22 @@
|
|||
{% block title %}Contacts List{% endblock %}
|
||||
{% block content %}
|
||||
<h1 id="title"
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-black text-transparent bg-clip-text bg-gradient-to-r from-red-600 via-pink-500 to-yellow-400 ring-red-500 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-thin text-transparent bg-clip-text bg-gradient-to-r from-terminal-600 via-terminal-500 to-terminal-900 ring-red-700 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
HTMX PATTERNS - INFINITE
|
||||
</h1>
|
||||
|
||||
<p class='text-3xl font-bold mt-0 mb-16 max-w-xl text-center prose-xl'>
|
||||
<p class='text-3xl px-2 mt-0 mb-16 max-w-xl text-center prose-xl text-terminal-500 font-extralight'>
|
||||
Contacts List
|
||||
</p>
|
||||
|
||||
<ul id="persons" class="flex flex-col gap-4 mb-16 px-4 sm:px-0">
|
||||
<ul id="persons" class="flex flex-col gap-4 mb-16 px-4 sm:px-0 container max-w-xl">
|
||||
{% include "infinite/persons_partial.html" %}
|
||||
</ul>
|
||||
|
||||
<div id='persons-loading' class='spinner mb-24 animate-bounce'>
|
||||
<p class='text-xl prose-xl'>loading more contacts</p>
|
||||
<p class='text-xl prose-xl text-terminal-700'>loading more contacts</p>
|
||||
<p class='text-xl prose-xl'>
|
||||
<em class='text-red-500'>with simulated slow down</em>
|
||||
<em class='text-terminal-500'>with simulated slow down</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
{% for person in persons %}
|
||||
<li
|
||||
{% if loop.last %}
|
||||
<li hx-get="{{ url_for('infinite', page=next_page) }}" hx-trigger="intersect once" hx-target="#persons"
|
||||
hx-swap='beforeend' hx-indicator="#persons-loading"
|
||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||
|
||||
{{ person.name }} -
|
||||
{{ person.phone_number }}
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||
{{ person.name }} -
|
||||
{{ person.phone_number }}
|
||||
</li>
|
||||
hx-get="{{ url_for('infinite', page=next_page) }}"
|
||||
hx-trigger="intersect once"
|
||||
hx-target="#persons"
|
||||
hx-swap='beforeend'
|
||||
hx-indicator="#persons-loading"
|
||||
{% endif %}
|
||||
class='max-w-xl container text-xl font-light px-2 mt-0 mb-4 py-2 text-center text-terminal-500 bg-terminal-950 prose-xl ring-1 ring-terminal-500 rounded-xl shadow-lg shadow-terminal-300/20'>
|
||||
<span class='font-normal'>{{ person.name.upper() }}</span> -
|
||||
{{ person.phone_number }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
|
|
|||
62
templates/tailwind.js
Normal file
62
templates/tailwind.js
Normal file
File diff suppressed because one or more lines are too long
0
templates/tailwindcss.js
Normal file
0
templates/tailwindcss.js
Normal file
9
templates/toast/toast-message.html
Normal file
9
templates/toast/toast-message.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<div
|
||||
class='text-terminal-500 font-extralight text-center text-3xl my-2 bg-terminal-950 ring-5 ring-terminal-500 rounded-lg px-4 py-2'
|
||||
hx-delete="{{ url_for('null') }}"
|
||||
hx-trigger='load delay:5s'
|
||||
hx-swap='outerHTML'
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
17
templates/toast/toast.html
Normal file
17
templates/toast/toast.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Toast{% endblock %}
|
||||
{% block content %}
|
||||
<h1 id="title"
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-thin text-transparent bg-clip-text bg-gradient-to-r from-terminal-600 via-terminal-500 to-terminal-900 ring-red-700 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
HTMX PATTERNS - TOAST
|
||||
</h1>
|
||||
<button
|
||||
class="text-3xl my-24 px-8 py-2 rounded rounded-xl bg-terminal-950 hover:bg-terminal-900 hover:text-terminal-400 text-terminal-500 shadow-lg shadow-terminal-300/20 hover:shadow-terminal-300/30 ring-terminal-300"
|
||||
hx-post="{{ url_for('post_toast') }}"
|
||||
hx-target="#toast"
|
||||
hx-swap="beforeend"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
|
||||
{% endblock %}
|
||||
26
templates/websocket/index.html
Normal file
26
templates/websocket/index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}WebSocket{% endblock %}
|
||||
{% block content %}
|
||||
<h1 id="title"
|
||||
class="inline-block px-4 pb-0 mx-auto mb-0 text-center text-6xl sm:text-8xl font-thin text-transparent bg-clip-text bg-gradient-to-r from-terminal-600 via-terminal-500 to-terminal-900 ring-red-700 text-shadow-xl text-shadow-zinc-950 ring-5 leading-none">
|
||||
HTMX PATTERNS - WEBSOCKET
|
||||
</h1>
|
||||
<button
|
||||
class="text-3xl my-24 px-8 py-2 rounded rounded-xl bg-terminal-950 hover:bg-terminal-900 hover:text-terminal-400 text-terminal-500 shadow-lg shadow-terminal-300/20 hover:shadow-terminal-300/30 ring-terminal-300"
|
||||
hx-post="{{ url_for('post_toast') }}"
|
||||
hx-target="#toast"
|
||||
hx-swap="beforeend"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<div hx-ext="ws" ws-connect="/websocket/">
|
||||
<div id="notifications"></div>
|
||||
<div id="chat_room">
|
||||
...
|
||||
</div>
|
||||
<form id="form" ws-send>
|
||||
<input name="chat_message" class='text-black'>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
476
templates/ws.js
Normal file
476
templates/ws.js
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension("ws", {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function (apiRef) {
|
||||
|
||||
// Store reference to internal API
|
||||
api = apiRef;
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket;
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = "full-jitter";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function (name, evt) {
|
||||
var parent = evt.target || evt.detail.elt;
|
||||
|
||||
switch (name) {
|
||||
|
||||
// Try to close the socket when elements are removed
|
||||
case "htmx:beforeCleanupElement":
|
||||
|
||||
var internalData = api.getInternalData(parent)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close();
|
||||
}
|
||||
return;
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case "htmx:beforeProcessNode":
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
|
||||
ensureWebSocket(child)
|
||||
});
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
|
||||
ensureWebSocketSend(child)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "connect") {
|
||||
return value[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
|
||||
|
||||
if (wssSource == null || wssSource === "") {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt);
|
||||
if (legacySource == null) {
|
||||
return;
|
||||
} else {
|
||||
wssSource = legacySource;
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf("/") === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '');
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = "wss://" + base_part + wssSource;
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = "ws://" + base_part + wssSource;
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function () {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
});
|
||||
|
||||
socketWrapper.addEventListener('message', function (event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var response = event.data;
|
||||
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function (extension) {
|
||||
response = extension.transformResponse(response, null, socketElt);
|
||||
});
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt);
|
||||
var fragment = api.makeFragment(response);
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children);
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks);
|
||||
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
});
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function (event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler);
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = [];
|
||||
}
|
||||
|
||||
this.events[event].push(handler);
|
||||
},
|
||||
|
||||
sendImmediately: function (message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message);
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function (message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message: message, sendElt: sendElt });
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt);
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function () {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
|
||||
this.messageQueue.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function () {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc();
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
|
||||
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = function (e) {
|
||||
wrapper.retryCount = 0;
|
||||
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
|
||||
wrapper.handleQueuedMessages();
|
||||
}
|
||||
|
||||
socket.onclose = function (e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
|
||||
setTimeout(function () {
|
||||
wrapper.retryCount += 1;
|
||||
wrapper.init();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
|
||||
};
|
||||
|
||||
socket.onerror = function (e) {
|
||||
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
|
||||
maybeCloseWebSocketSource(socketElt);
|
||||
};
|
||||
|
||||
var events = this.events;
|
||||
Object.keys(events).forEach(function (k) {
|
||||
events[k].forEach(function (e) {
|
||||
socket.addEventListener(k, e);
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
close: function () {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init();
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return;
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt);
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt);
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt);
|
||||
triggerSpecs.forEach(function (ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket;
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
|
||||
var results = api.getInputValues(sendElt, 'post');
|
||||
var errors = results.errors;
|
||||
var rawParameters = results.values;
|
||||
var expressionVars = api.getExpressionVars(sendElt);
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt);
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers: headers,
|
||||
errors: errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
};
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody;
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters);
|
||||
if (sendConfig.headers)
|
||||
toSend['HEADERS'] = headers;
|
||||
body = JSON.stringify(toSend);
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt);
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay;
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount);
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6);
|
||||
var maxDelay = 1000 * Math.pow(2, exp);
|
||||
return maxDelay * Math.random();
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
api.getInternalData(elt).webSocket.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, []);
|
||||
sock.binaryType = htmx.config.wsBinaryType;
|
||||
return sock;
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
|
||||
result.push(elt);
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue