Compare commits
No commits in common. "main" and "0.0.4" have entirely different histories.
32 changed files with 109 additions and 2917 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -1,21 +1,5 @@
|
||||||
# HTMX-PATTERNS CHANGELOG
|
# HTMX-PATTERNS CHANGELOG
|
||||||
|
|
||||||
## 0.1.3
|
|
||||||
|
|
||||||
* fix mobile style
|
|
||||||
|
|
||||||
## 0.1.2
|
|
||||||
|
|
||||||
* fix seo image size
|
|
||||||
|
|
||||||
## 0.1.1
|
|
||||||
|
|
||||||
* feat seo
|
|
||||||
|
|
||||||
## 0.1.0
|
|
||||||
|
|
||||||
* feat boosted links
|
|
||||||
|
|
||||||
## 0.0.4
|
## 0.0.4
|
||||||
|
|
||||||
* fix simulated load need asyncio.sleep
|
* fix simulated load need asyncio.sleep
|
||||||
|
|
|
||||||
118
alembic.ini
118
alembic.ini
|
|
@ -1,118 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
|
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
|
||||||
#
|
#
|
||||||
# SPDX-License-Identifier: MIT
|
# SPDX-License-Identifier: MIT
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.0.4"
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,13 @@ from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from htmx_patterns.__about__ import __version__
|
from htmx_patterns.__about__ import __version__
|
||||||
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
|
|
||||||
from htmx_patterns.zpages.router import zpages_router
|
|
||||||
|
|
||||||
|
|
||||||
def set_prefers(
|
def set_prefers(
|
||||||
request: Request = None,
|
request: Request,
|
||||||
):
|
):
|
||||||
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:
|
||||||
|
|
@ -42,14 +36,10 @@ app = FastAPI(
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
app.include_router(infinite_router)
|
app.include_router(infinite_router)
|
||||||
app.include_router(boosted_router)
|
|
||||||
app.include_router(toast_router)
|
|
||||||
app.include_router(websocket_router)
|
|
||||||
app.include_router(zpages_router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index(request: Request):
|
async def read_main(request: Request):
|
||||||
return config.templates.TemplateResponse("index.html", {"request": request})
|
return config.templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,19 +61,7 @@ async def app_css(request: Request):
|
||||||
return FileResponse("templates/app.css")
|
return FileResponse("templates/app.css")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/htmx.js")
|
@app.get("/htmx")
|
||||||
async def htmx_js(request: Request):
|
async def htmx(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,55 +0,0 @@
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header
|
|
||||||
from fastapi.requests import Request
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
|
|
||||||
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 = 1,
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
user_agent: Annotated[str | None, Header()] = None,
|
|
||||||
):
|
|
||||||
# person = PersonFactory.build()
|
|
||||||
person = Person.get_by_id(session, id)
|
|
||||||
|
|
||||||
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:
|
|
||||||
prev_id = None
|
|
||||||
next_id = id + 1
|
|
||||||
|
|
||||||
return config.templates.TemplateResponse(
|
|
||||||
"boosted/person.html",
|
|
||||||
{
|
|
||||||
"request": request,
|
|
||||||
"person": person,
|
|
||||||
"person_id": id,
|
|
||||||
"prev_id": prev_id,
|
|
||||||
"next_id": next_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -9,6 +7,7 @@ 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"
|
||||||
|
|
@ -38,10 +37,6 @@ 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,26 +1,17 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
from functools import lru_cache
|
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
import urllib.parse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import lru_cache, partial
|
||||||
|
from typing import Any, Optional
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
import jinja2
|
import jinja2
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
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
|
|
||||||
from htmx_patterns.__about__ import __version__
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from sqlalchemy.engine import Engine
|
|
||||||
|
|
||||||
__all__ = ["models"]
|
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
|
@ -41,6 +32,12 @@ class ApiServer(BaseModel):
|
||||||
proxy_headers: bool = True
|
proxy_headers: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# @pass_context
|
||||||
|
# def https_url_for(context: dict, name: str, **params: Any) -> str:
|
||||||
|
# http_url = url_for_query(context, name, **params)
|
||||||
|
# return str(http_url).replace("http", "https", 1)
|
||||||
|
|
||||||
|
|
||||||
@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"]
|
||||||
|
|
@ -74,6 +71,7 @@ def url_for_query(context: dict, name: str, **params: dict) -> str:
|
||||||
|
|
||||||
if os.environ.get("ENV") in ["dev", "qa", "prod"]:
|
if os.environ.get("ENV") in ["dev", "qa", "prod"]:
|
||||||
updated_url = updated_url.replace("http", "https", 1)
|
updated_url = updated_url.replace("http", "https", 1)
|
||||||
|
|
||||||
return updated_url
|
return updated_url
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -85,7 +83,7 @@ def get_templates(config: BaseSettings) -> Jinja2Templates:
|
||||||
).strftime("%B %d, %Y")
|
).strftime("%B %d, %Y")
|
||||||
templates.env.globals["url_for"] = url_for_query
|
templates.env.globals["url_for"] = url_for_query
|
||||||
templates.env.globals["config"] = config
|
templates.env.globals["config"] = config
|
||||||
console.print(f"Using environment: {os.environ.get('ENV')}")
|
console.print(f'Using environment: {os.environ.get("ENV")}')
|
||||||
|
|
||||||
# if os.environ.get("ENV") in ["dev", "qa", "prod"]:
|
# if os.environ.get("ENV") in ["dev", "qa", "prod"]:
|
||||||
# templates.env.globals["url_for"] = https_url_for
|
# templates.env.globals["url_for"] = https_url_for
|
||||||
|
|
@ -97,11 +95,9 @@ def get_templates(config: BaseSettings) -> Jinja2Templates:
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
env: str = "local"
|
env: str
|
||||||
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"
|
|
||||||
app_version: str = __version__
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def templates(self) -> Jinja2Templates:
|
def templates(self) -> Jinja2Templates:
|
||||||
|
|
@ -112,36 +108,6 @@ 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:
|
||||||
|
|
@ -150,12 +116,3 @@ 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
|
|
||||||
|
|
|
||||||
24
htmx_patterns/infinite/models.py
Normal file
24
htmx_patterns/infinite/models.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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,12 +1,13 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.requests import Request
|
from fastapi.requests import Request
|
||||||
|
|
||||||
from htmx_patterns.config import get_config
|
infinite_router = APIRouter(prefix="/infinite", tags=["Shots Methods"])
|
||||||
from htmx_patterns.models import PersonFactory
|
|
||||||
|
|
||||||
infinite_router = APIRouter(prefix="/infinite", tags=["Infinite"])
|
from htmx_patterns.config import get_config
|
||||||
|
from htmx_patterns.infinite.models import PersonFactory
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ async def infinite(request: Request, page: int = 1, n: int = 10):
|
||||||
persons = [PersonFactory.build() for _ in range(n)]
|
persons = [PersonFactory.build() for _ in range(n)]
|
||||||
|
|
||||||
if request.state.prefers_partial:
|
if request.state.prefers_partial:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(0.5)
|
||||||
return config.templates.TemplateResponse(
|
return config.templates.TemplateResponse(
|
||||||
"infinite/persons_partial.html",
|
"infinite/persons_partial.html",
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
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("")
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
from datetime import datetime, timezone
|
|
||||||
from kubernetes import client, config as kubernetes_config
|
|
||||||
from kubernetes.config.config_exception import ConfigException
|
|
||||||
import os
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
|
|
||||||
from htmx_patterns.config import get_config
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
zpages_router = APIRouter(tags=["zpages"])
|
|
||||||
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
|
|
||||||
def format_uptime(seconds: int) -> str:
|
|
||||||
minutes, sec = divmod(seconds, 60)
|
|
||||||
hours, min = divmod(minutes, 60)
|
|
||||||
days, hr = divmod(hours, 24)
|
|
||||||
|
|
||||||
parts = []
|
|
||||||
if days:
|
|
||||||
parts.append(f"{days}d")
|
|
||||||
if hr:
|
|
||||||
parts.append(f"{hr}h")
|
|
||||||
if min:
|
|
||||||
parts.append(f"{min}m")
|
|
||||||
parts.append(f"{sec}s")
|
|
||||||
|
|
||||||
return " ".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
class PodInfo(BaseModel):
|
|
||||||
pod_name: str
|
|
||||||
namespace: str
|
|
||||||
node_name: str
|
|
||||||
container_image: str
|
|
||||||
start_time: Union[str, datetime]
|
|
||||||
pod_uptime: Union[str, int]
|
|
||||||
|
|
||||||
|
|
||||||
class Ready(BaseModel):
|
|
||||||
ready: bool
|
|
||||||
timestamp: str
|
|
||||||
app_version: str
|
|
||||||
|
|
||||||
pod_info: PodInfo
|
|
||||||
|
|
||||||
|
|
||||||
def get_pod_info():
|
|
||||||
pod_name = os.getenv("KUBERNETES_POD_NAME")
|
|
||||||
namespace = os.getenv("KUBERNETES_POD_NAMESPACE")
|
|
||||||
|
|
||||||
try:
|
|
||||||
kubernetes_config.load_incluster_config()
|
|
||||||
except ConfigException:
|
|
||||||
return PodInfo(
|
|
||||||
pod_name="unknown",
|
|
||||||
namespace="unknown",
|
|
||||||
node_name="unknown",
|
|
||||||
pod_ip="unknown",
|
|
||||||
container_image="unknown",
|
|
||||||
start_time="unknown",
|
|
||||||
pod_uptime="unknown",
|
|
||||||
)
|
|
||||||
v1 = client.CoreV1Api()
|
|
||||||
pod = v1.read_namespaced_pod(name=pod_name, namespace=namespace)
|
|
||||||
start_time = pod.status.start_time
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
uptime_seconds = int((now - start_time).total_seconds())
|
|
||||||
|
|
||||||
return PodInfo(
|
|
||||||
pod_name=pod.metadata.name,
|
|
||||||
namespace=pod.metadata.namespace,
|
|
||||||
node_name=pod.spec.node_name,
|
|
||||||
container_image=pod.spec.containers[0].image,
|
|
||||||
start_time=pod.status.start_time,
|
|
||||||
pod_uptime=format_uptime(uptime_seconds),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@zpages_router.get("/readyz")
|
|
||||||
async def root() -> Ready:
|
|
||||||
pod_info = get_pod_info()
|
|
||||||
ready = Ready(
|
|
||||||
ready=True,
|
|
||||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
||||||
app_version=config.app_version,
|
|
||||||
pod_info=pod_info,
|
|
||||||
)
|
|
||||||
return ready
|
|
||||||
17
justfile
17
justfile
|
|
@ -4,24 +4,7 @@ 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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""${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"}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
"""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 ###
|
|
||||||
|
|
@ -25,7 +25,6 @@ classifiers = [
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kubernetes",
|
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"sqlmodel",
|
"sqlmodel",
|
||||||
"pydantic",
|
"pydantic",
|
||||||
|
|
@ -37,7 +36,6 @@ dependencies = [
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"uvicorn[standard]",
|
"uvicorn[standard]",
|
||||||
"typer",
|
"typer",
|
||||||
"alembic",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|
|
||||||
|
|
@ -8,46 +8,47 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
height: .5rem;
|
height: 1rem;
|
||||||
width: .5rem;
|
width: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-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: #002600;
|
background-color: #450a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
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: #002600;
|
background-color: #450a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-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: #1aff1a;
|
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
||||||
background-color: #00b300;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: #1aff1a;
|
background-color: #f87171;
|
||||||
|
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: #00b300;
|
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
body::-webkit-scrollbar-thumb:hover {
|
body::-webkit-scrollbar-thumb:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: #1aff1a;
|
background-color: #f87171;
|
||||||
|
background-color: #dc2626
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,33 @@
|
||||||
<!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="twitter:title" name="twitter:title" content="HTMX Patterns" />
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
<meta name="og:image" name="og:image"
|
|
||||||
content="https://shots.wayl.one/shot/?url={{ request.url }}&height=600&width=1200&scaled_width=1200&scaled_height=600&selectors=" />
|
|
||||||
<meta name="twitter:image" name="twitter:image"
|
|
||||||
content="https://shots.wayl.one/shot/?url={{ request.url }}&height=640&width=1280&scaled_width=1280&scaled_height=640&selectors=" />
|
|
||||||
<meta name="og:image:height" content="640" />
|
|
||||||
<meta name="og:image:width" content="1280" />
|
|
||||||
<meta name="og:url" name="og:url" content="{{ request.url }}" />
|
|
||||||
<meta name="description" name="description" content="HTMX Patterns from the hypermedia.systems book" />
|
|
||||||
<meta name="og:description" name="Check if my kids can play outside"
|
|
||||||
content="HTMX Patterns from the hypermedia.systems book" />
|
|
||||||
<meta name="twitter:description" name="twitter:description"
|
|
||||||
content="HTMX Patterns from the hypermedia.systems book" />
|
|
||||||
|
|
||||||
<link rel="icon" type="image/x-icon" href="{{ url_for('favicon') }}" />
|
<link 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_js') }}"></script>
|
<script src="{{ url_for('htmx') }}"></script>
|
||||||
<script src="{{ url_for('ws_js') }}"></script>
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
<script src="{{ url_for('tailwind_js') }}"></script>
|
{% endblock %}
|
||||||
<script>
|
</head>
|
||||||
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',
|
|
||||||
|
|
||||||
//
|
<body
|
||||||
// // aqua
|
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">
|
||||||
// 'terminal-50': '#f0f9ff',
|
<div id="grit"
|
||||||
// 'terminal-100': '#e0f2fe',
|
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);
|
||||||
// 'terminal-200': '#bae6fd',
|
pointer-events: none"></div>
|
||||||
// 'terminal-300': '#7dd3fc',
|
<div id="content" class="flex flex-col items-center min-h-screen min-w-screen text-white">
|
||||||
// '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 %}
|
{% if DEBUG %}
|
||||||
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
|
||||||
</head>
|
|
||||||
|
|
||||||
{% set links = {
|
|
||||||
"HTMX-PATTERNS": url_for("index"),
|
|
||||||
"Boosted Links": url_for('boosted'),
|
|
||||||
"Infinite Scroll": url_for('infinite'),
|
|
||||||
"Toast": url_for('get_toast'),
|
|
||||||
"WebSocket": url_for('websocket_index'),
|
|
||||||
} %}
|
|
||||||
|
|
||||||
<body
|
|
||||||
class="justify-center items-center min-h-screen bg-gray-900 bg-no-repeat bg-cover bg-gradient-to-b from-terminal-950/20 min-w-screen text-shadow-xl text-shadow-zinc-950 w-screen h-screen overflow-x-hidden">
|
|
||||||
|
|
||||||
<div id="grit"
|
|
||||||
class="absolute top-0 right-0 bottom-0 left-0 justify-center items-center min-w-full bg-repeat bg-cover"
|
|
||||||
style="background-image: url(https://fokais.com/grit.svg), url(https://fokais.com/grit-light.svg); animation: pulse 10s cubic-bezier(0.4, 0, 0.6, 1) infinite; pointer-events: none">
|
|
||||||
</div>
|
|
||||||
<div id="content" class="flex flex-col items-center min-h-screen min-w-screen text-white border-b">
|
|
||||||
<nav
|
|
||||||
class="flex flex-col sm:flex-row flex-wrap w-screen gap-x-8 gap-y-2 justify-center items-center w-full p-4 bg-black border-b-2 border-terminal-400 mb-8 font-mono">
|
|
||||||
<!-- <a href="/" class="text-3xl gap-4 font-bold">HTMX PATTERNS</a> -->
|
|
||||||
<!-- <a href="/infinite" class="text-3xl font-bold text-yellow-400">INFINITE</a> -->
|
|
||||||
{% for link, url in links.items() %}
|
|
||||||
<a href="{{ url }}"
|
|
||||||
class="text-xl sm:text-xl font-thin uppercase {% if not loop.first %}text-terminal-600{% endif %}">
|
|
||||||
{{ link }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</nav>
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ body | safe }}
|
{{ body | safe }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<section id='toast' class="fixed bottom-4 right-4">
|
</body>
|
||||||
</section>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% 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-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 px-2 mt-0 mb-16 max-w-xl text-center prose-xl text-terminal-500 font-extralight'>
|
|
||||||
Contact - {{ person_id }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
|
|
||||||
{% 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'>
|
|
||||||
{{ link(prev_id, 'Previous', boosted=True) }}
|
|
||||||
{{ link(next_id, 'Next', boosted=True) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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'>
|
|
||||||
{{ link(prev_id, 'Previous', boosted=False) }}
|
|
||||||
{{ link(next_id, 'Next', boosted=False) }}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,53 +1,21 @@
|
||||||
{% 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-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">
|
class="inline-block pb-0 mx-auto mb-0 text-8xl font-black leading-tight leading-loose 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">
|
||||||
HTMX PATTERNS
|
HTMX PATTERNS
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class='text-3xl px-2 mt-0 mb-16 max-w-xl text-center prose-xl text-terminal-500 font-extralight'>
|
<p class='mt-0 mb-16 max-w-xl text-center prose-xl'>
|
||||||
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'>
|
<ul id="patterns" class="flex flex-col gap-4">
|
||||||
These are patterns that I have written based on content from the <a
|
<li>
|
||||||
href="https://hypermedia.systems/htmx-in-action/">hypermedia.systems</a>
|
<a href="{{ url_for('infinite') }}"
|
||||||
book. There is lots of code duplication as each pattern is meant to be standalone.
|
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||||
</p>
|
Infinite Scroll
|
||||||
|
|
||||||
<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 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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
|
||||||
<!-- <a href="{{ url_for('infinite') }}" -->
|
|
||||||
<!-- class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded"> -->
|
|
||||||
<!-- Infinite Scroll -->
|
|
||||||
<!-- </a> -->
|
|
||||||
|
|
||||||
<!-- Ajaxify - https://hypermedia.systems/htmx-in-action/#_ajax_ifying_our_application -->
|
|
||||||
<!-- Using HTTP verbs -->
|
|
||||||
<!-- Validation -->
|
|
||||||
<!-- Pagination -->
|
|
||||||
<!-- Modals -->
|
|
||||||
<!-- Active Search -->
|
|
||||||
<!-- Lazy Loading -->
|
|
||||||
<!-- Inline Delete -->
|
|
||||||
<!-- Bulk Delete -->
|
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,20 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% 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-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">
|
class="inline-block pb-0 mx-auto mt-8 mb-0 text-6xl font-black leading-tight leading-loose 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">
|
||||||
HTMX PATTERNS - INFINITE
|
HTMX PATTERNS - INFINITE
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class='text-3xl px-2 mt-0 mb-16 max-w-xl text-center prose-xl text-terminal-500 font-extralight'>
|
<p class='text-3xl font-bold mt-0 mb-16 max-w-xl text-center prose-xl'>
|
||||||
Contacts List
|
Contacts List
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul id="persons" class="flex flex-col gap-4 mb-16 px-4 sm:px-0 container max-w-xl">
|
<ul id="persons" class="flex flex-col gap-4 mb-16">
|
||||||
{% 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 text-terminal-700'>loading more contacts</p>
|
loading more contacts
|
||||||
<p class='text-xl prose-xl'>
|
|
||||||
<em class='text-terminal-500'>with simulated slow down</em>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
{% for person in persons %}
|
{% for person in persons %}
|
||||||
<li
|
|
||||||
{% if loop.last %}
|
{% if loop.last %}
|
||||||
hx-get="{{ url_for('infinite', page=next_page) }}"
|
<li hx-get="{{ url_for('infinite', page=next_page) }}" hx-trigger="intersect once" hx-target="#persons"
|
||||||
hx-trigger="intersect once"
|
hx-swap='beforeend' hx-indicator="#persons-loading"
|
||||||
hx-target="#persons"
|
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||||
hx-swap='beforeend'
|
|
||||||
hx-indicator="#persons-loading"
|
{{ person.name }} -
|
||||||
{% 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 }}
|
{{ person.phone_number }}
|
||||||
</li>
|
</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 %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,9 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{% 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
476
templates/ws.js
|
|
@ -1,476 +0,0 @@
|
||||||
/*
|
|
||||||
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