diff --git a/Public/css/output.css b/Public/css/output.css index 5f9f61f..1ca9a8c 100644 --- a/Public/css/output.css +++ b/Public/css/output.css @@ -5220,6 +5220,9 @@ } } } + .m-1 { + margin: calc(var(--spacing) * 1); + } .m-4 { margin: calc(var(--spacing) * 4); } @@ -6371,10 +6374,6 @@ height: var(--size); } } - .size-4 { - width: calc(var(--spacing) * 4); - height: calc(var(--spacing) * 4); - } .size-7 { width: calc(var(--spacing) * 7); height: calc(var(--spacing) * 7); @@ -6796,6 +6795,9 @@ .justify-end { justify-content: flex-end; } + .justify-start { + justify-content: flex-start; + } .gap-1 { gap: calc(var(--spacing) * 1); } @@ -7401,9 +7403,6 @@ .bg-base-100 { background-color: var(--color-base-100); } - .bg-base-200 { - background-color: var(--color-base-200); - } .bg-base-300 { background-color: var(--color-base-300); } @@ -8571,6 +8570,10 @@ .opacity-50 { opacity: 50%; } + .shadow-2xl { + --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } .shadow-lg { --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); @@ -9512,90 +9515,6 @@ display: table-cell; } } - .is-drawer-close\:tooltip { - &:where(.drawer-toggle:not(:checked) ~ .drawer-side, .drawer-toggle:not(:checked) ~ .drawer-side *) { - @layer daisyui.l1.l2.l3 { - position: relative; - display: inline-block; - --tt-bg: var(--color-neutral); - --tt-off: calc(100% + 0.5rem); - --tt-tail: calc(100% + 1px + 0.25rem); - & > .tooltip-content, &[data-tip]:before { - position: absolute; - max-width: 20rem; - border-radius: var(--radius-field); - padding-inline: calc(0.25rem * 2); - padding-block: calc(0.25rem * 1); - text-align: center; - white-space: normal; - color: var(--color-neutral-content); - opacity: 0%; - font-size: 0.875rem; - line-height: 1.25; - background-color: var(--tt-bg); - width: max-content; - pointer-events: none; - z-index: 2; - --tw-content: attr(data-tip); - content: var(--tw-content); - } - &:after { - opacity: 0%; - background-color: var(--tt-bg); - content: ""; - pointer-events: none; - width: 0.625rem; - height: 0.25rem; - display: block; - position: absolute; - mask-repeat: no-repeat; - mask-position: -1px 0; - --mask-tooltip: url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A"); - mask-image: var(--mask-tooltip); - } - @media (prefers-reduced-motion: no-preference) { - & > .tooltip-content, &[data-tip]:before, &:after { - transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms; - } - } - &:is([data-tip]:not([data-tip=""]), :has(.tooltip-content:not(:empty))) { - &.tooltip-open, &:hover, &:has(:focus-visible) { - & > .tooltip-content, &[data-tip]:before, &:after { - opacity: 100%; - --tt-pos: 0rem; - @media (prefers-reduced-motion: no-preference) { - transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s; - } - } - } - } - } - @layer daisyui.l1.l2 { - > .tooltip-content, &[data-tip]:before { - transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem)); - inset: auto auto var(--tt-off) 50%; - } - &:after { - transform: translateX(-50%) translateY(var(--tt-pos, 0.25rem)); - inset: auto auto var(--tt-tail) 50%; - } - } - } - } - .is-drawer-close\:tooltip-right { - &:where(.drawer-toggle:not(:checked) ~ .drawer-side, .drawer-toggle:not(:checked) ~ .drawer-side *) { - @layer daisyui.l1.l2 { - > .tooltip-content, &[data-tip]:before { - transform: translateX(calc(var(--tt-pos, -0.25rem) + 0.25rem)) translateY(-50%); - inset: 50% auto auto var(--tt-off); - } - &:after { - transform: translateX(var(--tt-pos, -0.25rem)) translateY(-50%) rotate(90deg); - inset: 50% auto auto calc(var(--tt-tail) + 1px); - } - } - } - } .is-drawer-close\:mx-auto { &:where(.drawer-toggle:not(:checked) ~ .drawer-side, .drawer-toggle:not(:checked) ~ .drawer-side *) { margin-inline: auto; @@ -9606,11 +9525,6 @@ display: none; } } - .is-drawer-close\:w-14 { - &:where(.drawer-toggle:not(:checked) ~ .drawer-side, .drawer-toggle:not(:checked) ~ .drawer-side *) { - width: calc(var(--spacing) * 14); - } - } .is-drawer-close\:min-w-\[80px\] { &:where(.drawer-toggle:not(:checked) ~ .drawer-side, .drawer-toggle:not(:checked) ~ .drawer-side *) { min-width: 80px; @@ -9655,11 +9569,6 @@ display: flex; } } - .is-drawer-open\:w-64 { - &:where(.drawer-toggle:checked ~ .drawer-side, .drawer-toggle:checked ~ .drawer-side *) { - width: calc(var(--spacing) * 64); - } - } .is-drawer-open\:max-w-\[300px\] { &:where(.drawer-toggle:checked ~ .drawer-side, .drawer-toggle:checked ~ .drawer-side *) { max-width: 300px; diff --git a/Sources/App/Middleware/ViewRoute+middleware.swift b/Sources/App/Middleware/ViewRoute+middleware.swift index 0155e59..c3cbadd 100644 --- a/Sources/App/Middleware/ViewRoute+middleware.swift +++ b/Sources/App/Middleware/ViewRoute+middleware.swift @@ -14,7 +14,7 @@ private let viewRouteMiddleware: [any Middleware] = [ extension SiteRoute.View { var middleware: [any Middleware]? { switch self { - case .project: + case .project, .user: return viewRouteMiddleware case .login, .signup, .test: return nil diff --git a/Sources/DatabaseClient/UserProfile.swift b/Sources/DatabaseClient/UserProfile.swift index d0ad378..3ef5579 100644 --- a/Sources/DatabaseClient/UserProfile.swift +++ b/Sources/DatabaseClient/UserProfile.swift @@ -60,10 +60,35 @@ extension User.Profile.Create { guard !lastName.isEmpty else { throw ValidationError("User last name should not be empty.") } + guard !companyName.isEmpty else { + throw ValidationError("User company name should not be empty.") + } + guard !streetAddress.isEmpty else { + throw ValidationError("User street address should not be empty.") + } + guard !city.isEmpty else { + throw ValidationError("User city should not be empty.") + } + guard !state.isEmpty else { + throw ValidationError("User state should not be empty.") + } + guard !zipCode.isEmpty else { + throw ValidationError("User zip code should not be empty.") + } } func toModel() -> UserProfileModel { - .init(userID: userID, firstName: firstName, lastName: lastName, theme: theme) + .init( + userID: userID, + firstName: firstName, + lastName: lastName, + companyName: companyName, + streetAddress: streetAddress, + city: city, + state: state, + zipCode: zipCode, + theme: theme + ) } } @@ -80,6 +105,31 @@ extension User.Profile.Update { throw ValidationError("User last name should not be empty.") } } + if let companyName { + guard !companyName.isEmpty else { + throw ValidationError("User company name should not be empty.") + } + } + if let streetAddress { + guard !streetAddress.isEmpty else { + throw ValidationError("User street address should not be empty.") + } + } + if let city { + guard !city.isEmpty else { + throw ValidationError("User city should not be empty.") + } + } + if let state { + guard !state.isEmpty else { + throw ValidationError("User state should not be empty.") + } + } + if let zipCode { + guard !zipCode.isEmpty else { + throw ValidationError("User zip code should not be empty.") + } + } } } @@ -93,6 +143,11 @@ extension User.Profile { .id() .field("firstName", .string, .required) .field("lastName", .string, .required) + .field("companyName", .string, .required) + .field("streetAddress", .string, .required) + .field("city", .string, .required) + .field("state", .string, .required) + .field("zipCode", .string, .required) .field("theme", .string) .field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade)) .field("createdAt", .datetime) @@ -123,6 +178,21 @@ final class UserProfileModel: Model, @unchecked Sendable { @Field(key: "lastName") var lastName: String + @Field(key: "companyName") + var companyName: String + + @Field(key: "streetAddress") + var streetAddress: String + + @Field(key: "city") + var city: String + + @Field(key: "state") + var state: String + + @Field(key: "zipCode") + var zipCode: String + @Field(key: "theme") var theme: String? @@ -139,12 +209,22 @@ final class UserProfileModel: Model, @unchecked Sendable { userID: User.ID, firstName: String, lastName: String, + companyName: String, + streetAddress: String, + city: String, + state: String, + zipCode: String, theme: Theme? = nil ) { self.id = id $user.id = userID self.firstName = firstName self.lastName = lastName + self.companyName = companyName + self.streetAddress = streetAddress + self.city = city + self.state = state + self.zipCode = zipCode self.theme = theme?.rawValue } @@ -154,6 +234,11 @@ final class UserProfileModel: Model, @unchecked Sendable { userID: $user.id, firstName: firstName, lastName: lastName, + companyName: companyName, + streetAddress: streetAddress, + city: city, + state: state, + zipCode: zipCode, theme: self.theme.flatMap(Theme.init), createdAt: createdAt!, updatedAt: updatedAt! @@ -167,6 +252,21 @@ final class UserProfileModel: Model, @unchecked Sendable { if let lastName = updates.lastName, lastName != self.lastName { self.lastName = lastName } + if let companyName = updates.companyName, companyName != self.companyName { + self.companyName = companyName + } + if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress { + self.streetAddress = streetAddress + } + if let city = updates.city, city != self.city { + self.city = city + } + if let state = updates.state, state != self.state { + self.state = state + } + if let zipCode = updates.zipCode, zipCode != self.zipCode { + self.zipCode = zipCode + } if let theme = updates.theme, theme.rawValue != self.theme { self.theme = theme.rawValue } diff --git a/Sources/DatabaseClient/Users.swift b/Sources/DatabaseClient/Users.swift index 47623e3..0813f20 100644 --- a/Sources/DatabaseClient/Users.swift +++ b/Sources/DatabaseClient/Users.swift @@ -80,12 +80,11 @@ extension User { 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("createdAt", .datetime) .field("updatedAt", .datetime) - .unique(on: "email", "username") + .unique(on: "email") .create() } @@ -128,13 +127,10 @@ extension User.Create { func toModel() throws -> UserModel { try validate() - return try .init(username: username, email: email, passwordHash: User.hashPassword(password)) + return try .init(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") } @@ -154,9 +150,6 @@ final class UserModel: Model, @unchecked Sendable { @ID(key: .id) var id: UUID? - @Field(key: "username") - var username: String - @Field(key: "email") var email: String @@ -176,12 +169,10 @@ final class UserModel: Model, @unchecked Sendable { init( id: UUID? = nil, - username: String, email: String, passwordHash: String ) { self.id = id - self.username = username self.email = email self.passwordHash = passwordHash } @@ -190,7 +181,6 @@ final class UserModel: Model, @unchecked Sendable { try .init( id: requireID(), email: email, - username: username, createdAt: createdAt!, updatedAt: updatedAt! ) @@ -239,7 +229,7 @@ final class UserTokenModel: Model, Codable, @unchecked Sendable { extension User: Authenticatable {} extension User: SessionAuthenticatable { - public var sessionID: String { username } + public var sessionID: String { email } } public struct UserPasswordAuthenticator: AsyncBasicAuthenticator { @@ -250,7 +240,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator { 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) + .filter(\UserModel.$email == basic.username) .first(), try user.verifyPassword(basic.password) else { @@ -286,7 +276,7 @@ public struct UserSessionAuthenticator: AsyncSessionAuthenticator { 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) + .filter(\UserModel.$email == sessionID) .first() else { throw Abort(.unauthorized) diff --git a/Sources/ManualDCore/Routes/ViewRoute.swift b/Sources/ManualDCore/Routes/ViewRoute.swift index 06713e1..07e3b50 100644 --- a/Sources/ManualDCore/Routes/ViewRoute.swift +++ b/Sources/ManualDCore/Routes/ViewRoute.swift @@ -11,6 +11,7 @@ extension SiteRoute { case login(LoginRoute) case signup(SignupRoute) case project(ProjectRoute) + case user(UserRoute) //FIX: Remove. case test @@ -28,6 +29,9 @@ extension SiteRoute { Route(.case(Self.project)) { SiteRoute.View.ProjectRoute.router } + Route(.case(Self.user)) { + SiteRoute.View.UserRoute.router + } } } } @@ -696,6 +700,7 @@ extension SiteRoute.View { public enum SignupRoute: Equatable, Sendable { case index case submit(User.Create) + case submitProfile(User.Profile.Create) static let rootPath = "signup" @@ -709,7 +714,6 @@ extension SiteRoute.View { Method.post Body { FormData { - Field("username", .string) Field("email", .string) Field("password", .string) Field("confirmPassword", .string) @@ -717,6 +721,114 @@ extension SiteRoute.View { .map(.memberwise(User.Create.init)) } } + Route(.case(Self.submitProfile)) { + Path { + rootPath + "profile" + } + Method.post + Body { + FormData { + Field("userID") { User.ID.parser() } + Field("firstName", .string) + Field("lastName", .string) + Field("companyName", .string) + Field("streetAddress", .string) + Field("city", .string) + Field("state", .string) + Field("zipCode", .string) + Optionally { + Field("theme") { Theme.parser() } + } + } + .map(.memberwise(User.Profile.Create.init)) + } + } + } + } +} + +extension SiteRoute.View { + public enum UserRoute: Equatable, Sendable { + case profile(Profile) + + static let router = OneOf { + Route(.case(Self.profile)) { + Profile.router + } + } + } +} + +extension SiteRoute.View.UserRoute { + public enum Profile: Equatable, Sendable { + case index + case submit(User.Profile.Create) + case update(User.Profile.ID, User.Profile.Update) + + static let rootPath = "profile" + + static let router = OneOf { + Route(.case(Self.index)) { + Path { rootPath } + Method.get + } + Route(.case(Self.submit)) { + Path { rootPath } + Method.post + Body { + FormData { + Field("userID") { User.ID.parser() } + Field("firstName", .string) + Field("lastName", .string) + Field("companyName", .string) + Field("streetAddress", .string) + Field("city", .string) + Field("state", .string) + Field("zipCode", .string) + Optionally { + Field("theme") { Theme.parser() } + } + } + .map(.memberwise(User.Profile.Create.init)) + } + } + Route(.case(Self.update)) { + Path { + rootPath + User.Profile.ID.parser() + } + Method.patch + Body { + FormData { + Optionally { + Field("firstName", .string) + } + Optionally { + Field("lastName", .string) + } + Optionally { + Field("companyName", .string) + } + Optionally { + Field("streetAddress", .string) + } + Optionally { + Field("city", .string) + } + Optionally { + Field("state", .string) + } + Optionally { + Field("zipCode", .string) + } + Optionally { + Field("theme") { Theme.parser() } + } + } + .map(.memberwise(User.Profile.Update.init)) + } + } } } } diff --git a/Sources/ManualDCore/Theme.swift b/Sources/ManualDCore/Theme.swift index 98e94b0..691f6fa 100644 --- a/Sources/ManualDCore/Theme.swift +++ b/Sources/ManualDCore/Theme.swift @@ -5,6 +5,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable { case cupcake case cyberpunk case dark + case `default` case dracula case light case night @@ -20,7 +21,7 @@ public enum Theme: String, CaseIterable, Codable, Equatable, Sendable { Self.synthwave, ] - public static let lightThems = [ + public static let lightThemes = [ Self.cupcake, Self.cyberpunk, Self.light, diff --git a/Sources/ManualDCore/User.swift b/Sources/ManualDCore/User.swift index 56b4c6b..2cf9685 100644 --- a/Sources/ManualDCore/User.swift +++ b/Sources/ManualDCore/User.swift @@ -6,19 +6,16 @@ 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 @@ -28,18 +25,15 @@ public struct User: Codable, Equatable, Identifiable, Sendable { 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 diff --git a/Sources/ManualDCore/UserProfile.swift b/Sources/ManualDCore/UserProfile.swift index f4b7cd1..84baed3 100644 --- a/Sources/ManualDCore/UserProfile.swift +++ b/Sources/ManualDCore/UserProfile.swift @@ -7,6 +7,11 @@ extension User { public let userID: User.ID public let firstName: String public let lastName: String + public let companyName: String + public let streetAddress: String + public let city: String + public let state: String + public let zipCode: String public let theme: Theme? public let createdAt: Date public let updatedAt: Date @@ -16,6 +21,11 @@ extension User { userID: User.ID, firstName: String, lastName: String, + companyName: String, + streetAddress: String, + city: String, + state: String, + zipCode: String, theme: Theme? = nil, createdAt: Date, updatedAt: Date @@ -24,6 +34,11 @@ extension User { self.userID = userID self.firstName = firstName self.lastName = lastName + self.companyName = companyName + self.streetAddress = streetAddress + self.city = city + self.state = state + self.zipCode = zipCode self.theme = theme self.createdAt = createdAt self.updatedAt = updatedAt @@ -37,17 +52,32 @@ extension User.Profile { public let userID: User.ID public let firstName: String public let lastName: String + public let companyName: String + public let streetAddress: String + public let city: String + public let state: String + public let zipCode: String public let theme: Theme? public init( userID: User.ID, firstName: String, lastName: String, + companyName: String, + streetAddress: String, + city: String, + state: String, + zipCode: String, theme: Theme? = nil ) { self.userID = userID self.firstName = firstName self.lastName = lastName + self.companyName = companyName + self.streetAddress = streetAddress + self.city = city + self.state = state + self.zipCode = zipCode self.theme = theme } } @@ -55,15 +85,30 @@ extension User.Profile { public struct Update: Codable, Equatable, Sendable { public let firstName: String? public let lastName: String? + public let companyName: String? + public let streetAddress: String? + public let city: String? + public let state: String? + public let zipCode: String? public let theme: Theme? public init( firstName: String? = nil, lastName: String? = nil, + companyName: String? = nil, + streetAddress: String? = nil, + city: String? = nil, + state: String? = nil, + zipCode: String? = nil, theme: Theme? = nil ) { self.firstName = firstName self.lastName = lastName + self.companyName = companyName + self.streetAddress = streetAddress + self.city = city + self.state = state + self.zipCode = zipCode self.theme = theme } } diff --git a/Sources/Styleguide/SVG.swift b/Sources/Styleguide/SVG.swift index 97844cd..eb4851d 100644 --- a/Sources/Styleguide/SVG.swift +++ b/Sources/Styleguide/SVG.swift @@ -17,6 +17,7 @@ extension SVG { public enum Key: Sendable { case badgeCheck case ban + case chevronDown case chevronRight case chevronsLeft case circlePlus @@ -45,6 +46,10 @@ extension SVG { return """ """ + case .chevronDown: + return """ + + """ case .chevronRight: return """ diff --git a/Sources/ViewController/Live.swift b/Sources/ViewController/Live.swift index 4699c24..dc82943 100644 --- a/Sources/ViewController/Live.swift +++ b/Sources/ViewController/Live.swift @@ -42,7 +42,18 @@ extension ViewController.Request { // Create a new user and log them in. return await view { await ResultView { - let user = try await createAndAuthenticate(request) + try await createAndAuthenticate(request) + } onSuccess: { user in + MainPage { + UserProfileForm(userID: user.id, profile: nil, dismiss: false, signup: true) + } + } + } + case .submitProfile(let profile): + return await view { + await ResultView { + _ = try await database.userProfile.create(profile) + let user = try currentUser() return ( user.id, try await database.projects.fetch(user.id, .init(page: 1, per: 25)) @@ -54,6 +65,9 @@ extension ViewController.Request { } case .project(let route): return await route.renderView(on: self) + + case .user(let route): + return await route.renderView(on: self) } } @@ -74,7 +88,8 @@ extension ViewController.Request { } var theme: Theme? { - .dracula + nil + // .dracula } } @@ -576,53 +591,54 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute { } } -// private func _render( -// isHtmxRequest: Bool, -// active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, -// showSidebar: Bool = true, -// theme: Theme? = nil, -// @HTMLBuilder inner: () async throws -> C -// ) async throws -> AnySendableHTML where C: Sendable { -// let inner = try await inner() -// if isHtmxRequest { -// return div(.class("h-screen w-full")) { -// inner -// } -// .attributes(.data("theme", value: theme!.rawValue), when: theme != nil) -// } -// return MainPage(theme: theme) { inner } -// } -// -// private func _render( -// isHtmxRequest: Bool, -// active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, -// showSidebar: Bool = true, -// theme: Theme? = nil, -// @HTMLBuilder inner: () async -> C -// ) async -> AnySendableHTML where C: Sendable { -// let inner = await inner() -// if isHtmxRequest { -// return div(.class("h-screen w-full")) { -// inner -// } -// .attributes(.data("theme", value: theme!.rawValue), when: theme != nil) -// } -// return MainPage(theme: theme) { inner } -// } -// -// private func _render( -// isHtmxRequest: Bool, -// active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, -// showSidebar: Bool = true, -// theme: Theme? = nil, -// @HTMLBuilder inner: () -> C -// ) -> AnySendableHTML where C: Sendable { -// let inner = inner() -// if isHtmxRequest { -// return div(.class("h-screen w-full")) { -// inner -// } -// .attributes(.data("theme", value: theme!.rawValue), when: theme != nil) -// } -// return MainPage(theme: theme) { inner } -// } +extension SiteRoute.View.UserRoute { + + func renderView(on request: ViewController.Request) async -> AnySendableHTML { + switch self { + case .profile(let route): + return await route.renderView(on: request) + } + } +} + +extension SiteRoute.View.UserRoute.Profile { + + func renderView( + on request: ViewController.Request + ) async -> AnySendableHTML { + @Dependency(\.database) var database + + switch self { + case .index: + return await view(on: request) + case .submit(let form): + return await view(on: request) { + _ = try await database.userProfile.create(form) + } + case .update(let id, let updates): + return await view(on: request) { + _ = try await database.userProfile.update(id, updates) + } + } + } + + func view( + on request: ViewController.Request, + catching: @escaping @Sendable () async throws -> Void = {} + ) async -> AnySendableHTML { + @Dependency(\.database) var database + + return await request.view { + await ResultView { + try await catching() + let user = try request.currentUser() + return ( + user, + try await database.userProfile.get(user.id) + ) + } onSuccess: { (user, profile) in + UserView(user: user, profile: profile) + } + } + } +} diff --git a/Sources/ViewController/Views/MainPage.swift b/Sources/ViewController/Views/MainPage.swift index 149cf89..b0a920f 100644 --- a/Sources/ViewController/Views/MainPage.swift +++ b/Sources/ViewController/Views/MainPage.swift @@ -57,7 +57,7 @@ public struct MainPage: SendableHTMLDocument where Inner: Sendable div(.class("h-screen w-full")) { inner } - .attributes(.data("theme", value: theme!.rawValue), when: theme != nil) + .attributes(.data("theme", value: theme?.rawValue ?? "default"), when: theme != nil) } } diff --git a/Sources/ViewController/Views/Navbar.swift b/Sources/ViewController/Views/Navbar.swift index 8ae61f4..cf4b4a7 100644 --- a/Sources/ViewController/Views/Navbar.swift +++ b/Sources/ViewController/Views/Navbar.swift @@ -42,34 +42,40 @@ struct Navbar: HTML, Sendable { } } div(.class("flex-none")) { - details(.class("dropdown dropdown-left dropdown-bottom")) { - summary(.class("btn w-fit px-4 py-2")) { - SVG(.circleUser) - } - .navButton() - - ul( - .class( - """ - menu dropdown-content bg-base-100 - rounded-box z-1 w-fit p-2 shadow-sm - """ - ) - ) { - li(.class("w-full")) { - // TODO: Save theme to user profile ?? - div(.class("flex justify-between p-4 space-x-6")) { - Label("Theme") - input(.type(.checkbox), .class("toggle theme-controller"), .value("light")) - } - } - } - - // button(.class("w-fit px-4 py-2")) { - // SVG(.circleUser) - // } - // .navButton() + a( + .href(route: .user(.profile(.index))), + ) { + SVG(.circleUser) } + .navButton() + // details(.class("dropdown dropdown-left dropdown-bottom")) { + // summary(.class("btn w-fit px-4 py-2")) { + // SVG(.circleUser) + // } + // .navButton() + // + // ul( + // .class( + // """ + // menu dropdown-content bg-base-100 + // rounded-box z-1 w-fit p-2 shadow-sm + // """ + // ) + // ) { + // li(.class("w-full")) { + // // TODO: Save theme to user profile ?? + // div(.class("flex justify-between p-4 space-x-6")) { + // Label("Theme") + // input(.type(.checkbox), .class("toggle theme-controller"), .value("light")) + // } + // } + // } + // + // // button(.class("w-fit px-4 py-2")) { + // // SVG(.circleUser) + // // } + // // .navButton() + // } } } } diff --git a/Sources/ViewController/Views/TestPage.swift b/Sources/ViewController/Views/TestPage.swift index d37b522..9aaf88f 100644 --- a/Sources/ViewController/Views/TestPage.swift +++ b/Sources/ViewController/Views/TestPage.swift @@ -1,51 +1,10 @@ +import Dependencies import Elementary +import Foundation +import ManualDCore struct TestPage: HTML, Sendable { var body: some HTML { - HTMLRaw( - """ -
- -
- - - -
Page Content
-
- -
- -
- - -
-
-
- """ - ) + UserProfileForm(userID: UUID(0), profile: nil, dismiss: false) } } diff --git a/Sources/ViewController/Views/User/LoginForm.swift b/Sources/ViewController/Views/User/LoginForm.swift index 29b81cb..68c6c33 100644 --- a/Sources/ViewController/Views/User/LoginForm.swift +++ b/Sources/ViewController/Views/User/LoginForm.swift @@ -25,25 +25,6 @@ struct LoginForm: HTML, Sendable { input(.class("hidden"), .name("next"), .value(next)) } - if style == .signup { - div { - label(.class("input validator w-full")) { - SVG(.user) - input( - .type(.text), .required, .placeholder("Username"), - .name("username"), .id("username"), - .minlength("3"), .pattern(.username), - .autofocus - ) - } - div(.class("validator-hint hidden")) { - "Enter valid username" - br() - "Must be at least 3 characters" - } - } - } - div { label(.class("input validator w-full")) { SVG(.email) diff --git a/Sources/ViewController/Views/User/UserProfileForm.swift b/Sources/ViewController/Views/User/UserProfileForm.swift new file mode 100644 index 0000000..383ad2b --- /dev/null +++ b/Sources/ViewController/Views/User/UserProfileForm.swift @@ -0,0 +1,155 @@ +import Elementary +import ElementaryHTMX +import ManualDCore +import Styleguide + +struct UserProfileForm: HTML, Sendable { + + static func id(_ profile: User.Profile?) -> String { + let base = "userProfileForm" + guard let profile else { return base } + return "\(base)_\(profile.id.idString)" + } + + let userID: User.ID + let profile: User.Profile? + let dismiss: Bool + let signup: Bool + + init( + userID: User.ID, + profile: User.Profile? = nil, + dismiss: Bool, + signup: Bool = false + ) { + self.userID = userID + self.profile = profile + self.dismiss = dismiss + self.signup = signup + } + + var route: String { + guard !signup else { + return SiteRoute.View.router.path(for: .signup(.index)) + .appendingPath("profile") + } + return SiteRoute.View.router.path(for: .user(.profile(.index))) + .appendingPath(profile?.id) + } + + var body: some HTML { + ModalForm(id: Self.id(profile), closeButton: dismiss, dismiss: dismiss) { + + h1(.class("text-xl font-bold pb-6")) { "Profile" } + + form( + .class("grid grid-cols-1 gap-4 p-4"), + profile == nil + ? .hx.post(route) + : .hx.patch(route), + .hx.target("body"), + .hx.swap(.outerHTML) + ) { + if let profile { + input(.class("hidden"), .name("id"), .value(profile.id)) + } + input(.class("hidden"), .name("userID"), .value(userID)) + + label(.class("input w-full")) { + span(.class("label")) { "First Name" } + input(.name("firstName"), .required, .autofocus) + } + + label(.class("input w-full")) { + span(.class("label")) { "Last Name" } + input(.name("lastName"), .required) + } + + label(.class("input w-full")) { + span(.class("label")) { "Company" } + input(.name("companyName"), .required) + } + + label(.class("input w-full")) { + span(.class("label")) { "Address" } + input(.name("streetAddress"), .required) + } + + label(.class("input w-full")) { + span(.class("label")) { "City" } + input(.name("city"), .required) + } + + label(.class("input w-full")) { + span(.class("label")) { "State" } + input(.name("state"), .required) + } + + label(.class("input w-full")) { + span(.class("label")) { "Zip" } + input(.name("zipCode"), .required) + } + + div(.class("dropdown dropdown-top")) { + div(.class("input btn m-1 w-full"), .tabindex(0), .role(.init(rawValue: "button"))) { + "Theme" + SVG(.chevronDown) + } + ul( + .tabindex(-1), + .class("dropdown-content bg-base-300 rounded-box z-1 p-2 shadow-2xl") + ) { + li { + input( + .type(.radio), + .name("theme"), + .class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"), + .init(name: "aria-label", value: "Default"), + .value("default") + ) + .attributes(.checked, when: profile?.theme == .default) + } + li { + span(.class("text-sm font-bold text-gray-400")) { + "Light" + } + } + for theme in Theme.lightThemes { + li { + input( + .type(.radio), + .name("theme"), + .class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"), + .init(name: "aria-label", value: "\(theme.rawValue.capitalized)"), + .value(theme.rawValue) + ) + .attributes(.checked, when: profile?.theme == theme) + } + } + li { + span(.class("text-sm font-bold text-gray-400")) { + "Dark" + } + } + for theme in Theme.darkThemes { + li { + input( + .type(.radio), + .name("theme"), + .class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"), + .init(name: "aria-label", value: "\(theme.rawValue.capitalized)"), + .value(theme.rawValue) + ) + .attributes(.checked, when: profile?.theme == theme) + } + } + } + } + + SubmitButton() + .attributes(.class("btn-block")) + + } + } + } +} diff --git a/Sources/ViewController/Views/User/UserView.swift b/Sources/ViewController/Views/User/UserView.swift new file mode 100644 index 0000000..bc84b82 --- /dev/null +++ b/Sources/ViewController/Views/User/UserView.swift @@ -0,0 +1,53 @@ +import Elementary +import ManualDCore +import Styleguide + +struct UserView: HTML, Sendable { + let user: User + let profile: User.Profile? + + var body: some HTML { + div { + Row { + h1(.class("text-2xl font-bold")) { "Account" } + EditButton() + .attributes(.showModal(id: UserProfileForm.id(profile))) + } + + if let profile { + table(.class("table table-zebra")) { + tr { + td { Label("Name") } + td { "\(profile.firstName) \(profile.lastName)" } + } + tr { + td { Label("Company") } + td { profile.companyName } + } + tr { + td { Label("Street Address") } + td { profile.streetAddress } + } + tr { + td { Label("City") } + td { profile.city } + } + tr { + td { Label("State") } + td { profile.state } + } + tr { + td { Label("Zip Code") } + td { profile.zipCode } + } + tr { + td { Label("Theme") } + td { profile.theme?.rawValue ?? "" } + } + + } + } + UserProfileForm(userID: user.id, profile: profile, dismiss: true) + } + } +}