feat: Begins htmx integration
This commit is contained in:
@@ -79,10 +79,12 @@ let package = Package(
|
||||
.target(
|
||||
name: "ViewController",
|
||||
dependencies: [
|
||||
"ApiController",
|
||||
"Routes",
|
||||
"Styleguide",
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "Elementary", package: "elementary")
|
||||
.product(name: "Elementary", package: "elementary"),
|
||||
.product(name: "ElementaryHTMX", package: "elementary-htmx")
|
||||
],
|
||||
swiftSettings: swiftSettings
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,15 +2,15 @@ import Logging
|
||||
import PsychrometricClient
|
||||
import Routes
|
||||
|
||||
extension PsychrometricClient {
|
||||
public extension PsychrometricClient {
|
||||
|
||||
private func calculateProperties(_ request: MoldRisk.Request) async throws -> PsychrometricProperties {
|
||||
try await psychrometricProperties(.dryBulb(request.temperature, relativeHumidity: request.humidity))
|
||||
try await psychrometricProperties(.dryBulb(request.dryBulb, relativeHumidity: request.relativeHumidity))
|
||||
}
|
||||
|
||||
func respond(_ request: MoldRisk.Request, _ logger: Logger) async throws -> MoldRisk.Response {
|
||||
func respond(_ request: MoldRisk.Request, _ logger: Logger? = nil) async throws -> MoldRisk.Response {
|
||||
let properties = try await calculateProperties(request)
|
||||
let riskLevel = MoldRisk.RiskLevel(humidity: request.humidity)
|
||||
let riskLevel = MoldRisk.RiskLevel(humidity: request.relativeHumidity)
|
||||
|
||||
return .init(
|
||||
psychrometricProperties: properties,
|
||||
@@ -56,7 +56,7 @@ private extension Array where Element == String {
|
||||
"Reduce indoor relative humidity below 60% using dehumidification"
|
||||
)
|
||||
}
|
||||
if (request.temperature.fahrenheit - dewPoint.fahrenheit) < 4 {
|
||||
if (request.dryBulb.fahrenheit - dewPoint.fahrenheit) < 4 {
|
||||
recommendations.append(
|
||||
"Increase air temperature or improve insulation to prevent condensation"
|
||||
)
|
||||
|
||||
@@ -6,16 +6,11 @@ import ViewController
|
||||
|
||||
extension ViewController {
|
||||
func respond(route: SiteRoute.View, request: Vapor.Request) async throws -> any AsyncResponseEncodable {
|
||||
// let html = try await view(
|
||||
// for: route,
|
||||
// isHtmxRequest: request.isHtmxRequest,
|
||||
// logger: request.logger,
|
||||
// authenticate: { request.session.authenticate($0) },
|
||||
// currentUser: {
|
||||
// try request.auth.require(User.self)
|
||||
// }
|
||||
// )
|
||||
let html = try await view(route)
|
||||
let html = try await view(.init(
|
||||
route,
|
||||
isHtmxRequest: request.headers.contains(name: "hx-request"),
|
||||
logger: request.logger
|
||||
))
|
||||
return AnyHTMLResponse(value: html)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import PsychrometricClient
|
||||
public enum MoldRisk {
|
||||
public struct Request: Codable, Equatable, Sendable {
|
||||
|
||||
public let temperature: DryBulb
|
||||
public let humidity: RelativeHumidity
|
||||
public let temperature: Double
|
||||
public let humidity: Double
|
||||
|
||||
public init(temperature: DryBulb, humidity: RelativeHumidity) {
|
||||
public init(temperature: Double, humidity: Double) {
|
||||
self.temperature = temperature
|
||||
self.humidity = humidity
|
||||
}
|
||||
@@ -41,6 +41,15 @@ public enum MoldRisk {
|
||||
}
|
||||
}
|
||||
|
||||
public extension MoldRisk.Request {
|
||||
|
||||
var dryBulb: DryBulb {
|
||||
.fahrenheit(temperature)
|
||||
}
|
||||
|
||||
var relativeHumidity: RelativeHumidity { humidity% }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import Dependencies
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import CasePaths
|
||||
import Foundation
|
||||
import PsychrometricClient
|
||||
@preconcurrency import URLRouting
|
||||
|
||||
public enum SiteRoute: Equatable, Sendable {
|
||||
@@ -52,6 +53,7 @@ public extension SiteRoute {
|
||||
|
||||
public enum MoldRisk: Equatable, Sendable {
|
||||
case index
|
||||
case submit(Routes.MoldRisk.Request)
|
||||
|
||||
static let rootPath = "mold-risk"
|
||||
|
||||
@@ -60,6 +62,22 @@ public extension SiteRoute {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
|
||||
Route(.case(Self.submit)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body {
|
||||
FormData {
|
||||
Field("temperature") {
|
||||
Double.parser()
|
||||
}
|
||||
Field("humidity") {
|
||||
Double.parser()
|
||||
}
|
||||
}
|
||||
.map(.memberwise(Routes.MoldRisk.Request.init))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
Sources/ViewController/Extensions/HTMXExtensions.swift
Normal file
30
Sources/ViewController/Extensions/HTMXExtensions.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +84,12 @@ 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")) {
|
||||
div(.id("content")) {
|
||||
body()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol SendableHTMLDocument: HTMLDocument, Sendable {}
|
||||
|
||||
@@ -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,11 +28,12 @@ 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")) {
|
||||
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)"
|
||||
}
|
||||
} label: {
|
||||
SVG(.exclamation, color: "text-green-600 dark:text-lime-600")
|
||||
}
|
||||
.attributes(.class("flex items-center gap-2"))
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,11 +102,45 @@ 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")) {
|
||||
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)
|
||||
@@ -81,6 +152,7 @@ struct PsychrometricPropertiesGrid: HTML {
|
||||
displayProperty("Degree of Saturation", properties.degreeOfSaturation.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func displayProperty(_ label: String, _ value: Double, _ symbol: String? = nil) -> some HTML {
|
||||
let symbol = "\(symbol == nil ? "" : " \(symbol!)")"
|
||||
|
||||
Reference in New Issue
Block a user