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)
+ }
+ }
+}