diff --git a/Public/css/output.css b/Public/css/output.css
index 0dd1f8b..6c486fa 100644
--- a/Public/css/output.css
+++ b/Public/css/output.css
@@ -6051,6 +6051,9 @@
}
}
}
+ .mt-4 {
+ margin-top: calc(var(--spacing) * 4);
+ }
.mt-8 {
margin-top: calc(var(--spacing) * 8);
}
@@ -6133,6 +6136,12 @@
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
+ .mb-4 {
+ margin-bottom: calc(var(--spacing) * 4);
+ }
+ .mb-6 {
+ margin-bottom: calc(var(--spacing) * 6);
+ }
.mb-8 {
margin-bottom: calc(var(--spacing) * 8);
}
@@ -6956,6 +6965,9 @@
.h-36 {
height: calc(var(--spacing) * 36);
}
+ .h-\[1em\] {
+ height: 1em;
+ }
.h-\[var\(--radix-select-trigger-height\)\] {
height: var(--radix-select-trigger-height);
}
@@ -7135,6 +7147,12 @@
.w-\[100px\] {
width: 100px;
}
+ .w-\[400px\] {
+ width: 400px;
+ }
+ .w-\[600px\] {
+ width: 600px;
+ }
.w-auto {
width: auto;
}
@@ -7147,6 +7165,12 @@
.w-screen {
width: 100vw;
}
+ .w-xl {
+ width: var(--container-xl);
+ }
+ .w-xs {
+ width: var(--container-xs);
+ }
.max-w-2xl {
max-width: var(--container-2xl);
}
@@ -7872,6 +7896,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+ .overflow-x-auto {
+ overflow-x: auto;
+ }
.overflow-x-hidden {
overflow-x: hidden;
}
@@ -8223,6 +8250,10 @@
border-style: var(--tw-border-style);
border-width: 0px;
}
+ .border-1 {
+ border-style: var(--tw-border-style);
+ border-width: 1px;
+ }
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
@@ -8399,6 +8430,18 @@
.border-\[\#fbf0df\] {
border-color: #fbf0df;
}
+ .border-base-300 {
+ border-color: var(--color-base-300);
+ }
+ .border-base-content\/5 {
+ border-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
+ }
+ }
+ .border-gray-100 {
+ border-color: var(--color-gray-100);
+ }
.border-gray-200 {
border-color: var(--color-gray-200);
}
@@ -8565,6 +8608,12 @@
.bg-\[\#fbf0df\] {
background-color: #fbf0df;
}
+ .bg-base-100 {
+ background-color: var(--color-base-100);
+ }
+ .bg-base-200 {
+ background-color: var(--color-base-200);
+ }
.bg-blue-400 {
background-color: var(--color-blue-400);
}
@@ -10168,6 +10217,9 @@
.text-blue-400 {
color: var(--color-blue-400);
}
+ .text-error {
+ color: var(--color-error);
+ }
.text-gray-400 {
color: var(--color-gray-400);
}
@@ -10177,6 +10229,9 @@
.text-green-400 {
color: var(--color-green-400);
}
+ .text-info {
+ color: var(--color-info);
+ }
.text-primary {
color: var(--color-primary);
}
@@ -10186,6 +10241,9 @@
.text-slate-900 {
color: var(--color-slate-900);
}
+ .text-success {
+ color: var(--color-success);
+ }
.text-white {
color: var(--color-white);
}
diff --git a/Sources/DatabaseClient/Interface.swift b/Sources/DatabaseClient/Interface.swift
index afff9cf..4c409d7 100644
--- a/Sources/DatabaseClient/Interface.swift
+++ b/Sources/DatabaseClient/Interface.swift
@@ -57,6 +57,8 @@ extension DatabaseClient.Migrations: DependencyKey {
public static let liveValue = Self(
run: {
[
+ User.Migrate(),
+ User.Token.Migrate(),
Project.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
diff --git a/Sources/DatabaseClient/Users.swift b/Sources/DatabaseClient/Users.swift
new file mode 100644
index 0000000..e29c20d
--- /dev/null
+++ b/Sources/DatabaseClient/Users.swift
@@ -0,0 +1,294 @@
+import Dependencies
+import DependenciesMacros
+import Fluent
+import ManualDCore
+import Vapor
+
+extension DatabaseClient {
+ @DependencyClient
+ public struct Users: Sendable {
+ public var create: @Sendable (User.Create) async throws -> User
+ public var delete: @Sendable (User.ID) async throws -> Void
+ public var get: @Sendable (User.ID) async throws -> User?
+ public var login: @Sendable (User.Login) async throws -> User.Token
+ public var logout: @Sendable (User.Token.ID) async throws -> Void
+ // public var token: @Sendable (User.ID) async throws -> User.Token
+ }
+
+}
+
+extension DatabaseClient.Users: TestDependencyKey {
+ public static let testValue = Self()
+
+ public static func live(database: any Database) -> Self {
+ .init(
+ create: { request in
+ let model = try request.toModel()
+ try await model.save(on: database)
+ return try model.toDTO()
+ },
+ delete: { id in
+ guard let model = try await UserModel.find(id, on: database) else {
+ throw NotFoundError()
+ }
+ try await model.delete(on: database)
+ },
+ get: { id in
+ try await UserModel.find(id, on: database).map { try $0.toDTO() }
+ },
+ login: { request in
+ guard
+ let user = try await UserModel.query(on: database)
+ .with(\.$token)
+ .filter(\UserModel.$email == request.email)
+ .first()
+ else {
+ throw NotFoundError()
+ }
+
+ let token: User.Token
+
+ // Check if there's a user token
+ if let userToken = user.token {
+ token = try userToken.toDTO()
+ } else {
+ // generate a new token
+ let tokenModel = try user.generateToken()
+ try await tokenModel.save(on: database)
+ token = try tokenModel.toDTO()
+ }
+
+ return token
+
+ },
+ logout: { tokenID in
+ guard let token = try await UserTokenModel.find(tokenID, on: database) else { return }
+ try await token.delete(on: database)
+ }
+ // ,
+ // token: { id in
+ // }
+
+ )
+ }
+}
+
+extension User {
+ struct Migrate: AsyncMigration {
+ let name = "CreateUser"
+
+ func prepare(on database: any Database) async throws {
+ try await database.schema(UserModel.schema)
+ .id()
+ .field("username", .string, .required)
+ .field("email", .string, .required)
+ .field("password_hash", .string, .required)
+ .field("created_at", .datetime)
+ .field("updated_at", .datetime)
+ .unique(on: "email", "username")
+ .create()
+ }
+
+ func revert(on database: any Database) async throws {
+ try await database.schema(UserModel.schema).delete()
+ }
+ }
+}
+
+extension User.Token {
+ struct Migrate: AsyncMigration {
+ let name = "CreateUserToken"
+
+ func prepare(on database: any Database) async throws {
+ try await database.schema(UserTokenModel.schema)
+ .id()
+ .field("value", .string, .required)
+ .field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
+ .unique(on: "value")
+ .create()
+ }
+
+ func revert(on database: any Database) async throws {
+ try await database.schema(UserTokenModel.schema).delete()
+ }
+ }
+}
+
+extension User {
+
+ static func hashPassword(_ password: String) throws -> String {
+ try Bcrypt.hash(password, cost: 12)
+ }
+
+}
+
+extension User.Create {
+
+ func toModel() throws -> UserModel {
+ try validate()
+ return try .init(username: username, email: email, passwordHash: User.hashPassword(password))
+ }
+
+ func validate() throws {
+ guard !username.isEmpty else {
+ throw ValidationError("Username should not be empty.")
+ }
+ guard !email.isEmpty else {
+ throw ValidationError("Email should not be empty")
+ }
+ guard password.count > 8 else {
+ throw ValidationError("Password should be more than 8 characters long.")
+ }
+ guard password == confirmPassword else {
+ throw ValidationError("Passwords do not match.")
+ }
+ }
+}
+
+final class UserModel: Model, @unchecked Sendable {
+
+ static let schema = "user"
+
+ @ID(key: .id)
+ var id: UUID?
+
+ @Field(key: "username")
+ var username: String
+
+ @Field(key: "email")
+ var email: String
+
+ @Field(key: "password_hash")
+ var passwordHash: String
+
+ @Timestamp(key: "createdAt", on: .create, format: .iso8601)
+ var createdAt: Date?
+
+ @Timestamp(key: "updatedAt", on: .update, format: .iso8601)
+ var updatedAt: Date?
+
+ @OptionalChild(for: \.$user)
+ var token: UserTokenModel?
+
+ init() {}
+
+ init(
+ id: UUID? = nil,
+ username: String,
+ email: String,
+ passwordHash: String
+ ) {
+ self.id = id
+ self.username = username
+ self.email = email
+ self.passwordHash = passwordHash
+ }
+
+ func toDTO() throws -> User {
+ try .init(
+ id: requireID(),
+ email: email,
+ username: username,
+ createdAt: createdAt!,
+ updatedAt: updatedAt!
+ )
+ }
+
+ func generateToken() throws -> UserTokenModel {
+ try .init(
+ value: [UInt8].random(count: 16).base64,
+ userID: requireID()
+ )
+ }
+
+ func verifyPassword(_ password: String) throws -> Bool {
+ try Bcrypt.verify(password, created: passwordHash)
+ }
+}
+
+final class UserTokenModel: Model, Codable, @unchecked Sendable {
+
+ static let schema = "user_token"
+
+ @ID(key: .id)
+ var id: UUID?
+
+ @Field(key: "value")
+ var value: String
+
+ @Parent(key: "user_id")
+ var user: UserModel
+
+ init() {}
+
+ init(id: UUID? = nil, value: String, userID: UserModel.IDValue) {
+ self.id = id
+ self.value = value
+ $user.id = userID
+ }
+
+ func toDTO() throws -> User.Token {
+ try .init(id: requireID(), userID: $user.id, value: value)
+ }
+
+}
+
+// MARK: - Authentication
+
+extension User: Authenticatable {}
+extension User: SessionAuthenticatable {
+ public var sessionID: String { username }
+}
+
+public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
+ public typealias User = ManualDCore.User
+
+ public init() {}
+
+ public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
+ guard
+ let user = try await UserModel.query(on: request.db)
+ .filter(\UserModel.$username == basic.username)
+ .first(),
+ try user.verifyPassword(basic.password)
+ else {
+ throw Abort(.unauthorized)
+ }
+ try request.auth.login(user.toDTO())
+ }
+}
+
+public struct UserTokenAuthenticator: AsyncBearerAuthenticator {
+ public typealias User = ManualDCore.User
+
+ public init() {}
+
+ public func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
+ guard
+ let token = try await UserTokenModel.query(on: request.db)
+ .filter(\UserTokenModel.$value == bearer.token)
+ .with(\UserTokenModel.$user)
+ .first()
+ else {
+ throw Abort(.unauthorized)
+ }
+ try request.auth.login(token.user.toDTO())
+ }
+}
+
+public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
+ public typealias User = ManualDCore.User
+
+ public init() {}
+
+ public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
+ guard
+ let user = try await UserModel.query(on: request.db)
+ .filter(\UserModel.$username == sessionID)
+ .first()
+ else {
+ throw Abort(.unauthorized)
+ }
+ try request.auth.login(user.toDTO())
+ }
+}
diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift
index e36de9a..050ff4e 100644
--- a/Sources/ManualDCore/Routes/ViewRoute.swift
+++ b/Sources/ManualDCore/Routes/ViewRoute.swift
@@ -11,6 +11,7 @@ extension SiteRoute {
case room(RoomRoute)
case frictionRate(FrictionRateRoute)
case effectiveLength(EffectiveLengthRoute)
+ case user(UserRoute)
public static let router = OneOf {
Route(.case(Self.project)) {
@@ -25,6 +26,9 @@ extension SiteRoute {
Route(.case(Self.effectiveLength)) {
SiteRoute.View.EffectiveLengthRoute.router
}
+ Route(.case(Self.user)) {
+ SiteRoute.View.UserRoute.router
+ }
}
}
}
@@ -173,3 +177,74 @@ extension SiteRoute.View.EffectiveLengthRoute {
case group
}
}
+
+extension SiteRoute.View {
+ public enum UserRoute: Equatable, Sendable {
+ case login(Login)
+ case signup(Signup)
+
+ public static let router = OneOf {
+ Route(.case(Self.login)) {
+ SiteRoute.View.UserRoute.Login.router
+ }
+ Route(.case(Self.signup)) {
+ SiteRoute.View.UserRoute.Signup.router
+ }
+ }
+ }
+}
+
+extension SiteRoute.View.UserRoute {
+
+ public enum Login: Equatable, Sendable {
+ case index
+ case submit(User.Login)
+
+ static let rootPath = "login"
+
+ public static let router = OneOf {
+ Route(.case(Self.index)) {
+ Path { rootPath }
+ Method.get
+ }
+ Route(.case(Self.submit)) {
+ Path { rootPath }
+ Method.post
+ Body {
+ FormData {
+ Field("email", .string)
+ Field("password", .string)
+ }
+ .map(.memberwise(User.Login.init))
+ }
+ }
+ }
+ }
+
+ public enum Signup: Equatable, Sendable {
+ case index
+ case submit(User.Create)
+
+ static let rootPath = "signup"
+
+ public static let router = OneOf {
+ Route(.case(Self.index)) {
+ Path { rootPath }
+ Method.get
+ }
+ Route(.case(Self.submit)) {
+ Path { rootPath }
+ Method.post
+ Body {
+ FormData {
+ Field("username", .string)
+ Field("email", .string)
+ Field("password", .string)
+ Field("confirmPassword", .string)
+ }
+ .map(.memberwise(User.Create.init))
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/ManualDCore/User.swift b/Sources/ManualDCore/User.swift
new file mode 100644
index 0000000..bf73f23
--- /dev/null
+++ b/Sources/ManualDCore/User.swift
@@ -0,0 +1,70 @@
+import Dependencies
+import Foundation
+
+public struct User: Codable, Equatable, Identifiable, Sendable {
+
+ public let id: UUID
+ public let email: String
+ public let username: String
+ public let createdAt: Date
+ public let updatedAt: Date
+
+ public init(
+ id: UUID,
+ email: String,
+ username: String,
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.username = username
+ self.email = email
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+}
+
+extension User {
+ public struct Create: Codable, Equatable, Sendable {
+
+ public let username: String
+ public let email: String
+ public let password: String
+ public let confirmPassword: String
+
+ public init(
+ username: String,
+ email: String,
+ password: String,
+ confirmPassword: String
+ ) {
+ self.username = username
+ self.email = email
+ self.password = password
+ self.confirmPassword = confirmPassword
+ }
+ }
+
+ public struct Login: Codable, Equatable, Sendable {
+ public let email: String
+ public let password: String
+
+ public init(email: String, password: String) {
+ self.email = email
+ self.password = password
+ }
+ }
+
+ public struct Token: Codable, Equatable, Identifiable, Sendable {
+
+ public let id: UUID
+ public let userID: User.ID
+ public let value: String
+
+ public init(id: UUID, userID: User.ID, value: String) {
+ self.id = id
+ self.userID = userID
+ self.value = value
+ }
+ }
+}
diff --git a/Sources/Styleguide/Input.swift b/Sources/Styleguide/Input.swift
index 4be20ba..9979313 100644
--- a/Sources/Styleguide/Input.swift
+++ b/Sources/Styleguide/Input.swift
@@ -45,7 +45,7 @@ public struct Input: HTML, Sendable {
.id(id ?? ""), .name(_name), .placeholder(placeholder),
.class(
"""
- w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
+ input 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
"""
@@ -67,4 +67,30 @@ extension HTMLAttribute where Tag == HTMLTag.input {
public static func step(_ value: String) -> Self {
.init(name: "step", value: value)
}
+
+ public static func minlength(_ value: String) -> Self {
+ .init(name: "minlength", value: value)
+ }
+
+ public static func pattern(value: String) -> Self {
+ .init(name: "pattern", value: value)
+ }
+
+ public static func pattern(_ type: PatternType) -> Self {
+ pattern(value: type.value)
+ }
+}
+
+public enum PatternType: Sendable {
+ case password
+ case username
+
+ var value: String {
+ switch self {
+ case .password:
+ return "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
+ case .username:
+ return "[A-Za-z][A-Za-z0-9\\-]*"
+ }
+ }
}
diff --git a/Sources/Styleguide/SVG.swift b/Sources/Styleguide/SVG.swift
index 22ff7f6..fae90da 100644
--- a/Sources/Styleguide/SVG.swift
+++ b/Sources/Styleguide/SVG.swift
@@ -17,7 +17,10 @@ extension SVG {
public enum Key: Sendable {
case circlePlus
case close
+ case email
+ case key
case squarePen
+ case user
var svg: String {
switch self {
@@ -29,10 +32,57 @@ extension SVG {
return """
"""
+ case .email:
+ return """
+
+ """
+ case .key:
+ return """
+
+ """
case .squarePen:
return """
"""
+ case .user:
+ return """
+
+ """
}
}
}
diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift
index e03ce56..9711c3b 100644
--- a/Sources/ViewController/Live.swift
+++ b/Sources/ViewController/Live.swift
@@ -13,6 +13,8 @@ extension ViewController.Request {
return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .effectiveLength(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
+ case .user(let route):
+ return try await route.renderView(isHtmxRequest: isHtmxRequest)
default:
// FIX: FIX
return mainPage
@@ -101,6 +103,24 @@ extension SiteRoute.View.EffectiveLengthRoute {
}
}
+extension SiteRoute.View.UserRoute {
+
+ func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
+ switch self {
+ case .login(.index):
+ return MainPage(active: .projects, showSidebar: false) {
+ LoginForm()
+ }
+ case .signup(.index):
+ return MainPage(active: .projects, showSidebar: false) {
+ LoginForm(style: .signup)
+ }
+ default:
+ return div { "Fix Me!" }
+ }
+ }
+}
+
private let mainPage: AnySendableHTML = {
MainPage(active: .projects) {
div {
diff --git a/Sources/ViewController/Views/MainPage.swift b/Sources/ViewController/Views/MainPage.swift
index a5a9ee8..a557305 100644
--- a/Sources/ViewController/Views/MainPage.swift
+++ b/Sources/ViewController/Views/MainPage.swift
@@ -5,9 +5,15 @@ public struct MainPage: SendableHTMLDocument where Inner: Sendable
public var lang: String { "en" }
let inner: Inner
let activeTab: Sidebar.ActiveTab
+ let showSidebar: Bool
- init(active activeTab: Sidebar.ActiveTab, _ inner: () -> Inner) {
+ init(
+ active activeTab: Sidebar.ActiveTab,
+ showSidebar: Bool = true,
+ _ inner: () -> Inner
+ ) {
self.activeTab = activeTab
+ self.showSidebar = showSidebar
self.inner = inner()
}
@@ -24,7 +30,9 @@ public struct MainPage: SendableHTMLDocument where Inner: Sendable
// div(.class("bg-white dark:bg-gray-800 dark:text-white")) {
div {
div(.class("flex flex-row")) {
- Sidebar(active: activeTab)
+ if showSidebar {
+ Sidebar(active: activeTab)
+ }
main(.class("flex flex-col h-screen w-full px-6 py-10")) {
inner
}
diff --git a/Sources/ViewController/Views/Rooms/RoomsView.swift b/Sources/ViewController/Views/Rooms/RoomsView.swift
index 0991391..215729c 100644
--- a/Sources/ViewController/Views/Rooms/RoomsView.swift
+++ b/Sources/ViewController/Views/Rooms/RoomsView.swift
@@ -9,9 +9,9 @@ struct RoomsView: HTML, Sendable {
let rooms: [Room]
var body: some HTML {
- div(.class("m-10")) {
+ div {
Row {
- h1(.class("text-3xl font-bold pb-6")) { "Room Loads" }
+ h1(.class("text-2xl font-bold")) { "Room Loads" }
div(
.class("tooltip tooltip-left"),
.data("tip", value: "Add room")
@@ -20,94 +20,67 @@ struct RoomsView: HTML, Sendable {
.hx.get(route: .room(.form(dismiss: false))),
.hx.target("#roomForm"),
.hx.swap(.outerHTML),
- .class("btn btn-primary w-[40px]")
+ .class("btn btn-primary w-[40px] text-2xl")
) {
"+"
}
}
}
+ .attributes(.class("pb-6"))
- div(
- .id("roomTable"),
- .class(
- """
- border border-gray-200 rounded-lg shadow-lg
- grid grid-cols-5 p-4
- """
- )
- ) {
- // Header
- Label("Name")
- // Pushes items to right
- Row {
- div {}
- Label("Heating Load")
- }
- Row {
- div {}
- Label("Cooling Total")
- }
- Row {
- div {}
- Label("Cooling Sensible")
- }
- Row {
- div {}
- Label("Register Count")
- }
-
- // Divider
- div(.class("border-b border-gray-200 col-span-5 mb-2")) {}
-
- // Rows
- for row in rooms {
- span { row.name }
- // Pushes items to right
- Row {
- div {}
- Number(row.heatingLoad)
- .attributes(.class("text-red-500"))
+ div(.class("overflow-x-auto rounded-box border")) {
+ table(.class("table table-zebra"), .id("roomsTable")) {
+ thead {
+ tr {
+ th { Label("Name") }
+ th { Label("Heating Load") }
+ th { Label("Cooling Total") }
+ th { Label("Cooling Sensible") }
+ th { Label("Register Count") }
+ }
}
- Row {
- div {}
- Number(row.coolingLoad.total)
- .attributes(.class("text-green-400"))
+ tbody {
+ for room in rooms {
+ tr {
+ td { room.name }
+ td {
+ Number(room.heatingLoad)
+ .attributes(.class("text-error"))
+ }
+ td {
+ Number(room.coolingLoad.total)
+ .attributes(.class("text-success"))
+ }
+ td {
+ Number(room.coolingLoad.sensible)
+ .attributes(.class("text-info"))
+ }
+ td {
+ Number(room.registerCount)
+ }
+ }
+ }
+ // TOTALS
+ tr(.class("font-bold text-xl")) {
+ td { Label("Total") }
+ td {
+ Number(rooms.heatingTotal)
+ .attributes(.class("badge badge-outline badge-error badge-xl"))
+ }
+ td {
+ Number(rooms.coolingTotal)
+ .attributes(
+ .class("badge badge-outline badge-success badge-xl"))
+ }
+ td {
+ Number(rooms.coolingSensibleTotal)
+ .attributes(.class("badge badge-outline badge-info badge-xl"))
+ }
+ td {}
+ }
}
- Row {
- div {}
- Number(row.coolingLoad.sensible)
- .attributes(.class("text-blue-400"))
- }
- Row {
- div {}
- Number(row.registerCount)
- }
-
- // Divider
- div(.class("border-b border-gray-200 col-span-5 mb-2")) {}
}
-
- // Totals
- Label("Total")
- Row {
- div {}
- Number(rooms.heatingTotal)
- .attributes(.class("badge badge-outline badge-error badge-xl text-xl font-bold"))
- }
- Row {
- div {}
- Number(rooms.coolingTotal)
- .attributes(.class("badge badge-outline badge-success badge-xl text-xl font-bold"))
- }
- Row {
- div {}
- Number(rooms.coolingSensibleTotal)
- .attributes(.class("badge badge-outline badge-info badge-xl text-xl font-bold"))
- }
- // Empty register count column
- div {}
}
-
RoomForm(dismiss: true)
}
}
diff --git a/Sources/ViewController/Views/Sidebar.swift b/Sources/ViewController/Views/Sidebar.swift
index d59f9b2..009df9e 100644
--- a/Sources/ViewController/Views/Sidebar.swift
+++ b/Sources/ViewController/Views/Sidebar.swift
@@ -2,7 +2,7 @@ import Elementary
import ManualDCore
import Styleguide
-// TODO: Need to add active to sidebar links.
+// TODO: Update to use DaisyUI drawer.
struct Sidebar: HTML {
let active: ActiveTab
@@ -23,7 +23,7 @@ struct Sidebar: HTML {
Label("Theme")
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
}
- .attributes(.class("py-4"))
+ .attributes(.class("p-4"))
row(title: "Project", icon: .mapPin, route: .project(.index))
.attributes(.data("active", value: active == .projects ? "true" : "false"))
diff --git a/Sources/ViewController/Views/User/LoginForm.swift b/Sources/ViewController/Views/User/LoginForm.swift
new file mode 100644
index 0000000..1716a95
--- /dev/null
+++ b/Sources/ViewController/Views/User/LoginForm.swift
@@ -0,0 +1,158 @@
+import Elementary
+import ElementaryHTMX
+import Styleguide
+
+struct LoginForm: HTML, Sendable {
+
+ let style: Style
+
+ init(style: Style = .login) {
+ self.style = style
+ }
+
+ var body: some HTML {
+ form {
+ fieldset(.class("fieldset bg-base-200 border-base-300 rounded-box w-xl border p-4")) {
+ legend(.class("fieldset-legend")) { style.title }
+
+ if style == .signup {
+ label(.class("input validator w-full")) {
+ SVG(.user)
+ input(
+ .type(.text), .required, .placeholder("Username"),
+ .minlength("3"), .pattern(.username)
+ )
+ }
+ div(.class("validator-hint hidden")) {
+ "Enter valid username"
+ br()
+ "Must be at least 3 characters"
+ }
+ }
+
+ label(.class("input validator w-full")) {
+ SVG(.email)
+ input(
+ .type(.email), .placeholder("Email"), .required
+ )
+ }
+ div(.class("validator-hint hidden")) { "Enter valid email address." }
+
+ label(.class("input validator w-full")) {
+ SVG(.key)
+ input(
+ .type(.password), .placeholder("Password"), .required,
+ .pattern(.password), .minlength("8")
+ )
+ }
+
+ if style == .signup {
+ label(.class("input validator w-full")) {
+ SVG(.key)
+ input(
+ .type(.password), .placeholder("Confirm Password"), .required,
+ .pattern(.password), .minlength("8")
+ )
+ }
+ }
+
+ div(.class("validator-hint hidden")) {
+ p {
+ "Must be more than 8 characters, including"
+ br()
+ "At least one number"
+ br()
+ "At least one lowercase letter"
+ br()
+ "At least one uppercase letter"
+ }
+ }
+
+ button(.class("btn btn-neutral mt-4")) { style.title }
+ }
+ }
+ // div(.class("flex items-center justify-center")) {
+ // div(.class("w-full mx-auto")) {
+ // h1(.class("text-2xl font-bold")) { style.title }
+ // form(
+ // .class("w-full h-screen")
+ // ) {
+ // fieldset(.class("fieldset w-full")) {
+ // legend(.class("fieldset-legend")) { "Email" }
+ // label(.class("input validator")) {
+ // SVG(.email)
+ // input(
+ // .type(.email), .placeholder("mail@site.com"), .required,
+ // .autofocus
+ // )
+ // }
+ // div(.class("validator-hint hidden")) { "Enter valid email address." }
+ // }
+ //
+ // if style == .signup {
+ // fieldset(.class("fieldset")) {
+ // legend(.class("fieldset-legend")) { "Name" }
+ // label(.class("input validator")) {
+ // input(
+ // .type(.text), .placeholder("Username"), .required,
+ // .init(name: "pattern", value: "[A-Za-z][A-Za-z0-9\\-]*"),
+ // .init(name: "minlength", value: "3")
+ // )
+ // }
+ // div(.class("validator-hint hidden")) { "Enter valid email address." }
+ // }
+ // }
+ //
+ // fieldset(.class("fieldset")) {
+ // legend(.class("fieldset-legend")) { "Password" }
+ // label(.class("input validator")) {
+ // SVG(.key)
+ // input(
+ // .type(.password), .placeholder("Password"), .required,
+ // .init(name: "pattern", value: "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"),
+ // .init(name: "minlength", value: "8")
+ // )
+ // }
+ // if style == .signup {
+ // label(.class("input validator")) {
+ // SVG(.key)
+ // input(
+ // .type(.password), .placeholder("Confirm Password"), .required,
+ // .init(name: "pattern", value: "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"),
+ // .init(name: "minlength", value: "8")
+ // )
+ // }
+ // }
+ // div(.class("validator-hint hidden")) {
+ // p {
+ // "Must be more than 8 characters, including"
+ // br()
+ // "At least one number"
+ // br()
+ // "At least one lowercase letter"
+ // br()
+ // "At least one uppercase letter"
+ // }
+ // }
+ // }
+ //
+ // SubmitButton(title: style.title)
+ // }
+ // }
+ // }
+ }
+}
+
+extension LoginForm {
+ enum Style: Equatable, Sendable {
+ case login
+ case signup
+
+ var title: String {
+ switch self {
+ case .login: return "Login"
+ case .signup: return "Sign Up"
+ }
+ }
+ }
+}
diff --git a/Sources/ViewController/Views/User/SignupForm.swift b/Sources/ViewController/Views/User/SignupForm.swift
new file mode 100644
index 0000000..e69de29