diff --git a/.gitignore b/.gitignore index 0023a53..371ec2d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +.swift-version diff --git a/Package.swift b/Package.swift index 86469dd..3fb748f 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( .library(name: "DatabaseClient", targets: ["DatabaseClient"]), .library(name: "ManualDCore", targets: ["ManualDCore"]), .library(name: "ManualDClient", targets: ["ManualDClient"]), + .library(name: "Styleguide", targets: ["Styleguide"]), .library(name: "ViewController", targets: ["ViewController"]), ], dependencies: [ @@ -84,6 +85,14 @@ let package = Package( .product(name: "DependenciesMacros", package: "swift-dependencies"), ] ), + .target( + name: "Styleguide", + dependencies: [ + "ManualDCore", + .product(name: "Elementary", package: "elementary"), + .product(name: "ElementaryHTMX", package: "elementary-htmx"), + ] + ), .testTarget( name: "ManualDClientTests", dependencies: [ @@ -95,6 +104,7 @@ let package = Package( name: "ViewController", dependencies: [ .target(name: "ManualDCore"), + .target(name: "Styleguide"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "DependenciesMacros", package: "swift-dependencies"), .product(name: "Elementary", package: "elementary"), diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index 14b69e6..6e98bb8 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -62,11 +62,19 @@ extension SiteRoute.View { extension SiteRoute.View { public enum RoomRoute: Equatable, Sendable { case form + case index static let rootPath = "rooms" public static let router = OneOf { Route(.case(Self.form)) { + Path { + rootPath + "create" + } + Method.get + } + Route(.case(Self.index)) { Path { rootPath } Method.get } diff --git a/Sources/Styleguide/Buttons.swift b/Sources/Styleguide/Buttons.swift new file mode 100644 index 0000000..f742c85 --- /dev/null +++ b/Sources/Styleguide/Buttons.swift @@ -0,0 +1,53 @@ +import Elementary + +public struct SubmitButton: HTML, Sendable { + let title: String + let type: HTMLAttribute.ButtonType + + public init( + title: String = "Submit", + type: HTMLAttribute.ButtonType = .submit + ) { + self.title = title + self.type = type + } + + public var body: some HTML { + button( + .class( + """ + text-white font-bold text-xl bg-blue-500 hover:bg-blue-600 px-4 py-2 rounded-lg shadow-lg + """ + ), + .type(type) + ) { + title + } + } +} + +public struct CancelButton: HTML, Sendable { + let title: String + let type: HTMLAttribute.ButtonType + + public init( + title: String = "Cancel", + type: HTMLAttribute.ButtonType = .button + ) { + self.title = title + self.type = type + } + + public var body: some HTML { + button( + .class( + """ + text-white font-bold text-xl bg-red-500 hover:bg-red-600 px-4 py-2 rounded-lg shadow-lg + """ + ), + .type(type) + ) { + title + } + } +} diff --git a/Sources/Styleguide/HTMXExtensions.swift b/Sources/Styleguide/HTMXExtensions.swift new file mode 100644 index 0000000..5e79840 --- /dev/null +++ b/Sources/Styleguide/HTMXExtensions.swift @@ -0,0 +1,30 @@ +import Elementary +import ElementaryHTMX +import ManualDCore + +extension HTMLAttribute.hx { + @Sendable + public static func get(route: SiteRoute.View) -> HTMLAttribute { + get(SiteRoute.View.router.path(for: route)) + } + + @Sendable + public static func patch(route: SiteRoute.View) -> HTMLAttribute { + patch(SiteRoute.View.router.path(for: route)) + } + + @Sendable + public static func post(route: SiteRoute.View) -> HTMLAttribute { + post(SiteRoute.View.router.path(for: route)) + } + + @Sendable + public 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)) + // } +} diff --git a/Sources/Styleguide/Icon.swift b/Sources/Styleguide/Icon.swift new file mode 100644 index 0000000..f689fa1 --- /dev/null +++ b/Sources/Styleguide/Icon.swift @@ -0,0 +1,44 @@ +import Elementary + +public struct Icon: HTML, Sendable { + + let icon: String + + public init(icon: String) { + self.icon = icon + } + + public var body: some HTML { + i(.data("lucide", value: icon)) {} + } +} + +extension Icon { + + public init(_ icon: Key) { + self.init(icon: icon.icon) + } + + public enum Key: String { + + case circlePlus + case close + case doorClosed + case mapPin + case rulerDimensionLine + case squareFunction + case wind + + var icon: String { + switch self { + case .circlePlus: return "circle-plus" + case .close: return "x" + case .doorClosed: return "door-closed" + case .mapPin: return "map-pin" + case .rulerDimensionLine: return "ruler-dimension-line" + case .squareFunction: return "square-function" + case .wind: return rawValue + } + } + } +} diff --git a/Sources/Styleguide/Input.swift b/Sources/Styleguide/Input.swift new file mode 100644 index 0000000..c28fee0 --- /dev/null +++ b/Sources/Styleguide/Input.swift @@ -0,0 +1,45 @@ +import Elementary + +public struct Input: HTML, Sendable { + let id: String + let name: String? + let placeholder: String + + public init( + id: String, + name: String? = nil, + placeholder: String + ) { + self.id = id + self.name = name + self.placeholder = placeholder + } + + public var body: some HTML { + input( + .id(id), .name(name ?? id), .placeholder(placeholder), + .class( + """ + w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1 + -outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2 + focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500 + """ + ) + ) + } +} + +extension HTMLAttribute where Tag == HTMLTag.input { + + public static func max(_ value: String) -> Self { + .init(name: "max", value: value) + } + + public static func min(_ value: String) -> Self { + .init(name: "min", value: value) + } + + public static func step(_ value: String) -> Self { + .init(name: "step", value: value) + } +} diff --git a/Sources/Styleguide/Row.swift b/Sources/Styleguide/Row.swift new file mode 100644 index 0000000..5e9eabc --- /dev/null +++ b/Sources/Styleguide/Row.swift @@ -0,0 +1,18 @@ +import Elementary + +public struct Row: HTML, Sendable where T: Sendable { + + let inner: T + + public init( + @HTMLBuilder _ body: () -> T + ) { + self.inner = body() + } + + public var body: some HTML { + div(.class("flex justify-between")) { + inner + } + } +} diff --git a/Sources/Styleguide/SVG.swift b/Sources/Styleguide/SVG.swift new file mode 100644 index 0000000..510ca90 --- /dev/null +++ b/Sources/Styleguide/SVG.swift @@ -0,0 +1,29 @@ +import Elementary + +public struct SVG: HTML, Sendable { + + let key: Key + + public init(_ key: Key) { + self.key = key + } + + public var body: some HTML { + HTMLRaw(key.svg) + } +} + +extension SVG { + public enum Key: Sendable { + case close + + var svg: String { + switch self { + case .close: + return """ + + """ + } + } + } +} diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 5b7c2a6..7e77f08 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -37,8 +37,13 @@ extension SiteRoute.View.RoomRoute { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { switch self { case .form: + // TODO: Check that it's an htmx request. + return RoomForm() + case .index: return MainPage { - RoomTable(rooms: Room.mocks) + div { + RoomTable(rooms: Room.mocks) + } } } } diff --git a/Sources/ViewController/Views/Rooms/RoomForm.swift b/Sources/ViewController/Views/Rooms/RoomForm.swift index d439200..8003847 100644 --- a/Sources/ViewController/Views/Rooms/RoomForm.swift +++ b/Sources/ViewController/Views/Rooms/RoomForm.swift @@ -1,97 +1,63 @@ import Dependencies import Elementary +import ElementaryHTMX import Foundation import ManualDCore +import Styleguide // TODO: Need to hold the project ID in hidden input field. struct RoomForm: HTML, Sendable { var body: some HTML { - div(.class("mx-10 my-10")) { - h1(.class("text-3xl font-bold pb-6")) { "Rooms " } + div( + .class( + "fixed top-20 z-50 w-1/2 mx-[20vw] my-10 bg-gray-700 rounded-lg shadow-lg p-4" + ), + .id("roomForm") + ) { + h1(.class("text-3xl font-bold pb-6")) { "New Room" } form( .class( """ - grid md:grid-cols-3 gap-4 + space-y-4 """ ) ) { - div(.class("col-span-1")) { - div { - label(.for("name")) { "Name:" } - } - input( - .type(.text), .name("name"), .id("name"), .placeholder("Room Name"), .required, - .autofocus - ) + div { + label(.for("name")) { "Name:" } + Input(id: "name", placeholder: "Room Name") + .attributes(.type(.text), .required, .autofocus) } - div(.class("col-span-1")) { - div { - label(.for("heatingLoad")) { "Heating Load:" } - } - input( - .type(.number), .name("heatingLoad"), .id("heatingLoad"), .placeholder("Heating Load"), - .required - ) + div { + label(.for("heatingLoad")) { "Heating Load:" } + Input(id: "heatingLoad", placeholder: "Heating Load") + .attributes(.type(.number), .required, .min("0")) } - div(.class("col-span-1")) { - div { - label(.for("coolingLoad")) { "Cooling Load:" } - } - input( - .type(.number), .name("coolingLoad"), .id("coolingLoad"), .placeholder("Cooling Load"), - .required - ) + div { + label(.for("coolingLoad")) { "Cooling Load:" } + Input(id: "coolingLoad", placeholder: "Cooling Load") + .attributes(.type(.number), .required, .min("0")) } + div { + label(.for("registerCount")) { "Registers:" } + Input(id: "registerCount", placeholder: "Register Count") + .attributes(.type(.number), .required, .value("1"), .min("1")) + } + Row { + // Force button to the right, probably a better way. + div {} + div(.class("space-x-4")) { + CancelButton() + .attributes( + .hx.get(route: .room(.index)), + .hx.target("body"), + .hx.swap(.outerHTML) + ) + SubmitButton() + } + } + .attributes(.class("py-4")) } } } } - -struct RoomTable: HTML, Sendable { - let rooms: [Room] - - var body: some HTML { - div(.class("m-10")) { - h1(.class("text-3xl font-bold")) { "Rooms" } - table( - .id("rooms"), - .class( - "w-full border-collapse border border-gray-200 table-fixed" - ) - ) { - thead { tableHeader } - tbody { - Rows(rooms: rooms) - } - } - } - } - - private var tableHeader: some HTML { - tr { - th(.class("border border-gray-200 text-xl font-bold")) { "Name" } - th(.class("border border-gray-200 text-xl font-bold")) { "Heating Load" } - th(.class("border border-gray-200 text-xl font-bold")) { "Cooling Total" } - th(.class("border border-gray-200 text-xl font-bold")) { "Cooling Sensible" } - th(.class("border border-gray-200 text-xl font-bold")) { "Register Count" } - } - } - - private struct Rows: HTML, Sendable { - let rooms: [Room] - - var body: some HTML { - for room in rooms { - tr { - td(.class("border border-gray-200 p-2")) { room.name } - td(.class("border border-gray-200 p-2")) { "\(room.heatingLoad)" } - td(.class("border border-gray-200 p-2")) { "\(room.coolingLoad.total)" } - td(.class("border border-gray-200 p-2")) { "\(room.coolingLoad.sensible)" } - td(.class("border border-gray-200 p-2")) { "\(room.registerCount)" } - } - } - } - - } -} diff --git a/Sources/ViewController/Views/Rooms/RoomTable.swift b/Sources/ViewController/Views/Rooms/RoomTable.swift new file mode 100644 index 0000000..be8c6ab --- /dev/null +++ b/Sources/ViewController/Views/Rooms/RoomTable.swift @@ -0,0 +1,71 @@ +import Dependencies +import Elementary +import ElementaryHTMX +import Foundation +import ManualDCore +import Styleguide + +struct RoomTable: HTML, Sendable { + let rooms: [Room] + + var body: some HTML { + div(.class("m-10")) { + h1(.class("text-3xl font-bold")) { "Rooms" } + table( + .id("rooms"), + .class( + "w-full border-collapse border border-gray-200 table-fixed" + ) + ) { + thead { tableHeader } + tbody { + Rows(rooms: rooms) + } + } + div(.id("roomForm")) {} + } + } + + private var tableHeader: some HTML { + tr { + th(.class("border border-gray-200 text-xl font-bold")) { "Name" } + th(.class("border border-gray-200 text-xl font-bold")) { "Heating Load" } + th(.class("border border-gray-200 text-xl font-bold")) { "Cooling Total" } + th(.class("border border-gray-200 text-xl font-bold")) { "Cooling Sensible" } + th(.class("border border-gray-200 text-xl font-bold")) { "Register Count" } + th(.class("border border-gray-200 text-xl font-bold")) { + div(.class("flex justify-between")) { + div {} + button( + .class("px-2"), + .hx.get(route: .room(.form)), + .hx.target("#roomForm"), + .hx.swap(.outerHTML) + ) { + Icon(.circlePlus) + } + } + } + } + } + + private struct Rows: HTML, Sendable { + let rooms: [Room] + + var body: some HTML { + for room in rooms { + tr { + td(.class("border border-gray-200 p-2")) { room.name } + td(.class("border border-gray-200 p-2")) { "\(room.heatingLoad)" } + td(.class("border border-gray-200 p-2")) { "\(room.coolingLoad.total)" } + td(.class("border border-gray-200 p-2")) { "\(room.coolingLoad.sensible)" } + td(.class("border border-gray-200 p-2")) { "\(room.registerCount)" } + td(.class("border border-gray-200 p-2")) { + // TODO: Add edit button. + } + } + } + } + + } +} diff --git a/Sources/ViewController/Views/Sidebar.swift b/Sources/ViewController/Views/Sidebar.swift index e69add9..6c042ad 100644 --- a/Sources/ViewController/Views/Sidebar.swift +++ b/Sources/ViewController/Views/Sidebar.swift @@ -1,26 +1,28 @@ import Elementary +import Styleguide +// TODO: Need to add active to sidebar links. struct Sidebar: HTML { var body: some HTML { aside( .class( """ - h-screen sticky top-0 border-r-3 border-gray-800 bg-gray-100 shadow + h-screen sticky top-0 min-w-[280px] flex-none border border-r-3 border-gray-800 bg-gray-100 shadow """ ) ) { - row(title: "Project", icon: "map-pin", href: "/projects") - row(title: "Rooms", icon: "door-closed", href: "/rooms") - row(title: "Equivalent Lengths", icon: "ruler-dimension-line", href: "#") - row(title: "Friction Rate", icon: "square-function", href: "#") - row(title: "Duct Sizes", icon: "wind", href: "#") + row(title: "Project", icon: .mapPin, href: "/projects") + row(title: "Rooms", icon: .doorClosed, href: "/rooms") + row(title: "Equivalent Lengths", icon: .rulerDimensionLine, href: "#") + row(title: "Friction Rate", icon: .squareFunction, href: "#") + row(title: "Duct Sizes", icon: .wind, href: "#") } } private func row( title: String, - icon: String, + icon: Icon.Key, href: String ) -> some HTML { a( @@ -31,10 +33,8 @@ struct Sidebar: HTML { ), .href(href) ) { - i(.data("lucide", value: icon)) {} - p( - .class("text-xl font-bold") - ) { + Icon(icon) + span(.class("text-xl font-bold")) { title } } diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 6708c80..482bb5a 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM swift:6.2-noble +FROM docker.io/swift:6.2-noble # Make sure all system packages are up to date, and install only essential packages. RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ diff --git a/swift-dev b/swift-dev index badada6..73e4c3d 100755 --- a/swift-dev +++ b/swift-dev @@ -1,5 +1,5 @@ #!/usr/bin/env bash touch .build/browser-dev-sync -browser-sync start -p 0.0.0.0:8080 --ws --no-open & +# browser-sync start --proxy localhost:8080 --ws -w --no-notify & watchexec -w Sources -e .swift -r 'swift build --product App && touch .build/browser-dev-sync' & -watchexec -w .build/browser-dev-sync --ignore-nothing -r '.build/debug/App' +watchexec -w .build/browser-dev-sync -r 'swift run App'