init
This commit is contained in:
commit
0500266b92
21 changed files with 1766 additions and 0 deletions
46
justfile
Normal file
46
justfile
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
default: build tag push convert set-image deploy
|
||||||
|
fresh: create-ns cred convert deploy viz
|
||||||
|
update: convert patch
|
||||||
|
|
||||||
|
regcred:
|
||||||
|
kubectl get secret -n default regcred --output=yaml -o yaml | sed 's/namespace: default/namespace: play-outside/' | kubectl apply -n play-outside -f - && echo deployed secret || echo secret exists
|
||||||
|
build:
|
||||||
|
podman build -t docker.io/waylonwalker/play-outside -f Dockerfile .
|
||||||
|
tag:
|
||||||
|
podman tag docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:$(hatch version)
|
||||||
|
push:
|
||||||
|
podman push docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:$(hatch version)
|
||||||
|
podman push docker.io/waylonwalker/play-outside docker.io/waylonwalker/play-outside:latest
|
||||||
|
set-image:
|
||||||
|
kubectl set image deployment/play-outside-wayl-one --namespace play-outside play-outside-wayl-one=docker.io/waylonwalker/play-outside:$(hatch version)
|
||||||
|
|
||||||
|
create-ns:
|
||||||
|
kubectl create ns play-outside && echo created ns play-outside || echo namespace play-outside already exists
|
||||||
|
cred:
|
||||||
|
kubectl get secret regcred --output=yaml -o yaml | sed 's/namespace: default/namespace: play-outside/' | kubectl apply -n play-outside -f - && echo deployed secret || echo secret exists
|
||||||
|
convert:
|
||||||
|
kompose convert -o deployment.yaml -n play-outside --replicas 3
|
||||||
|
deploy:
|
||||||
|
kubectl apply -f deployment.yaml
|
||||||
|
delete:
|
||||||
|
kubectl delete all --all -n play-outside --timeout=0s
|
||||||
|
viz:
|
||||||
|
k8sviz -n play-outside --kubeconfig $KUBECONFIG -t png -o play-outside-k8s.png
|
||||||
|
restart:
|
||||||
|
kubectl rollout restart -n play-outside deployment/play-outside-wayl-one
|
||||||
|
patch:
|
||||||
|
kubectl patch -f deployment.yaml
|
||||||
|
|
||||||
|
describe:
|
||||||
|
kubectl get deployment -n play-outside
|
||||||
|
kubectl get rs -n play-outside
|
||||||
|
kubectl get pod -n play-outside
|
||||||
|
kubectl get svc -n play-outside
|
||||||
|
kubectl get ing -n play-outside
|
||||||
|
|
||||||
|
|
||||||
|
describe-pod:
|
||||||
|
kubectl describe pod -n play-outside
|
||||||
|
|
||||||
|
logs:
|
||||||
|
kubectl logs --all-containers -l io.kompose.service=play-outside-wayl-one -n play-outside -f
|
||||||
4
play_outside/__about__.py
Normal file
4
play_outside/__about__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
__version__ = "0.0.28"
|
||||||
3
play_outside/__init__.py
Normal file
3
play_outside/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
270
play_outside/api.py
Normal file
270
play_outside/api.py
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
from fastapi import FastAPI
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi import Header
|
||||||
|
import httpx
|
||||||
|
from play_outside.config import get_config
|
||||||
|
from play_outside.decorators import cache, no_cache
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
import arel
|
||||||
|
|
||||||
|
|
||||||
|
def set_prefers(
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
hx_request_header = request.headers.get("hx-request")
|
||||||
|
user_agent = request.headers.get("user-agent", "").lower()
|
||||||
|
if "mozilla" in user_agent or "webkit" in user_agent or hx_request_header:
|
||||||
|
request.state.prefers_html = True
|
||||||
|
request.state.prefers_json = False
|
||||||
|
else:
|
||||||
|
request.state.prefers_html = False
|
||||||
|
request.state.prefers_json = True
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
dependencies=[Depends(set_prefers)],
|
||||||
|
)
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
|
||||||
|
if config.env == "local":
|
||||||
|
hot_reload = arel.HotReload(
|
||||||
|
paths=[arel.Path("fokais"), arel.Path("templates"), arel.Path("static")]
|
||||||
|
)
|
||||||
|
app.add_websocket_route("/hot-reload", route=hot_reload, name="hot-reload")
|
||||||
|
app.add_event_handler("startup", hot_reload.startup)
|
||||||
|
app.add_event_handler("shutdown", hot_reload.shutdown)
|
||||||
|
config.templates.env.globals["DEBUG"] = True
|
||||||
|
config.templates.env.globals["hot_reload"] = hot_reload
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lat_long(ip_address):
|
||||||
|
if ip_address is None:
|
||||||
|
ip_address = "140.177.140.75"
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(f"https://ipwho.is/{ip_address}")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather(lat_long=None):
|
||||||
|
if not lat_long:
|
||||||
|
lat_long = {"latitude": 40.7128, "longitude": -74.0060}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"https://api.openweathermap.org/data/2.5/weather?units=imperial&lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_forecast(lat_long=None):
|
||||||
|
if not lat_long:
|
||||||
|
lat_long = {"latitude": 40.7128, "longitude": -74.0060}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"https://api.openweathermap.org/data/2.5/forecast?units=imperial&lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
|
||||||
|
)
|
||||||
|
return response.json()["list"]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_air_quality(lat_long):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"https://api.openweathermap.org/data/2.5/air_pollution?lat={lat_long['latitude']}&lon={lat_long['longitude']}&appid={config.open_weather_api_key}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/htmx.org@1.9.8", include_in_schema=False)
|
||||||
|
@cache()
|
||||||
|
async def get_htmx(request: Request):
|
||||||
|
return FileResponse("static/htmx.org@1.9.8")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
@cache()
|
||||||
|
async def get_favicon(request: Request):
|
||||||
|
return FileResponse("static/favicon.ico")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/app3.css", include_in_schema=False)
|
||||||
|
@cache()
|
||||||
|
async def get_app_css(request: Request):
|
||||||
|
"""
|
||||||
|
return app.css for development updates
|
||||||
|
"""
|
||||||
|
return FileResponse("static/app.css")
|
||||||
|
|
||||||
|
|
||||||
|
def make_text_response(request, data, color):
|
||||||
|
template_response = config.templates.TemplateResponse(
|
||||||
|
"index.txt", {"request": request, "color": color, **data}
|
||||||
|
)
|
||||||
|
|
||||||
|
return PlainTextResponse(template_response.body, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
@no_cache
|
||||||
|
async def get_home(request: Request, color: bool = False, n_forecast: int = None):
|
||||||
|
data = await get_data(request, n_forecast)
|
||||||
|
print(n_forecast)
|
||||||
|
print(len(data["forecast"]))
|
||||||
|
if request.state.prefers_html:
|
||||||
|
return config.templates.TemplateResponse(
|
||||||
|
"index.html", {"request": request, **data}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return make_text_response(request, data, color=color)
|
||||||
|
|
||||||
|
|
||||||
|
def hours_till_sunset(weather):
|
||||||
|
if "sys" not in weather:
|
||||||
|
return ""
|
||||||
|
if "sunset" not in weather["sys"]:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
sunset = (
|
||||||
|
datetime.fromtimestamp(weather["sys"]["sunset"])
|
||||||
|
- datetime.fromtimestamp(weather["dt"])
|
||||||
|
).total_seconds() / 60
|
||||||
|
|
||||||
|
if sunset < 0:
|
||||||
|
return "it is after sunset"
|
||||||
|
elif sunset > 120:
|
||||||
|
return ""
|
||||||
|
elif sunset > 60:
|
||||||
|
return f"Time till sunset is {round(int(sunset)/60)} hours. "
|
||||||
|
else:
|
||||||
|
return f"Time till sunset is {round(int(sunset))} minutes. "
|
||||||
|
|
||||||
|
|
||||||
|
ANSI_RED = r"\x1b[1;31m"
|
||||||
|
ANSI_GREEN = r"\x1b[1;32m"
|
||||||
|
ANSI_YELLOW = r"\x1b[1;33m"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayCondition:
|
||||||
|
message: str = ""
|
||||||
|
color: str = "bg-green-500"
|
||||||
|
ansi_color: str = ANSI_GREEN
|
||||||
|
|
||||||
|
|
||||||
|
def determine_play_condition(weather, aqi=0):
|
||||||
|
play_condition = PlayCondition()
|
||||||
|
|
||||||
|
feels_like_temperature = weather["main"]["feels_like"]
|
||||||
|
# visibility = weather["visibility"]
|
||||||
|
|
||||||
|
play_condition.message += hours_till_sunset(weather)
|
||||||
|
|
||||||
|
if "after" in play_condition.message:
|
||||||
|
play_condition.color = "bg-red-500"
|
||||||
|
play_condition.ansi_color = ANSI_RED
|
||||||
|
|
||||||
|
# if visibility < 1000:
|
||||||
|
# play_condition.message += "It's too foggy. Find better activities inside!"
|
||||||
|
# play_condition.color = "bg-red-500"
|
||||||
|
# play_condition.ansi_color = ANSI_RED
|
||||||
|
|
||||||
|
if aqi > 150:
|
||||||
|
play_condition.message += "It's too polluted. Find better activities inside!"
|
||||||
|
play_condition.color = "bg-red-500"
|
||||||
|
play_condition.ansi_color = ANSI_RED
|
||||||
|
elif aqi > 100:
|
||||||
|
play_condition.message += "limit your time outside due to the poor air quality"
|
||||||
|
play_condition.color = "bg-yellow-500"
|
||||||
|
play_condition.ansi_color = ANSI_YELLOW
|
||||||
|
elif aqi > 50:
|
||||||
|
play_condition.message += "Check the air quality outside at your discression."
|
||||||
|
play_condition.color = "bg-yellow-500"
|
||||||
|
play_condition.ansi_color = ANSI_YELLOW
|
||||||
|
else:
|
||||||
|
play_condition.message += ""
|
||||||
|
|
||||||
|
if feels_like_temperature < 10:
|
||||||
|
play_condition.message += "It's too cold. Stay indoors and keep warm!"
|
||||||
|
play_condition.color = "bg-red-500"
|
||||||
|
play_condition.ansi_color = ANSI_RED
|
||||||
|
elif feels_like_temperature < 30:
|
||||||
|
play_condition.message += (
|
||||||
|
"You can play outside, but limit your time in this cold!"
|
||||||
|
)
|
||||||
|
play_condition.color = "bg-yellow-500"
|
||||||
|
play_condition.ansi_color = ANSI_YELLOW
|
||||||
|
elif feels_like_temperature < 40:
|
||||||
|
play_condition.message += (
|
||||||
|
"Coats and winter gear required for outdoor play. Stay cozy!"
|
||||||
|
)
|
||||||
|
elif feels_like_temperature < 50:
|
||||||
|
play_condition.message += "Grab a warm jacket and enjoy your time outside!"
|
||||||
|
elif feels_like_temperature < 60:
|
||||||
|
play_condition.message += "Grab some long sleeves and enjoy your time outside!"
|
||||||
|
elif feels_like_temperature > 90:
|
||||||
|
play_condition.message += (
|
||||||
|
"You can play outside, but limit your time in this heat!"
|
||||||
|
)
|
||||||
|
play_condition.color = "bg-yellow-500"
|
||||||
|
play_condition.ansi_color = ANSI_YELLOW
|
||||||
|
elif feels_like_temperature > 109:
|
||||||
|
play_condition.message += (
|
||||||
|
"It's too hot for outdoor play. Find cooler activities indoors!"
|
||||||
|
)
|
||||||
|
play_condition.color = "bg-red-500"
|
||||||
|
play_condition.ansi_color = ANSI_RED
|
||||||
|
else:
|
||||||
|
play_condition.message += "Enjoy your time outside!"
|
||||||
|
return play_condition
|
||||||
|
|
||||||
|
|
||||||
|
async def get_data(request: Request, n_forecast: int = None):
|
||||||
|
user_ip = request.headers.get("CF-Connecting-IP")
|
||||||
|
lat_long = await get_lat_long(user_ip)
|
||||||
|
weather = await get_weather(lat_long)
|
||||||
|
forecast = await get_forecast(lat_long)
|
||||||
|
if n_forecast is not None:
|
||||||
|
forecast = forecast[: n_forecast + 2]
|
||||||
|
air_quality = await get_air_quality(lat_long)
|
||||||
|
weather["play_condition"] = determine_play_condition(
|
||||||
|
weather,
|
||||||
|
air_quality["list"][0]["main"]["aqi"],
|
||||||
|
)
|
||||||
|
|
||||||
|
forecast = [
|
||||||
|
{"play_condition": determine_play_condition(x), **x}
|
||||||
|
for x in forecast
|
||||||
|
if datetime.fromtimestamp(x["dt"]).hour >= 6
|
||||||
|
and datetime.fromtimestamp(x["dt"]).hour <= 21
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"request.client": request.client,
|
||||||
|
"request.client.host": request.client.host,
|
||||||
|
"user_ip": user_ip,
|
||||||
|
"lat_long": lat_long,
|
||||||
|
"weather": weather,
|
||||||
|
"forecast": forecast,
|
||||||
|
"air_quality": air_quality,
|
||||||
|
"sunset": weather["sys"]["sunset"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/metadata")
|
||||||
|
async def root(
|
||||||
|
request: Request,
|
||||||
|
):
|
||||||
|
return await get_data(request)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8100)
|
||||||
133
play_outside/cli/api.py
Normal file
133
play_outside/cli/api.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
import uvicorn
|
||||||
|
from rich.console import Console
|
||||||
|
from play_outside.config import get_config
|
||||||
|
|
||||||
|
api_app = typer.Typer()
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.callback()
|
||||||
|
def api():
|
||||||
|
"model cli"
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def config(
|
||||||
|
env: str = typer.Option(
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
play_outside_config = get_config(env)
|
||||||
|
Console().print(play_outside_config)
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def upgrade(
|
||||||
|
env: str = typer.Option(
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
alembic_revision: str = typer.Option(
|
||||||
|
"head",
|
||||||
|
help="the alembic revision to use",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
play_outside_config = get_config(env)
|
||||||
|
Console().print(play_outside_config)
|
||||||
|
alembic_cfg = Config("alembic.ini")
|
||||||
|
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
|
||||||
|
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def revision(
|
||||||
|
env: str = typer.Option(
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
alembic_revision: str = typer.Option(
|
||||||
|
"head",
|
||||||
|
help="the alembic revision to upgrade to before creating a new revision",
|
||||||
|
),
|
||||||
|
message: str = typer.Option(
|
||||||
|
None,
|
||||||
|
"--message",
|
||||||
|
"-m",
|
||||||
|
help="the message to use for the new revision",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
play_outside_config = get_config(env)
|
||||||
|
Console().print(play_outside_config)
|
||||||
|
alembic_cfg = Config("alembic.ini")
|
||||||
|
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
|
||||||
|
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
|
||||||
|
alembic.command.revision(
|
||||||
|
config=alembic_cfg,
|
||||||
|
message=message,
|
||||||
|
autogenerate=True,
|
||||||
|
)
|
||||||
|
alembic.command.upgrade(config=alembic_cfg, revision="head")
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def datasette(
|
||||||
|
env: str = typer.Option(
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
alembic_revision: str = typer.Option(
|
||||||
|
"head",
|
||||||
|
help="the alembic revision to use",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
from datasette.app import Datasette
|
||||||
|
|
||||||
|
play_outside_config = get_config(env)
|
||||||
|
Console().print(play_outside_config)
|
||||||
|
alembic_cfg = Config("alembic.ini")
|
||||||
|
alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
|
||||||
|
alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
|
||||||
|
|
||||||
|
ds = Datasette(
|
||||||
|
files=[Path(play_outside_config.database_url.replace("sqlite:///", ""))],
|
||||||
|
)
|
||||||
|
uvicorn.run(
|
||||||
|
ds.app(), **play_outside_config.datasette_server.dict(exclude={"app", "reload"})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def run(
|
||||||
|
env: str = typer.Option(
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
alembic_revision: str = typer.Option(
|
||||||
|
"head",
|
||||||
|
help="the alembic revision to use",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
play_outside_config = get_config(env)
|
||||||
|
Console().print(play_outside_config)
|
||||||
|
# alembic_cfg = Config("alembic.ini")
|
||||||
|
# alembic_cfg.set_main_option("sqlalchemy.url", play_outside_config.database_url)
|
||||||
|
# alembic.command.upgrade(config=alembic_cfg, revision=alembic_revision)
|
||||||
|
uvicorn.run(**play_outside_config.api_server.dict())
|
||||||
|
|
||||||
|
|
||||||
|
@api_app.command()
|
||||||
|
def justrun(
|
||||||
|
env: str = typer.Option(
|
||||||
|
"local",
|
||||||
|
help="the environment to use",
|
||||||
|
),
|
||||||
|
alembic_revision: str = typer.Option(
|
||||||
|
"head",
|
||||||
|
help="the alembic revision to use",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
play_outside_config = get_config(env)
|
||||||
|
Console().print(play_outside_config)
|
||||||
|
uvicorn.run(**play_outside_config.api_server.dict())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
api_app()
|
||||||
98
play_outside/config.py
Normal file
98
play_outside/config.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import pydantic
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import Field
|
||||||
|
from typing import Any
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timezone
|
||||||
|
from functools import lru_cache
|
||||||
|
import os
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
class ApiServer(BaseModel):
|
||||||
|
app: str = "play_outside.api:app"
|
||||||
|
port: int = 8200
|
||||||
|
reload: bool = True
|
||||||
|
log_level: str = "info"
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
workers: int = 1
|
||||||
|
forwarded_allow_ips: str = "*"
|
||||||
|
proxy_headers: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
if hasattr(jinja2, "pass_context"):
|
||||||
|
pass_context = jinja2.pass_context
|
||||||
|
else:
|
||||||
|
pass_context = jinja2.contextfunction
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
templates.env.globals["datetime"] = datetime
|
||||||
|
templates.env.globals["len"] = len
|
||||||
|
templates.env.globals["int"] = int
|
||||||
|
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 = "prod"
|
||||||
|
open_weather_api_key: Optional[str] = None
|
||||||
|
api_server: ApiServer = ApiServer()
|
||||||
|
the_templates: Optional[Jinja2Templates] = Field(None, exclude=True)
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="PLAY_OUTSIDE_",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def templates(self) -> Jinja2Templates:
|
||||||
|
if self.the_templates is None:
|
||||||
|
self.the_templates = get_templates(self)
|
||||||
|
return self.the_templates
|
||||||
|
|
||||||
|
@pydantic.validator("open_weather_api_key", pre=True, always=True)
|
||||||
|
def validate_open_weather_api_key(cls, v):
|
||||||
|
if v is None:
|
||||||
|
v = os.getenv("OPEN_WEATHER_API_KEY")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_config(env: Optional[str] = None):
|
||||||
|
if env is None:
|
||||||
|
env = os.environ.get("ENV", "prod")
|
||||||
|
load_dotenv(dotenv_path=f".env.{env}")
|
||||||
|
config = Config(env=env)
|
||||||
|
return config
|
||||||
104
play_outside/decorators.py
Normal file
104
play_outside/decorators.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import inspect
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
from inspect import signature
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from play_outside.config import get_config
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
|
||||||
|
|
||||||
|
admin_routes = []
|
||||||
|
not_cached_routes = []
|
||||||
|
cached_routes = []
|
||||||
|
|
||||||
|
|
||||||
|
def not_found(request):
|
||||||
|
hx_request_header = request.headers.get("hx-request")
|
||||||
|
user_agent = request.headers.get("user-agent", "").lower()
|
||||||
|
|
||||||
|
if "mozilla" in user_agent or "webkit" in user_agent or hx_request_header:
|
||||||
|
return config.templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{"status_code": 404, "detail": "Not Found", "request": request},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status_code": 404,
|
||||||
|
"detail": "Not Found",
|
||||||
|
},
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def no_cache(func):
|
||||||
|
not_cached_routes.append(f"{func.__module__}.{func.__name__}")
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, request: Request, **kwargs):
|
||||||
|
# my_header will be now available in decorator
|
||||||
|
if "request" in signature(func).parameters:
|
||||||
|
kwargs["request"] = request
|
||||||
|
|
||||||
|
if inspect.iscoroutinefunction(func):
|
||||||
|
response = await func(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
response = func(*args, **kwargs)
|
||||||
|
|
||||||
|
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def cache(max_age=86400):
|
||||||
|
def inner_wrapper(func):
|
||||||
|
cached_routes.append(f"{func.__module__}.{func.__name__}")
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, request: Request, **kwargs):
|
||||||
|
if "request" in signature(func).parameters:
|
||||||
|
kwargs["request"] = request
|
||||||
|
if inspect.iscoroutinefunction(func):
|
||||||
|
response = await func(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
response = func(*args, **kwargs)
|
||||||
|
response.headers[
|
||||||
|
"Cache-Control"
|
||||||
|
] = f"public, max-age={max_age}, stale-while-revalidate=31536000, stale-if-error=31536000"
|
||||||
|
response.headers["Expires"] = f"{int(time.time()) + max_age}"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return inner_wrapper
|
||||||
|
|
||||||
|
|
||||||
|
default_data = {}
|
||||||
|
|
||||||
|
|
||||||
|
def defaults(data=default_data):
|
||||||
|
def inner_wrapper(func):
|
||||||
|
default_data[f"{func.__module__}.{func.__name__}"] = data
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, request: Request, **kwargs):
|
||||||
|
if "request" in signature(func).parameters:
|
||||||
|
kwargs["request"] = request
|
||||||
|
if inspect.iscoroutinefunction(func):
|
||||||
|
response = await func(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
response = func(*args, **kwargs)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return inner_wrapper
|
||||||
4
play_outside/queries.py
Normal file
4
play_outside/queries.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
# get weather from open weather api
|
||||||
79
pyproject.toml
Normal file
79
pyproject.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["play_outside"]
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "play-outside"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = 'Use Open Weather Api to determine if Kids can play outside.'
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
license = "MIT"
|
||||||
|
keywords = []
|
||||||
|
authors = [{ name = "Waylon S. Walker", email = "waylon@waylonwalker.com" }]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Programming Language :: Python",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
'httpx',
|
||||||
|
'fastapi',
|
||||||
|
'uvicorn[standard]',
|
||||||
|
'typer',
|
||||||
|
'python-dotenv',
|
||||||
|
'rich',
|
||||||
|
'pydantic-settings',
|
||||||
|
'jinja2',
|
||||||
|
'arel',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Documentation = "https://github.com/unknown/play-outside#readme"
|
||||||
|
Issues = "https://github.com/unknown/play-outside/issues"
|
||||||
|
Source = "https://github.com/unknown/play-outside"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
play-outside = "play_outside.cli.api:api_app"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "play_outside/__about__.py"
|
||||||
|
|
||||||
|
[tool.hatch.envs.default]
|
||||||
|
dependencies = ["coverage[toml]>=6.5", "pytest"]
|
||||||
|
[tool.hatch.envs.default.scripts]
|
||||||
|
test = "pytest {args:tests}"
|
||||||
|
test-cov = "coverage run -m pytest {args:tests}"
|
||||||
|
cov-report = ["- coverage combine", "coverage report"]
|
||||||
|
cov = ["test-cov", "cov-report"]
|
||||||
|
|
||||||
|
[[tool.hatch.envs.all.matrix]]
|
||||||
|
python = ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
|
[tool.hatch.envs.types]
|
||||||
|
dependencies = ["mypy>=1.0.0"]
|
||||||
|
[tool.hatch.envs.types.scripts]
|
||||||
|
check = "mypy --install-types --non-interactive {args:play_outside tests}"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source_pkgs = ["play_outside", "tests"]
|
||||||
|
branch = true
|
||||||
|
parallel = true
|
||||||
|
omit = ["play_outside/__about__.py"]
|
||||||
|
|
||||||
|
[tool.coverage.paths]
|
||||||
|
play_outside = ["play_outside", "*/play-outside/play_outside"]
|
||||||
|
tests = ["tests", "*/play-outside/tests"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
|
||||||
822
static/app.css
Normal file
822
static/app.css
Normal file
|
|
@ -0,0 +1,822 @@
|
||||||
|
/*
|
||||||
|
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
|
||||||
|
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
|
||||||
|
*/
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* 1 */
|
||||||
|
border-width: 0;
|
||||||
|
/* 2 */
|
||||||
|
border-style: solid;
|
||||||
|
/* 2 */
|
||||||
|
border-color: #e5e7eb;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
--tw-content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Use a consistent sensible line-height in all browsers.
|
||||||
|
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||||
|
3. Use a more readable tab size.
|
||||||
|
4. Use the user's configured `sans` font-family by default.
|
||||||
|
5. Use the user's configured `sans` font-feature-settings by default.
|
||||||
|
6. Use the user's configured `sans` font-variation-settings by default.
|
||||||
|
7. Disable tap highlights on iOS
|
||||||
|
*/
|
||||||
|
|
||||||
|
html,
|
||||||
|
:host {
|
||||||
|
line-height: 1.5;
|
||||||
|
/* 1 */
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
/* 2 */
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
/* 3 */
|
||||||
|
-o-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
/* 3 */
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
/* 4 */
|
||||||
|
font-feature-settings: normal;
|
||||||
|
/* 5 */
|
||||||
|
font-variation-settings: normal;
|
||||||
|
/* 6 */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
/* 7 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Remove the margin in all browsers.
|
||||||
|
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
/* 1 */
|
||||||
|
line-height: inherit;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Add the correct height in Firefox.
|
||||||
|
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||||
|
3. Ensure horizontal rules are visible by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 0;
|
||||||
|
/* 1 */
|
||||||
|
color: inherit;
|
||||||
|
/* 2 */
|
||||||
|
border-top-width: 1px;
|
||||||
|
/* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
abbr:where([title]) {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the default font size and weight for headings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reset links to optimize for opt-in styling instead of opt-out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct font weight in Edge and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
b,
|
||||||
|
strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Use the user's configured `mono` font-family by default.
|
||||||
|
2. Use the user's configured `mono` font-feature-settings by default.
|
||||||
|
3. Use the user's configured `mono` font-variation-settings by default.
|
||||||
|
4. Correct the odd `em` font sizing in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
code,
|
||||||
|
kbd,
|
||||||
|
samp,
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
/* 1 */
|
||||||
|
font-feature-settings: normal;
|
||||||
|
/* 2 */
|
||||||
|
font-variation-settings: normal;
|
||||||
|
/* 3 */
|
||||||
|
font-size: 1em;
|
||||||
|
/* 4 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct font size in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sub,
|
||||||
|
sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||||
|
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||||
|
3. Remove gaps between table borders by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
table {
|
||||||
|
text-indent: 0;
|
||||||
|
/* 1 */
|
||||||
|
border-color: inherit;
|
||||||
|
/* 2 */
|
||||||
|
border-collapse: collapse;
|
||||||
|
/* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Change the font styles in all browsers.
|
||||||
|
2. Remove the margin in Firefox and Safari.
|
||||||
|
3. Remove default padding in all browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
optgroup,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
/* 1 */
|
||||||
|
font-feature-settings: inherit;
|
||||||
|
/* 1 */
|
||||||
|
font-variation-settings: inherit;
|
||||||
|
/* 1 */
|
||||||
|
font-size: 100%;
|
||||||
|
/* 1 */
|
||||||
|
font-weight: inherit;
|
||||||
|
/* 1 */
|
||||||
|
line-height: inherit;
|
||||||
|
/* 1 */
|
||||||
|
color: inherit;
|
||||||
|
/* 1 */
|
||||||
|
margin: 0;
|
||||||
|
/* 2 */
|
||||||
|
padding: 0;
|
||||||
|
/* 3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the inheritance of text transform in Edge and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
2. Remove default button styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[type='button'],
|
||||||
|
[type='reset'],
|
||||||
|
[type='submit'] {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
/* 1 */
|
||||||
|
background-color: transparent;
|
||||||
|
/* 2 */
|
||||||
|
background-image: none;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Use the modern Firefox focus style for all focusable elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:-moz-focusring {
|
||||||
|
outline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
|
||||||
|
*/
|
||||||
|
|
||||||
|
:-moz-ui-invalid {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct vertical alignment in Chrome and Firefox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Correct the cursor style of increment and decrement buttons in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-inner-spin-button,
|
||||||
|
::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the odd appearance in Chrome and Safari.
|
||||||
|
2. Correct the outline style in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
[type='search'] {
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
/* 1 */
|
||||||
|
outline-offset: -2px;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Remove the inner padding in Chrome and Safari on macOS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Correct the inability to style clickable types in iOS and Safari.
|
||||||
|
2. Change font properties to `inherit` in Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
::-webkit-file-upload-button {
|
||||||
|
-webkit-appearance: button;
|
||||||
|
/* 1 */
|
||||||
|
font: inherit;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Add the correct display in Chrome and Safari.
|
||||||
|
*/
|
||||||
|
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Removes the default spacing and border for appropriate elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
blockquote,
|
||||||
|
dl,
|
||||||
|
dd,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
hr,
|
||||||
|
figure,
|
||||||
|
p,
|
||||||
|
pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul,
|
||||||
|
menu {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Reset default styling for dialogs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Prevent resizing textareas horizontally by default.
|
||||||
|
*/
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
|
||||||
|
2. Set the default placeholder color to the user's configured gray 400 color.
|
||||||
|
*/
|
||||||
|
|
||||||
|
input::-moz-placeholder, textarea::-moz-placeholder {
|
||||||
|
opacity: 1;
|
||||||
|
/* 1 */
|
||||||
|
color: #9ca3af;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
opacity: 1;
|
||||||
|
/* 1 */
|
||||||
|
color: #9ca3af;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Set the default cursor for buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
button,
|
||||||
|
[role="button"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Make sure disabled buttons don't get the pointer cursor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||||
|
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
|
||||||
|
This can trigger a poorly considered lint error in some tools but is included by design.
|
||||||
|
*/
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg,
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
audio,
|
||||||
|
iframe,
|
||||||
|
embed,
|
||||||
|
object {
|
||||||
|
display: block;
|
||||||
|
/* 1 */
|
||||||
|
vertical-align: middle;
|
||||||
|
/* 2 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
|
||||||
|
*/
|
||||||
|
|
||||||
|
img,
|
||||||
|
video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make elements with the HTML hidden attribute stay hidden by default */
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, ::before, ::after {
|
||||||
|
--tw-border-spacing-x: 0;
|
||||||
|
--tw-border-spacing-y: 0;
|
||||||
|
--tw-translate-x: 0;
|
||||||
|
--tw-translate-y: 0;
|
||||||
|
--tw-rotate: 0;
|
||||||
|
--tw-skew-x: 0;
|
||||||
|
--tw-skew-y: 0;
|
||||||
|
--tw-scale-x: 1;
|
||||||
|
--tw-scale-y: 1;
|
||||||
|
--tw-pan-x: ;
|
||||||
|
--tw-pan-y: ;
|
||||||
|
--tw-pinch-zoom: ;
|
||||||
|
--tw-scroll-snap-strictness: proximity;
|
||||||
|
--tw-gradient-from-position: ;
|
||||||
|
--tw-gradient-via-position: ;
|
||||||
|
--tw-gradient-to-position: ;
|
||||||
|
--tw-ordinal: ;
|
||||||
|
--tw-slashed-zero: ;
|
||||||
|
--tw-numeric-figure: ;
|
||||||
|
--tw-numeric-spacing: ;
|
||||||
|
--tw-numeric-fraction: ;
|
||||||
|
--tw-ring-inset: ;
|
||||||
|
--tw-ring-offset-width: 0px;
|
||||||
|
--tw-ring-offset-color: #fff;
|
||||||
|
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow-colored: 0 0 #0000;
|
||||||
|
--tw-blur: ;
|
||||||
|
--tw-brightness: ;
|
||||||
|
--tw-contrast: ;
|
||||||
|
--tw-grayscale: ;
|
||||||
|
--tw-hue-rotate: ;
|
||||||
|
--tw-invert: ;
|
||||||
|
--tw-saturate: ;
|
||||||
|
--tw-sepia: ;
|
||||||
|
--tw-drop-shadow: ;
|
||||||
|
--tw-backdrop-blur: ;
|
||||||
|
--tw-backdrop-brightness: ;
|
||||||
|
--tw-backdrop-contrast: ;
|
||||||
|
--tw-backdrop-grayscale: ;
|
||||||
|
--tw-backdrop-hue-rotate: ;
|
||||||
|
--tw-backdrop-invert: ;
|
||||||
|
--tw-backdrop-opacity: ;
|
||||||
|
--tw-backdrop-saturate: ;
|
||||||
|
--tw-backdrop-sepia: ;
|
||||||
|
}
|
||||||
|
|
||||||
|
::backdrop {
|
||||||
|
--tw-border-spacing-x: 0;
|
||||||
|
--tw-border-spacing-y: 0;
|
||||||
|
--tw-translate-x: 0;
|
||||||
|
--tw-translate-y: 0;
|
||||||
|
--tw-rotate: 0;
|
||||||
|
--tw-skew-x: 0;
|
||||||
|
--tw-skew-y: 0;
|
||||||
|
--tw-scale-x: 1;
|
||||||
|
--tw-scale-y: 1;
|
||||||
|
--tw-pan-x: ;
|
||||||
|
--tw-pan-y: ;
|
||||||
|
--tw-pinch-zoom: ;
|
||||||
|
--tw-scroll-snap-strictness: proximity;
|
||||||
|
--tw-gradient-from-position: ;
|
||||||
|
--tw-gradient-via-position: ;
|
||||||
|
--tw-gradient-to-position: ;
|
||||||
|
--tw-ordinal: ;
|
||||||
|
--tw-slashed-zero: ;
|
||||||
|
--tw-numeric-figure: ;
|
||||||
|
--tw-numeric-spacing: ;
|
||||||
|
--tw-numeric-fraction: ;
|
||||||
|
--tw-ring-inset: ;
|
||||||
|
--tw-ring-offset-width: 0px;
|
||||||
|
--tw-ring-offset-color: #fff;
|
||||||
|
--tw-ring-color: rgb(59 130 246 / 0.5);
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow-colored: 0 0 #0000;
|
||||||
|
--tw-blur: ;
|
||||||
|
--tw-brightness: ;
|
||||||
|
--tw-contrast: ;
|
||||||
|
--tw-grayscale: ;
|
||||||
|
--tw-hue-rotate: ;
|
||||||
|
--tw-invert: ;
|
||||||
|
--tw-saturate: ;
|
||||||
|
--tw-sepia: ;
|
||||||
|
--tw-drop-shadow: ;
|
||||||
|
--tw-backdrop-blur: ;
|
||||||
|
--tw-backdrop-brightness: ;
|
||||||
|
--tw-backdrop-contrast: ;
|
||||||
|
--tw-backdrop-grayscale: ;
|
||||||
|
--tw-backdrop-hue-rotate: ;
|
||||||
|
--tw-backdrop-invert: ;
|
||||||
|
--tw-backdrop-opacity: ;
|
||||||
|
--tw-backdrop-saturate: ;
|
||||||
|
--tw-backdrop-sepia: ;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.container {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.container {
|
||||||
|
max-width: 768px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.container {
|
||||||
|
max-width: 1024px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.container {
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1536px) {
|
||||||
|
.container {
|
||||||
|
max-width: 1536px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-4 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-\[-1rem\] {
|
||||||
|
margin-top: -1rem;
|
||||||
|
margin-bottom: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-\[-4rem\] {
|
||||||
|
margin-top: -4rem;
|
||||||
|
margin-bottom: -4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-0 {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-24 {
|
||||||
|
margin-top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-\[10rem\] {
|
||||||
|
margin-left: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-block {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-3xl {
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-4xl {
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-none {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-3xl {
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-2 {
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-green-500 {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-red-500 {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-yellow-500 {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(234 179 8 / var(--tw-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-green-500 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-red-500 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-yellow-500 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-zinc-900 {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(24 24 27 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-opacity-10 {
|
||||||
|
--tw-bg-opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient-to-r {
|
||||||
|
background-image: linear-gradient(to right, var(--tw-gradient-stops));
|
||||||
|
}
|
||||||
|
|
||||||
|
.from-red-600 {
|
||||||
|
--tw-gradient-from: #dc2626 var(--tw-gradient-from-position);
|
||||||
|
--tw-gradient-to: rgb(220 38 38 / 0) var(--tw-gradient-to-position);
|
||||||
|
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
|
||||||
|
}
|
||||||
|
|
||||||
|
.via-pink-500 {
|
||||||
|
--tw-gradient-to: rgb(236 72 153 / 0) var(--tw-gradient-to-position);
|
||||||
|
--tw-gradient-stops: var(--tw-gradient-from), #ec4899 var(--tw-gradient-via-position), var(--tw-gradient-to);
|
||||||
|
}
|
||||||
|
|
||||||
|
.to-yellow-400 {
|
||||||
|
--tw-gradient-to: #facc15 var(--tw-gradient-to-position);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-clip-text {
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-0 {
|
||||||
|
padding-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-3xl {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
line-height: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-8xl {
|
||||||
|
font-size: 6rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-black {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leading-loose {
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leading-tight {
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-transparent {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-zinc-100 {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(244 244 245 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-red-500 {
|
||||||
|
--tw-ring-opacity: 1;
|
||||||
|
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(39 39 42 / var(--tw-bg-opacity));
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
html:-webkit-autofill {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
html:autofill {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(24 24 27 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-track {
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(219 39 119 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(82 82 91 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(113 113 122 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 9999px;
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(6 182 212 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb:hover {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(34 211 238 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.autofill\:bg-yellow-500:-webkit-autofill {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.autofill\:bg-yellow-500:autofill {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
static/htmx.org@1.9.8
Normal file
1
static/htmx.org@1.9.8
Normal file
File diff suppressed because one or more lines are too long
17
tailwind.config.js
Normal file
17
tailwind.config.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: ["templates/**/*.html"],
|
||||||
|
plugins: [require("@tailwindcss/typography")],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
width: {
|
||||||
|
128: "32rem",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
xlc: "0 0 60px 15px rgba(0, 0, 0, 0.3)",
|
||||||
|
lgc: "0 0 20px 0px rgba(0, 0, 0, 0.3)",
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
28
tailwind/app.css
Normal file
28
tailwind/app.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
@apply bg-zinc-800 text-white autofill:bg-yellow-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
@apply h-4 w-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
@apply rounded-full bg-zinc-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-track {
|
||||||
|
@apply rounded-full bg-pink-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
@apply rounded-full bg-zinc-600 hover:bg-zinc-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb {
|
||||||
|
@apply rounded-full bg-cyan-500 hover:bg-cyan-400;
|
||||||
|
}
|
||||||
49
templates/base.html
Normal file
49
templates/base.html
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{% block head %}
|
||||||
|
<title>
|
||||||
|
{% block title %}Play Outside{% endblock %}
|
||||||
|
</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="{{ url_for('get_favicon') }}" />
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="og:title" name="og:title" content="Can I Play Outside" />
|
||||||
|
<meta name="twitter:title" name="twitter:title" content="Can I Play Outside" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="og:image" name="og:image"
|
||||||
|
content="https://shots.wayl.one/shot/?url=https://play-outside.wayl.one&height=1080&width=1920&scaled_width=1200&scaled_height=600&selectors=" />
|
||||||
|
<meta name="twitter:image" name="twitter:image"
|
||||||
|
content="https://shots.wayl.one/shot/?url=https://play-outside.wayl.one&height=1080&width=1920&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="https://waylonwalker.com" />
|
||||||
|
<meta name="description" name="description"
|
||||||
|
content="Check if my kids can play outside" />
|
||||||
|
<meta name="og:description" name="Check if my kids can play outside"
|
||||||
|
content="Check if my kids can play outside" />
|
||||||
|
<meta name="twitter:description" name="twitter:description"
|
||||||
|
content="Check if my kids can play outside" />
|
||||||
|
|
||||||
|
<link href="{{ url_for('get_app_css') }}" rel="stylesheet" />
|
||||||
|
<!-- <script src="{{ url_for('get_htmx') }}"></script> -->
|
||||||
|
|
||||||
|
{% if config.env == 'local' %}
|
||||||
|
{{ hot_reload.script(url_for('hot-reload') ) | safe }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body >
|
||||||
|
<div class='p-4 bg-zinc-900 text-zinc-100 autofill:bg-yellow-500'>
|
||||||
|
<h2 class='mx-auto max-w-4xl'>
|
||||||
|
Disclaimer
|
||||||
|
</h2>
|
||||||
|
<p class='mx-auto max-w-4xl'>
|
||||||
|
I use this site to determine if my kids can play outside, each kid is different, use your own discretion before allowing kids outside.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% block body %}
|
||||||
|
{{ content | safe }}
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
70
templates/card.html
Normal file
70
templates/card.html
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
{% if datetime.fromtimestamp(weather.dt).hour == 6 %}
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2 class='block mt-24 text-3xl font-black text-center'>
|
||||||
|
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%A') }}
|
||||||
|
<br>
|
||||||
|
<span class='text-xl'>
|
||||||
|
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%Y-%m-%d') }}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<ul class='list-none'>
|
||||||
|
{% endif %}
|
||||||
|
<li <p class='text-center'>
|
||||||
|
{{ datetime.fromtimestamp(weather.dt).strftime(format = '%I:%M %p') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class='container p-4 my-4 mx-auto max-w-3xl list-none rounded-3xl border-2 {{ weather.play_condition.color.replace("bg-", "border-") }} {{ weather.play_condition.color }} bg-opacity-10'>
|
||||||
|
<h1 id="title"
|
||||||
|
class="inline-block pb-0 mx-auto mb-0 text-xl 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">
|
||||||
|
|
||||||
|
{{ weather.play_condition.message.replace('.', '<br />') | safe }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ weather.name }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Feels Like: {{ weather.main.feels_like }}
|
||||||
|
</p>
|
||||||
|
<ul class='list-none'>
|
||||||
|
{% for w in weather.weather %}
|
||||||
|
<li>
|
||||||
|
<p>
|
||||||
|
{{ w.description }}
|
||||||
|
<img src='https://openweathermap.org/img/wn/{{ w.icon }}.png' class='inline-block my-[-1rem]' />
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Air Quality: {{ air_quality.list.0.main.aqi }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Sunset: {{ datetime.fromtimestamp(sunset) }}
|
||||||
|
</p>
|
||||||
|
{% if weather.sys.sunset %}
|
||||||
|
{% if (datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt)).total_seconds() > 0
|
||||||
|
%}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Time till sunset: {{ datetime.fromtimestamp(weather.sys.sunset) - datetime.fromtimestamp(weather.dt) }}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
Its after sunset
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
Visibility: {{ weather.visibility }}
|
||||||
|
</p>
|
||||||
|
{% if weather.wind.speed > 5 %}
|
||||||
|
<p>
|
||||||
|
Wind Speed: {{ weather.wind.speed }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
12
templates/card.txt
Normal file
12
templates/card.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% set w = int((len(weather.play_condition.message) - len(datetime.fromtimestamp(weather.dt).strftime(format = '%A %I:%M %p')))-1) %}
|
||||||
|
╭─ {{ datetime.fromtimestamp(weather.dt).strftime(format = '%A %I:%M %p')}} {{ '─'*w }}╮
|
||||||
|
│ {%if color %}{{ weather.play_condition.ansi_color }}{%endif%}{{ weather.play_condition.message | safe }}{%if color%}\033[0m{%endif%} │
|
||||||
|
╰─{{ '─'* len(weather.play_condition.message) }}─╯
|
||||||
|
{{ weather.name }}
|
||||||
|
Feels Like: {{ weather.main.feels_like }}
|
||||||
|
Air Quality: {{ air_quality.list.0.main.aqi }}
|
||||||
|
Sunset: {{ datetime.fromtimestamp(sunset).strftime(format = '%I:%M %p') }}
|
||||||
|
Visibility: {{ weather.visibility }}
|
||||||
|
{% if weather.wind.speed > 5 -%}
|
||||||
|
Wind Speed: {{ weather.wind.speed }}
|
||||||
|
{% endif -%}
|
||||||
6
templates/includestyles.html
Normal file
6
templates/includestyles.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<div class='bg-green-500 border-green-500'>
|
||||||
|
</div>
|
||||||
|
<div class='bg-red-500 border-red-500'>
|
||||||
|
</div>
|
||||||
|
<div class='bg-yellow-500 border-yellow-500'>
|
||||||
|
</div>
|
||||||
13
templates/index.html
Normal file
13
templates/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block body %}
|
||||||
|
<h2 class='block mt-24 text-3xl font-black text-center'>Currently</h2>
|
||||||
|
<ul class='list-none'>
|
||||||
|
{% include "card.html" %}
|
||||||
|
</ul>
|
||||||
|
<h2 class='block mt-24 text-3xl font-black text-center'>5 Day Forecast</h2>
|
||||||
|
<ul class='list-none'>
|
||||||
|
{% for weather in forecast %}
|
||||||
|
{% include "card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
4
templates/index.txt
Normal file
4
templates/index.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{% include "card.txt" %}
|
||||||
|
{% for weather in forecast %}
|
||||||
|
{% include "card.txt" %}
|
||||||
|
{% endfor %}
|
||||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# SPDX-FileCopyrightText: 2024-present Waylon S. Walker <waylon@waylonwalker.com>
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
Loading…
Add table
Add a link
Reference in a new issue