From 6602c4a8b583dcf43ca9711889487d23429ab6d9 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 2 Jan 2026 17:00:50 -0500 Subject: [PATCH] WIP: Begins work on login / signup, adds user database models, authentication needs implemented. --- Public/css/output.css | 58 ++++ Sources/DatabaseClient/Interface.swift | 2 + Sources/DatabaseClient/Users.swift | 294 ++++++++++++++++++ Sources/ManualDCore/Routes/ViewRoute.swift | 75 +++++ Sources/ManualDCore/User.swift | 70 +++++ Sources/Styleguide/Input.swift | 28 +- Sources/Styleguide/SVG.swift | 50 +++ Sources/ViewController/Live.swift | 20 ++ Sources/ViewController/Views/MainPage.swift | 12 +- .../Views/Rooms/RoomsView.swift | 133 ++++---- Sources/ViewController/Views/Sidebar.swift | 4 +- .../ViewController/Views/User/LoginForm.swift | 158 ++++++++++ .../Views/User/SignupForm.swift | 0 13 files changed, 819 insertions(+), 85 deletions(-) create mode 100644 Sources/DatabaseClient/Users.swift create mode 100644 Sources/ManualDCore/User.swift create mode 100644 Sources/ViewController/Views/User/LoginForm.swift create mode 100644 Sources/ViewController/Views/User/SignupForm.swift 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