feat: Begins htmx integration
This commit is contained in:
@@ -79,10 +79,12 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "ViewController",
|
name: "ViewController",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
"ApiController",
|
||||||
"Routes",
|
"Routes",
|
||||||
"Styleguide",
|
"Styleguide",
|
||||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||||
.product(name: "Elementary", package: "elementary")
|
.product(name: "Elementary", package: "elementary"),
|
||||||
|
.product(name: "ElementaryHTMX", package: "elementary-htmx")
|
||||||
],
|
],
|
||||||
swiftSettings: swiftSettings
|
swiftSettings: swiftSettings
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,15 +2,15 @@ import Logging
|
|||||||
import PsychrometricClient
|
import PsychrometricClient
|
||||||
import Routes
|
import Routes
|
||||||
|
|
||||||
extension PsychrometricClient {
|
public extension PsychrometricClient {
|
||||||
|
|
||||||
private func calculateProperties(_ request: MoldRisk.Request) async throws -> PsychrometricProperties {
|
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 properties = try await calculateProperties(request)
|
||||||
let riskLevel = MoldRisk.RiskLevel(humidity: request.humidity)
|
let riskLevel = MoldRisk.RiskLevel(humidity: request.relativeHumidity)
|
||||||
|
|
||||||
return .init(
|
return .init(
|
||||||
psychrometricProperties: properties,
|
psychrometricProperties: properties,
|
||||||
@@ -56,7 +56,7 @@ private extension Array where Element == String {
|
|||||||
"Reduce indoor relative humidity below 60% using dehumidification"
|
"Reduce indoor relative humidity below 60% using dehumidification"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (request.temperature.fahrenheit - dewPoint.fahrenheit) < 4 {
|
if (request.dryBulb.fahrenheit - dewPoint.fahrenheit) < 4 {
|
||||||
recommendations.append(
|
recommendations.append(
|
||||||
"Increase air temperature or improve insulation to prevent condensation"
|
"Increase air temperature or improve insulation to prevent condensation"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,16 +6,11 @@ import ViewController
|
|||||||
|
|
||||||
extension ViewController {
|
extension ViewController {
|
||||||
func respond(route: SiteRoute.View, request: Vapor.Request) async throws -> any AsyncResponseEncodable {
|
func respond(route: SiteRoute.View, request: Vapor.Request) async throws -> any AsyncResponseEncodable {
|
||||||
// let html = try await view(
|
let html = try await view(.init(
|
||||||
// for: route,
|
route,
|
||||||
// isHtmxRequest: request.isHtmxRequest,
|
isHtmxRequest: request.headers.contains(name: "hx-request"),
|
||||||
// logger: request.logger,
|
logger: request.logger
|
||||||
// authenticate: { request.session.authenticate($0) },
|
))
|
||||||
// currentUser: {
|
|
||||||
// try request.auth.require(User.self)
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
let html = try await view(route)
|
|
||||||
return AnyHTMLResponse(value: html)
|
return AnyHTMLResponse(value: html)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import PsychrometricClient
|
|||||||
public enum MoldRisk {
|
public enum MoldRisk {
|
||||||
public struct Request: Codable, Equatable, Sendable {
|
public struct Request: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let temperature: DryBulb
|
public let temperature: Double
|
||||||
public let humidity: RelativeHumidity
|
public let humidity: Double
|
||||||
|
|
||||||
public init(temperature: DryBulb, humidity: RelativeHumidity) {
|
public init(temperature: Double, humidity: Double) {
|
||||||
self.temperature = temperature
|
self.temperature = temperature
|
||||||
self.humidity = humidity
|
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
|
#if DEBUG
|
||||||
import Dependencies
|
import Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import CasePaths
|
import CasePaths
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PsychrometricClient
|
||||||
@preconcurrency import URLRouting
|
@preconcurrency import URLRouting
|
||||||
|
|
||||||
public enum SiteRoute: Equatable, Sendable {
|
public enum SiteRoute: Equatable, Sendable {
|
||||||
@@ -52,6 +53,7 @@ public extension SiteRoute {
|
|||||||
|
|
||||||
public enum MoldRisk: Equatable, Sendable {
|
public enum MoldRisk: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
|
case submit(Routes.MoldRisk.Request)
|
||||||
|
|
||||||
static let rootPath = "mold-risk"
|
static let rootPath = "mold-risk"
|
||||||
|
|
||||||
@@ -60,6 +62,22 @@ public extension SiteRoute {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
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 Dependencies
|
||||||
import DependenciesMacros
|
import DependenciesMacros
|
||||||
import Elementary
|
import Elementary
|
||||||
|
import Logging
|
||||||
|
import PsychrometricClient
|
||||||
import Routes
|
import Routes
|
||||||
|
|
||||||
public extension DependencyValues {
|
public extension DependencyValues {
|
||||||
@@ -14,7 +17,23 @@ public typealias AnySendableHTML = (any HTML & Sendable)
|
|||||||
|
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
public struct ViewController: Sendable {
|
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 {
|
extension ViewController: TestDependencyKey {
|
||||||
@@ -23,10 +42,37 @@ extension ViewController: TestDependencyKey {
|
|||||||
|
|
||||||
extension ViewController: DependencyKey {
|
extension ViewController: DependencyKey {
|
||||||
public static var liveValue: ViewController {
|
public static var liveValue: ViewController {
|
||||||
.init(view: { _ in
|
@Dependency(\.psychrometricClient) var psychrometricClient
|
||||||
MainPage {
|
|
||||||
MoldRiskForm()
|
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")
|
.init(name: "sizes", value: "180x180")
|
||||||
)
|
)
|
||||||
link(.rel(.init(rawValue: "mainifest")), .href("/site.webmanifest"))
|
link(.rel(.init(rawValue: "mainifest")), .href("/site.webmanifest"))
|
||||||
|
script(.src("https://unpkg.com/htmx.org@2.0.4")) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
@@ -59,7 +60,10 @@ private struct Header: HTML {
|
|||||||
// TODO: Add class active, to button that is the active route.
|
// 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")) {
|
ul(.class("flex flex-wrap gap-x-2 lg:gap-x-5 \(text: .yellow) font-bold")) {
|
||||||
li {
|
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"
|
"Mold-Risk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +84,9 @@ private struct PageContent<Body: HTML>: HTML where Body: Sendable {
|
|||||||
var content: some HTML {
|
var content: some HTML {
|
||||||
div(.class("mx-5 lg:mx-20")) {
|
div(.class("mx-5 lg:mx-20")) {
|
||||||
div(.class("rounded-xl shadow-lg bg-white dark:bg-slate-700 p-8")) {
|
div(.class("rounded-xl shadow-lg bg-white dark:bg-slate-700 p-8")) {
|
||||||
body()
|
div(.id("content")) {
|
||||||
|
body()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import Styleguide
|
|||||||
|
|
||||||
struct MoldRiskForm: HTML {
|
struct MoldRiskForm: HTML {
|
||||||
|
|
||||||
|
let response: MoldRisk.Response?
|
||||||
|
|
||||||
var content: some HTML {
|
var content: some HTML {
|
||||||
FormHeader(label: "Mold Risk Calculator", svg: .thermometer)
|
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")) {
|
div(.class("space-y-6")) {
|
||||||
LabeledContent(label: "Indoor Temperature (°F)") {
|
LabeledContent(label: "Indoor Temperature (°F)") {
|
||||||
Input(id: "temperature", placeholder: "Dry bulb temperature")
|
Input(id: "temperature", placeholder: "Dry bulb temperature")
|
||||||
@@ -22,10 +28,11 @@ struct MoldRiskForm: HTML {
|
|||||||
div {
|
div {
|
||||||
SubmitButton(label: "Calculate Mold Risk")
|
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 {
|
var content: some HTML {
|
||||||
ResultContainer {
|
ResultContainer {
|
||||||
// TODO: Color needs to be derived from risk level.
|
div(
|
||||||
div(.class("p-2 rounded-lg shadow-lg bg-lime-100 border-2 border border-lime-600")) {
|
.class("""
|
||||||
LabeledContent {
|
p-2 rounded-lg shadow-lg \(response.riskLevel.backgroundColor) border-2 border \(response.riskLevel.borderColor)
|
||||||
p(.class("text-lg font-semibold \(text: .green) dark:text-lime-600 mt-2")) {
|
""")
|
||||||
"Risk Level: \(response.riskLevel.rawValue.capitalized)"
|
) {
|
||||||
|
// 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)
|
PsychrometricPropertiesGrid(properties: response.psychrometricProperties)
|
||||||
.attributes(.class("mx-6"))
|
.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")) {
|
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")) {
|
p(.class("text-sm text-blue-500")) {
|
||||||
span(.class("font-extrabold pe-2")) { "Note:" }
|
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 {
|
struct PsychrometricPropertiesGrid: HTML {
|
||||||
let properties: PsychrometricProperties
|
let properties: PsychrometricProperties
|
||||||
|
|
||||||
var content: some HTML<HTMLTag.div> {
|
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")) {
|
||||||
displayProperty("Dew Point", \.dewPoint.rawValue)
|
p(.class("text-xl font-semibold border-b")) {
|
||||||
displayProperty("Wet Bulb", \.wetBulb.rawValue)
|
"Psychrometric Properties:"
|
||||||
displayProperty("Enthalpy", \.enthalpy.rawValue)
|
}
|
||||||
displayProperty("Density", \.density.rawValue)
|
div(.class("w-full mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")) {
|
||||||
displayProperty("Vapor Pressure", \.vaporPressure.rawValue)
|
displayProperty("Dew Point", \.dewPoint.rawValue)
|
||||||
displayProperty("Specific Volume", properties.specificVolume.rawValue)
|
displayProperty("Wet Bulb", \.wetBulb.rawValue)
|
||||||
displayProperty("Absolute Humidity", \.absoluteHumidity)
|
displayProperty("Enthalpy", \.enthalpy.rawValue)
|
||||||
displayProperty("Humidity Ratio", properties.humidityRatio.value)
|
displayProperty("Density", \.density.rawValue)
|
||||||
displayProperty("Degree of Saturation", properties.degreeOfSaturation.value)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user