This commit is contained in:
Waylon S. Walker 2024-12-11 09:17:38 -06:00
parent a70c24398a
commit e181f57a91
30 changed files with 2458 additions and 197 deletions

118
alembic.ini Normal file
View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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("")

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

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

View file

@ -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
View 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
View 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"}

View 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 ###

View file

@ -36,6 +36,7 @@ dependencies = [
"jinja2",
"uvicorn[standard]",
"typer",
"alembic",
]
[project.scripts]

View file

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

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<head>
{% block head %}
<title>
{% block title %}HTMX Patterns{% endblock %}
@ -26,42 +26,130 @@
<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">
<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',
//
// // 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",
},
}
}
}
}
</script>
{% if DEBUG %}
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
{% endif %}
{% endblock %}
</head>
</head>
{% set links = {
"HTMX-PATTERNS": url_for("index"),
"Boosted Links": url_for('boosted'),
"Infinite Scroll": url_for('infinite'),
} %}
{% 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">
<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">
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>
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>
</body>
<section id='toast' class="fixed bottom-4 right-4">
</section>
</body>
</html>

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

0
templates/tailwindcss.js Normal file
View file

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

View 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 %}

View 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
View 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]);
}
}
}
})();

1068
uv.lock generated Normal file

File diff suppressed because it is too large Load diff