feat: Begins htmx integration

This commit is contained in:
2025-02-27 12:38:08 -05:00
parent 36e00cd007
commit 3e17bf2a9a
10 changed files with 227 additions and 49 deletions

View File

@@ -0,0 +1,30 @@
import Elementary
import ElementaryHTMX
import Routes
extension HTMLAttribute.hx {
@Sendable
static func get(route: SiteRoute.View) -> HTMLAttribute {
get(SiteRoute.View.router.path(for: route))
}
@Sendable
static func patch(route: SiteRoute.View) -> HTMLAttribute {
patch(SiteRoute.View.router.path(for: route))
}
@Sendable
static func post(route: SiteRoute.View) -> HTMLAttribute {
post(SiteRoute.View.router.path(for: route))
}
@Sendable
static func put(route: SiteRoute.View) -> HTMLAttribute {
put(SiteRoute.View.router.path(for: route))
}
@Sendable
static func delete(route: SiteRoute.Api) -> HTMLAttribute {
delete(SiteRoute.Api.router.path(for: route))
}
}

View File

@@ -1,6 +1,9 @@
import ApiController
import Dependencies
import DependenciesMacros
import Elementary
import Logging
import PsychrometricClient
import Routes
public extension DependencyValues {
@@ -14,7 +17,23 @@ public typealias AnySendableHTML = (any HTML & Sendable)
@DependencyClient
public struct ViewController: Sendable {
public var view: @Sendable (SiteRoute.View) async throws -> AnySendableHTML
public var view: @Sendable (Request) async throws -> AnySendableHTML
public struct Request: Sendable {
let route: SiteRoute.View
let isHtmxRequest: Bool
let logger: Logger
public init(
_ route: SiteRoute.View,
isHtmxRequest: Bool,
logger: Logger
) {
self.route = route
self.isHtmxRequest = isHtmxRequest
self.logger = logger
}
}
}
extension ViewController: TestDependencyKey {
@@ -23,10 +42,37 @@ extension ViewController: TestDependencyKey {
extension ViewController: DependencyKey {
public static var liveValue: ViewController {
.init(view: { _ in
MainPage {
MoldRiskForm()
@Dependency(\.psychrometricClient) var psychrometricClient
return .init(view: { request in
switch request.route {
case .index:
return MainPage {
p(.class("dark:text-gray-200")) {
"Professional calculators for HVAC system design and troubleshooting."
}
}
case let .moldRisk(route):
switch route {
case .index:
return request.respond(MoldRiskForm(response: nil))
case let .submit(request):
let response = try await psychrometricClient.respond(request)
return MoldRiskResponse(response: response)
}
}
})
}
}
extension ViewController.Request {
func respond<C: HTML>(
_ html: C
) -> AnySendableHTML where C: Sendable {
guard isHtmxRequest else {
return MainPage { html }
}
return html
}
}

View File

@@ -28,6 +28,7 @@ struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
.init(name: "sizes", value: "180x180")
)
link(.rel(.init(rawValue: "mainifest")), .href("/site.webmanifest"))
script(.src("https://unpkg.com/htmx.org@2.0.4")) {}
}
var body: some HTML {
@@ -59,7 +60,10 @@ private struct Header: HTML {
// TODO: Add class active, to button that is the active route.
ul(.class("flex flex-wrap gap-x-2 lg:gap-x-5 \(text: .yellow) font-bold")) {
li {
a(.href(route: .moldRisk(.index)), .class("hover:border-b \(border: .yellow)")) {
a(
.class("hover:border-b \(border: .yellow)"),
.hx.get(route: .moldRisk(.index)), .hx.target("#content"), .hx.pushURL(true)
) {
"Mold-Risk"
}
}
@@ -80,7 +84,9 @@ private struct PageContent<Body: HTML>: HTML where Body: Sendable {
var content: some HTML {
div(.class("mx-5 lg:mx-20")) {
div(.class("rounded-xl shadow-lg bg-white dark:bg-slate-700 p-8")) {
body()
div(.id("content")) {
body()
}
}
}
}

View File

@@ -5,9 +5,15 @@ import Styleguide
struct MoldRiskForm: HTML {
let response: MoldRisk.Response?
var content: some HTML {
FormHeader(label: "Mold Risk Calculator", svg: .thermometer)
form(.action("#")) {
form(
// Using index to get the correct path, but really uses the `submit` end-point.
.hx.post(route: .moldRisk(.index)),
.hx.target("#result")
) {
div(.class("space-y-6")) {
LabeledContent(label: "Indoor Temperature (°F)") {
Input(id: "temperature", placeholder: "Dry bulb temperature")
@@ -22,10 +28,11 @@ struct MoldRiskForm: HTML {
div {
SubmitButton(label: "Calculate Mold Risk")
}
div(.id("result")) {
MoldRiskResponse(response: .mock)
}
}
}
div(.id("result")) {
if let response {
MoldRiskResponse(response: response)
}
}
}
@@ -36,21 +43,51 @@ struct MoldRiskResponse: HTML {
var content: some HTML {
ResultContainer {
// TODO: Color needs to be derived from risk level.
div(.class("p-2 rounded-lg shadow-lg bg-lime-100 border-2 border border-lime-600")) {
LabeledContent {
p(.class("text-lg font-semibold \(text: .green) dark:text-lime-600 mt-2")) {
"Risk Level: \(response.riskLevel.rawValue.capitalized)"
div(
.class("""
p-2 rounded-lg shadow-lg \(response.riskLevel.backgroundColor) border-2 border \(response.riskLevel.borderColor)
""")
) {
// Risk level and days to mold header.
div(.class("\(response.riskLevel.textColor)")) {
div(.class("flex flex-wrap mt-2")) {
div(.class("w-full sm:w-1/2 flex gap-2")) {
SVG(.exclamation, color: "\(response.riskLevel.textColor)")
span(.class("text-lg font-extrabold")) {
"Risk Level: \(response.riskLevel.rawValue.capitalized)"
}
}
if let daysToMold = response.daysToMold {
div(.class("w-full sm:w-1/2 gap-2")) {
span(.class("font-semibold")) { "Estimated Days to Mold Growth: " }
span { "\(daysToMold) days" }
}
}
}
// Recommendations
if response.recommendations.count > 0 {
div(.class("mt-6 pb-4")) {
p(.class("font-semibold mb-4")) {
u {
"Recommendation\(response.recommendations.count == 1 ? "" : "s"):"
}
}
ul(.class("list-disc mx-10")) {
for recommendation in response.recommendations {
li { recommendation }
}
}
}
}
} label: {
SVG(.exclamation, color: "text-green-600 dark:text-lime-600")
}
.attributes(.class("flex items-center gap-2"))
}
// Display psychrometric properties.
PsychrometricPropertiesGrid(properties: response.psychrometricProperties)
.attributes(.class("mx-6"))
// Disclaimer.
div(.class("mt-8 p-4 bg-gray-100 dark:bg-gray-700 rounded-md shadow-md border border-blue-500")) {
p(.class("text-sm text-blue-500")) {
span(.class("font-extrabold pe-2")) { "Note:" }
@@ -65,20 +102,55 @@ struct MoldRiskResponse: HTML {
}
}
private extension MoldRisk.RiskLevel {
var backgroundColor: String {
switch self {
case .low: return "bg-green-200"
case .moderate: return "bg-amber-200"
case .high: return "bg-orange-200"
case .severe: return "bg-red-200"
}
}
var borderColor: String {
switch self {
case .low: return "border-green-500"
case .moderate: return "border-amber-500"
case .high: return "border-orange-500"
case .severe: return "border-red-500"
}
}
var textColor: String {
switch self {
case .low: return "text-green-500"
case .moderate: return "text-amber-500"
case .high: return "text-orange-500"
case .severe: return "text-red-500"
}
}
}
struct PsychrometricPropertiesGrid: HTML {
let properties: PsychrometricProperties
var content: some HTML<HTMLTag.div> {
div(.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-6 md:mx-20")) {
displayProperty("Dew Point", \.dewPoint.rawValue)
displayProperty("Wet Bulb", \.wetBulb.rawValue)
displayProperty("Enthalpy", \.enthalpy.rawValue)
displayProperty("Density", \.density.rawValue)
displayProperty("Vapor Pressure", \.vaporPressure.rawValue)
displayProperty("Specific Volume", properties.specificVolume.rawValue)
displayProperty("Absolute Humidity", \.absoluteHumidity)
displayProperty("Humidity Ratio", properties.humidityRatio.value)
displayProperty("Degree of Saturation", properties.degreeOfSaturation.value)
div(.class("pt-8")) {
p(.class("text-xl font-semibold border-b")) {
"Psychrometric Properties:"
}
div(.class("w-full mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")) {
displayProperty("Dew Point", \.dewPoint.rawValue)
displayProperty("Wet Bulb", \.wetBulb.rawValue)
displayProperty("Enthalpy", \.enthalpy.rawValue)
displayProperty("Density", \.density.rawValue)
displayProperty("Vapor Pressure", \.vaporPressure.rawValue)
displayProperty("Specific Volume", properties.specificVolume.rawValue)
displayProperty("Absolute Humidity", \.absoluteHumidity)
displayProperty("Humidity Ratio", properties.humidityRatio.value)
displayProperty("Degree of Saturation", properties.degreeOfSaturation.value)
}
}
}