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.boosted.router import boosted_router
|
||||||
from htmx_patterns.config import get_config
|
from htmx_patterns.config import get_config
|
||||||
from htmx_patterns.infinite.router import infinite_router
|
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(
|
def set_prefers(
|
||||||
request: Request,
|
request: Request = None,
|
||||||
):
|
):
|
||||||
|
if request is None:
|
||||||
|
return
|
||||||
hx_request_header = request.headers.get("hx-request")
|
hx_request_header = request.headers.get("hx-request")
|
||||||
user_agent = request.headers.get("user-agent", "").lower()
|
user_agent = request.headers.get("user-agent", "").lower()
|
||||||
if hx_request_header:
|
if hx_request_header:
|
||||||
|
|
@ -38,6 +42,8 @@ config = get_config()
|
||||||
|
|
||||||
app.include_router(infinite_router)
|
app.include_router(infinite_router)
|
||||||
app.include_router(boosted_router)
|
app.include_router(boosted_router)
|
||||||
|
app.include_router(toast_router)
|
||||||
|
app.include_router(websocket_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
@ -63,7 +69,19 @@ async def app_css(request: Request):
|
||||||
return FileResponse("templates/app.css")
|
return FileResponse("templates/app.css")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/htmx")
|
@app.get("/htmx.js")
|
||||||
async def htmx(request: Request):
|
async def htmx_js(request: Request):
|
||||||
"use a proper static file server like nginx or apache in production"
|
"use a proper static file server like nginx or apache in production"
|
||||||
return config.templates.TemplateResponse("htmx.js", {"request": request})
|
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
|
from typing import Annotated
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends, Header
|
||||||
from fastapi.requests import Request
|
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, get_session
|
||||||
from htmx_patterns.config import get_config
|
from htmx_patterns.models import Person
|
||||||
|
|
||||||
|
boosted_router = APIRouter(prefix="/boosted", tags=["Boosted"])
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
|
|
||||||
@boosted_router.get("/")
|
@boosted_router.get("/")
|
||||||
@boosted_router.get("")
|
@boosted_router.get("")
|
||||||
async def boosted(request: Request, id: int = 0):
|
async def boosted(
|
||||||
# simulate getting a person by id
|
request: Request,
|
||||||
person = PersonFactory.build()
|
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
|
prev_id = id - 1
|
||||||
next_id = id + 1
|
next_id = id + 1
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import alembic
|
||||||
import typer
|
import typer
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
from alembic.config import Config
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from htmx_patterns.config import get_config
|
from htmx_patterns.config import get_config
|
||||||
|
|
@ -7,7 +9,6 @@ from htmx_patterns.config import get_config
|
||||||
api_app = typer.Typer()
|
api_app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@api_app.callback()
|
@api_app.callback()
|
||||||
def api():
|
def api():
|
||||||
"model cli"
|
"model cli"
|
||||||
|
|
@ -37,6 +38,10 @@ def run(
|
||||||
):
|
):
|
||||||
config = get_config(env)
|
config = get_config(env)
|
||||||
Console().print(config.api_server)
|
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())
|
uvicorn.run(**config.api_server.dict())
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
import os
|
|
||||||
import urllib.parse
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from functools import lru_cache, partial
|
from functools import lru_cache
|
||||||
from typing import Any, Optional
|
import os
|
||||||
|
from typing import Optional
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import jinja2
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
import jinja2
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from rich.console import Console
|
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()
|
console = Console()
|
||||||
|
|
||||||
|
|
@ -32,7 +40,6 @@ class ApiServer(BaseModel):
|
||||||
proxy_headers: bool = True
|
proxy_headers: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pass_context
|
@pass_context
|
||||||
def url_for_query(context: dict, name: str, **params: dict) -> str:
|
def url_for_query(context: dict, name: str, **params: dict) -> str:
|
||||||
request = context["request"]
|
request = context["request"]
|
||||||
|
|
@ -89,9 +96,10 @@ def get_templates(config: BaseSettings) -> Jinja2Templates:
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
env: str
|
env: str = "local"
|
||||||
the_templates: Optional[Jinja2Templates] = Field(None, exclude=True)
|
the_templates: Optional[Jinja2Templates] = Field(None, exclude=True)
|
||||||
api_server: ApiServer = ApiServer()
|
api_server: ApiServer = ApiServer()
|
||||||
|
database_url: str = "sqlite:///database.db"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def templates(self) -> Jinja2Templates:
|
def templates(self) -> Jinja2Templates:
|
||||||
|
|
@ -102,6 +110,36 @@ class Config(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
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
|
@lru_cache
|
||||||
def get_config(env: Optional[str] = None):
|
def get_config(env: Optional[str] = None):
|
||||||
if env is None:
|
if env is None:
|
||||||
|
|
@ -110,3 +148,12 @@ def get_config(env: Optional[str] = None):
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
return 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 asyncio
|
||||||
import time
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.requests import Request
|
from fastapi.requests import Request
|
||||||
|
|
||||||
infinite_router = APIRouter(prefix="/infinite", tags=["Shots Methods"])
|
|
||||||
|
|
||||||
from htmx_patterns.config import get_config
|
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()
|
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:
|
build-image:
|
||||||
podman build -t docker.io/waylonwalker/htmx-patterns-waylonwalker-com:$(hatch version) .
|
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:
|
push-image:
|
||||||
podman push docker.io/waylonwalker/htmx-patterns-waylonwalker-com:$(hatch version)
|
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",
|
"jinja2",
|
||||||
"uvicorn[standard]",
|
"uvicorn[standard]",
|
||||||
"typer",
|
"typer",
|
||||||
|
"alembic",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
|
|
@ -8,47 +8,46 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
height: 1rem;
|
height: .5rem;
|
||||||
width: 1rem;
|
width: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: #450a0a;
|
background-color: #002600;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::-webkit-scrollbar-track {
|
body::-webkit-scrollbar-track {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: #450a0a;
|
background-color: #002600;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
background-color: #1aff1a;
|
||||||
|
background-color: #00b300;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: #f87171;
|
background-color: #1aff1a;
|
||||||
background-color: #dc2626
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body::-webkit-scrollbar-thumb {
|
body::-webkit-scrollbar-thumb {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
background-color: #00b300;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::-webkit-scrollbar-thumb:hover {
|
body::-webkit-scrollbar-thumb:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: #f87171;
|
background-color: #1aff1a;
|
||||||
background-color: #dc2626
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,155 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>
|
<title>
|
||||||
{% block title %}HTMX Patterns{% endblock %}
|
{% block title %}HTMX Patterns{% endblock %}
|
||||||
</title>
|
</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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="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:title" name="twitter:title" content="HTMX Patterns" />
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="og:image" name="og: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=" />
|
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"
|
<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=" />
|
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:height" content="640" />
|
||||||
<meta name="og:image:width" content="1280" />
|
<meta name="og:image:width" content="1280" />
|
||||||
<meta name="og:url" name="og:url" content="{{ request.url }}" />
|
<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="description" name="description" content="HTMX Patterns from the hypermedia.systems book" />
|
||||||
<meta name="og:description" name="Check if my kids can play outside"
|
<meta name="og:description" name="Check if my kids can play outside"
|
||||||
content="HTMX Patterns from the hypermedia.systems book" />
|
content="HTMX Patterns from the hypermedia.systems book" />
|
||||||
<meta name="twitter:description" name="twitter:description"
|
<meta name="twitter:description" name="twitter:description"
|
||||||
content="HTMX Patterns from the hypermedia.systems book" />
|
content="HTMX Patterns from the hypermedia.systems book" />
|
||||||
|
|
||||||
<link rel="icon" type="image/x-icon" href="{{ url_for('favicon') }}" />
|
<link rel="icon" type="image/x-icon" href="{{ url_for('favicon') }}" />
|
||||||
<link href="{{ url_for('app_css') }}" rel="stylesheet" />
|
<link href="{{ url_for('app_css') }}" rel="stylesheet" />
|
||||||
<script src="{{ url_for('htmx') }}"></script>
|
<script src="{{ url_for('htmx_js') }}"></script>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<script src="{{ url_for('ws_js') }}"></script>
|
||||||
{% if DEBUG %}
|
<script src="{{ url_for('tailwind_js') }}"></script>
|
||||||
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
<script>
|
||||||
{% endif %}
|
tailwind.config = {
|
||||||
{% endblock %}
|
theme: {
|
||||||
</head>
|
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"),
|
// // aqua
|
||||||
"Boosted Links": url_for('boosted'),
|
// 'terminal-50': '#f0f9ff',
|
||||||
"Infinite Scroll": url_for('infinite'),
|
// '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"
|
</script>
|
||||||
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">
|
{% if DEBUG %}
|
||||||
</div>
|
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
||||||
<div id="content" class="flex flex-col items-center min-h-screen min-w-screen text-white border-b">
|
{% endif %}
|
||||||
<nav
|
{% endblock %}
|
||||||
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">
|
</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="/" class="text-3xl gap-4 font-bold">HTMX PATTERNS</a> -->
|
||||||
<!-- <a href="/infinite" class="text-3xl font-bold text-yellow-400">INFINITE</a> -->
|
<!-- <a href="/infinite" class="text-3xl font-bold text-yellow-400">INFINITE</a> -->
|
||||||
{% for link, url in links.items() %}
|
{% for link, url in links.items() %}
|
||||||
<a href="{{ url }}"
|
<a href="{{ url }}"
|
||||||
class="text-xl sm:text-3xl font-bold uppercase {% if not loop.first %}text-yellow-400{% endif %}">{{
|
class="text-xl sm:text-xl font-thin uppercase {% if not loop.first %}text-terminal-600{% endif %}">
|
||||||
link
|
{{ link }}
|
||||||
}}</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ body | safe }}
|
{{ body | safe }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
<section id='toast' class="fixed bottom-4 right-4">
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,60 +2,62 @@
|
||||||
{% block title %}Contact - {{ person_id }} - {{ person.name }}{% endblock %}
|
{% block title %}Contact - {{ person_id }} - {{ person.name }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 id="title"
|
<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
|
HTMX PATTERNS - BOOSTED
|
||||||
</h1>
|
</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 }}
|
Contact - {{ person_id }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class='text-3xl font-bold mt-0 mb-16 max-w-2xl text-center prose-xl'>
|
{% if person is not none %}
|
||||||
{{ person.name }} -
|
<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 }}
|
{{ person.phone_number }}
|
||||||
</p>
|
</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
|
Boosted Links
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class='flex flex-row gap-4'>
|
<div class='flex flex-row gap-4'>
|
||||||
{% if prev_id is not none %}
|
{{ link(prev_id, 'Previous', boosted=True) }}
|
||||||
<a href="{{ url_for('boosted', id=prev_id) }}"
|
{{ link(next_id, 'Next', boosted=True) }}
|
||||||
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>
|
|
||||||
</div>
|
</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
|
Normal Links
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class='flex flex-row gap-4'>
|
<div class='flex flex-row gap-4'>
|
||||||
{% if prev_id is not none %}
|
{{ link(prev_id, 'Previous', boosted=False) }}
|
||||||
<a href="{{ url_for('boosted', id=prev_id) }}"
|
{{ link(next_id, 'Next', boosted=False) }}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 id="title"
|
<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
|
HTMX PATTERNS
|
||||||
</h1>
|
</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
|
A collection of HTMX patterns
|
||||||
</p>
|
</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">
|
<ul id="patterns" class="flex flex-col sm:grid grid-cols-3 gap-4">
|
||||||
{% for link, url in links.items() %}
|
{% for link, url in links.items() %}
|
||||||
<li class='w-full'>
|
<li class='w-full'>
|
||||||
<a href="{{ url }}"
|
<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 }}
|
{{ link }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,22 @@
|
||||||
{% block title %}Contacts List{% endblock %}
|
{% block title %}Contacts List{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 id="title"
|
<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
|
HTMX PATTERNS - INFINITE
|
||||||
</h1>
|
</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
|
Contacts List
|
||||||
</p>
|
</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" %}
|
{% include "infinite/persons_partial.html" %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div id='persons-loading' class='spinner mb-24 animate-bounce'>
|
<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'>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
{% for person in persons %}
|
{% for person in persons %}
|
||||||
|
<li
|
||||||
{% if loop.last %}
|
{% if loop.last %}
|
||||||
<li hx-get="{{ url_for('infinite', page=next_page) }}" hx-trigger="intersect once" hx-target="#persons"
|
hx-get="{{ url_for('infinite', page=next_page) }}"
|
||||||
hx-swap='beforeend' hx-indicator="#persons-loading"
|
hx-trigger="intersect once"
|
||||||
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
hx-target="#persons"
|
||||||
|
hx-swap='beforeend'
|
||||||
{{ person.name }} -
|
hx-indicator="#persons-loading"
|
||||||
{{ 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>
|
|
||||||
{% endif %}
|
{% 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 %}
|
{% 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