create infinite
This commit is contained in:
parent
54b3c4bc9b
commit
7bff037b78
14 changed files with 419 additions and 4 deletions
67
htmx_patterns/app.py
Normal file
67
htmx_patterns/app.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
from fastapi import Depends, FastAPI, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from htmx_patterns.__about__ import __version__
|
||||||
|
from htmx_patterns.config import get_config
|
||||||
|
from htmx_patterns.infinite.router import infinite_router
|
||||||
|
|
||||||
|
|
||||||
|
def set_prefers(
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
hx_request_header = request.headers.get("hx-request")
|
||||||
|
user_agent = request.headers.get("user-agent", "").lower()
|
||||||
|
if hx_request_header:
|
||||||
|
request.state.prefers_html = True
|
||||||
|
request.state.prefers_partial = True
|
||||||
|
request.state.prefers_json = False
|
||||||
|
elif "mozilla" in user_agent or "webkit" in user_agent:
|
||||||
|
request.state.prefers_html = True
|
||||||
|
request.state.prefers_partial = False
|
||||||
|
request.state.prefers_json = False
|
||||||
|
else:
|
||||||
|
request.state.prefers_html = False
|
||||||
|
request.state.prefers_partial = False
|
||||||
|
request.state.prefers_json = True
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="HTMX Patterns",
|
||||||
|
version=__version__,
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None,
|
||||||
|
openapi_url=None,
|
||||||
|
dependencies=[Depends(set_prefers)],
|
||||||
|
)
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
app.include_router(infinite_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def read_main(request: Request):
|
||||||
|
return config.templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico")
|
||||||
|
async def favicon(request: Request):
|
||||||
|
"use a proper static file server like nginx or apache in production"
|
||||||
|
return FileResponse("templates/favicon.ico")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/robots.txt")
|
||||||
|
async def robots(request: Request):
|
||||||
|
"use a proper static file server like nginx or apache in production"
|
||||||
|
return config.templates.TemplateResponse("robots.txt", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/css")
|
||||||
|
async def app_css(request: Request):
|
||||||
|
"use a proper static file server like nginx or apache in production"
|
||||||
|
return FileResponse("templates/app.css")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/htmx")
|
||||||
|
async def htmx(request: Request):
|
||||||
|
"use a proper static file server like nginx or apache in production"
|
||||||
|
return config.templates.TemplateResponse("htmx.js", {"request": request})
|
||||||
44
htmx_patterns/cli/api.py
Normal file
44
htmx_patterns/cli/api.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import typer
|
||||||
|
import uvicorn
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from htmx_patterns.config import get_config
|
||||||
|
|
||||||
|
api_app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.callback()
|
||||||
|
def api():
|
||||||
|
"model cli"
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def config(
|
||||||
|
env: str = typer.Option(
|
||||||
|
"local",
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
config = get_config(env)
|
||||||
|
Console().print(config)
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def run(
|
||||||
|
env: str = typer.Option(
|
||||||
|
"local",
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
alembic_revision: str = typer.Option(
|
||||||
|
"head",
|
||||||
|
help="the alembic revision to use",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
config = get_config(env)
|
||||||
|
Console().print(config.api_server)
|
||||||
|
uvicorn.run(**config.api_server.dict())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
api_app()
|
||||||
7
htmx_patterns/cli/cli.py
Normal file
7
htmx_patterns/cli/cli.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from htmx_patterns.cli.api import api_app
|
||||||
|
|
||||||
|
app = typer.Typer()
|
||||||
|
|
||||||
|
app.add_typer(api_app, name="api")
|
||||||
80
htmx_patterns/config.py
Normal file
80
htmx_patterns/config.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
if hasattr(jinja2, "pass_context"):
|
||||||
|
pass_context = jinja2.pass_context
|
||||||
|
else:
|
||||||
|
pass_context = jinja2.contextfunction
|
||||||
|
|
||||||
|
|
||||||
|
class ApiServer(BaseModel):
|
||||||
|
app: str = "htmx_patterns.app:app"
|
||||||
|
port: int = 5000
|
||||||
|
reload: bool = True
|
||||||
|
log_level: str = "info"
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
workers: int = 1
|
||||||
|
forwarded_allow_ips: str = "*"
|
||||||
|
proxy_headers: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@pass_context
|
||||||
|
def https_url_for(context: dict, name: str, **path_params: Any) -> str:
|
||||||
|
request = context["request"]
|
||||||
|
http_url = request.url_for(name, **path_params)
|
||||||
|
return str(http_url).replace("http", "https", 1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_templates(config: BaseSettings) -> Jinja2Templates:
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
templates.env.filters["quote_plus"] = lambda u: quote_plus(str(u))
|
||||||
|
templates.env.filters["timestamp"] = lambda u: datetime.fromtimestamp(
|
||||||
|
u, tz=timezone.utc
|
||||||
|
).strftime("%B %d, %Y")
|
||||||
|
templates.env.globals["https_url_for"] = https_url_for
|
||||||
|
templates.env.globals["config"] = config
|
||||||
|
console.print(f'Using environment: {os.environ.get("ENV")}')
|
||||||
|
|
||||||
|
if os.environ.get("ENV") in ["dev", "qa", "prod"]:
|
||||||
|
templates.env.globals["url_for"] = https_url_for
|
||||||
|
console.print("Using HTTPS")
|
||||||
|
else:
|
||||||
|
console.print("Using HTTP")
|
||||||
|
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseSettings):
|
||||||
|
env: str
|
||||||
|
the_templates: Optional[Jinja2Templates] = Field(None, exclude=True)
|
||||||
|
api_server: ApiServer = ApiServer()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def templates(self) -> Jinja2Templates:
|
||||||
|
if self.the_templates is None:
|
||||||
|
self.the_templates = get_templates(self)
|
||||||
|
return self.the_templates
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_nested_delimiter="__")
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_config(env: Optional[str] = None):
|
||||||
|
if env is None:
|
||||||
|
env = os.environ.get("ENV", "local")
|
||||||
|
load_dotenv(dotenv_path=f".env.{env}")
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
return config
|
||||||
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()
|
||||||
42
htmx_patterns/infinite/router.py
Normal file
42
htmx_patterns/infinite/router.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.requests import Request
|
||||||
|
|
||||||
|
infinite_router = APIRouter(prefix="/infinite", tags=["Shots Methods"])
|
||||||
|
|
||||||
|
from htmx_patterns.config import get_config
|
||||||
|
from htmx_patterns.infinite.models import PersonFactory
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
|
||||||
|
@infinite_router.get("/persons")
|
||||||
|
async def get_persons(request: Request, page: int = 1, n: int = 10):
|
||||||
|
# simulated last page
|
||||||
|
if page == 5:
|
||||||
|
return config.templates.TemplateResponse(
|
||||||
|
"infinite/persons_partial.html", {"request": request, "persons": []}
|
||||||
|
)
|
||||||
|
|
||||||
|
persons = [PersonFactory.build() for _ in range(n)]
|
||||||
|
|
||||||
|
if request.state.prefers_partial:
|
||||||
|
time.sleep(1)
|
||||||
|
return config.templates.TemplateResponse(
|
||||||
|
"infinite/persons_partial.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"persons": persons,
|
||||||
|
"next_page": page + 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return config.templates.TemplateResponse(
|
||||||
|
"infinite/persons.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"persons": persons,
|
||||||
|
"next_page": page + 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -24,12 +24,27 @@ classifiers = [
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
]
|
]
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"fastapi",
|
||||||
|
"sqlmodel",
|
||||||
|
"pydantic",
|
||||||
|
"pydantic_settings",
|
||||||
|
'faker',
|
||||||
|
"polyfactory",
|
||||||
|
"python-dotenv",
|
||||||
|
"python-multipart",
|
||||||
|
"jinja2",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"typer",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
htmx-patterns = "htmx_patterns.cli.cli:app"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Documentation = "https://github.com/unknown/htmx-patterns#readme"
|
Documentation = "https://github.com/waylonwalker/htmx-patterns#readme"
|
||||||
Issues = "https://github.com/unknown/htmx-patterns/issues"
|
Issues = "https://github.com/waylonwalker/htmx-patterns/issues"
|
||||||
Source = "https://github.com/unknown/htmx-patterns"
|
Source = "https://github.com/waylonwalker/htmx-patterns"
|
||||||
|
|
||||||
[tool.hatch.version]
|
[tool.hatch.version]
|
||||||
path = "htmx_patterns/__about__.py"
|
path = "htmx_patterns/__about__.py"
|
||||||
|
|
|
||||||
54
templates/app.css
Normal file
54
templates/app.css
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
|
||||||
|
.spinner.htmx-request {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: #450a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-track {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: #450a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: #f87171;
|
||||||
|
background-color: #dc2626
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgba(239,68,68,var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: #f87171;
|
||||||
|
background-color: #dc2626
|
||||||
|
}
|
||||||
|
|
||||||
33
templates/base.html
Normal file
33
templates/base.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
{% block head %}
|
||||||
|
<title>
|
||||||
|
{% block title %}HTMX Patterns{% endblock %}
|
||||||
|
</title>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="{{ url_for('favicon') }}" />
|
||||||
|
<link href="{{ url_for('app_css') }}" rel="stylesheet" />
|
||||||
|
<script src="{{ url_for('htmx') }}"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<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);
|
||||||
|
pointer-events: none"></div>
|
||||||
|
<div id="content" class="flex flex-col items-center min-h-screen min-w-screen text-white">
|
||||||
|
{% if DEBUG %}
|
||||||
|
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
||||||
|
{% endif %}
|
||||||
|
{% block content %}
|
||||||
|
{{ body | safe }}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
BIN
templates/favicon.ico
Executable file
BIN
templates/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
1
templates/htmx.js
Normal file
1
templates/htmx.js
Normal file
File diff suppressed because one or more lines are too long
12
templates/index.html
Normal file
12
templates/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 id="title"
|
||||||
|
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
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class='mt-0 mb-16 max-w-xl text-center prose-xl'>
|
||||||
|
A collection of HTMX patterns
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
20
templates/infinite/persons.html
Normal file
20
templates/infinite/persons.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1 id="title"
|
||||||
|
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
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class='text-3xl font-bold mt-0 mb-16 max-w-xl text-center prose-xl'>
|
||||||
|
Contacts List
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul id="persons" class="flex flex-col gap-4 mb-16">
|
||||||
|
{% include "infinite/persons_partial.html" %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div id='persons-loading' class='spinner mb-24 animate-bounce'>
|
||||||
|
loading more contacts
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
16
templates/infinite/persons_partial.html
Normal file
16
templates/infinite/persons_partial.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% for person in persons %}
|
||||||
|
{% if loop.last %}
|
||||||
|
<li hx-get="/infinite/persons?page={{ next_page }}" hx-trigger="intersect once" hx-target="#persons" hx-swap='beforeend'
|
||||||
|
hx-indicator="#persons-loading"
|
||||||
|
class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||||
|
|
||||||
|
{{ person.name }} -
|
||||||
|
{{ person.phone_number }}
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="cursor-pointer bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
|
||||||
|
{{ person.name }} -
|
||||||
|
{{ person.phone_number }}
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue