WIP: Begins updating signup route to also setup a user's profile.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><path d="M4.929 4.929 19.07 19.071"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
"""
|
||||
case .chevronDown:
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
|
||||
"""
|
||||
case .chevronRight:
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
|
||||
|
||||
@@ -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<C: HTML>(
|
||||
// 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<C: HTML>(
|
||||
// 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<C: HTML>(
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ public struct MainPage<Inner: HTML>: 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")) {
|
||||
a(
|
||||
.href(route: .user(.profile(.index))),
|
||||
) {
|
||||
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")) {
|
||||
// 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()
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,10 @@
|
||||
import Dependencies
|
||||
import Elementary
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
struct TestPage: HTML, Sendable {
|
||||
var body: some HTML {
|
||||
HTMLRaw(
|
||||
"""
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="my-drawer-4" type="checkbox" class="drawer-toggle" checked/>
|
||||
<div class="drawer-content">
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar w-full bg-base-300">
|
||||
<label for="my-drawer-4" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
||||
<!-- Sidebar toggle icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block size-4"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg>
|
||||
</label>
|
||||
<div class="px-4">Navbar Title</div>
|
||||
</nav>
|
||||
<!-- Page content here -->
|
||||
<div class="p-4">Page Content</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side is-drawer-close:overflow-visible">
|
||||
<label for="my-drawer-4" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<div class="flex min-h-full flex-col items-start bg-base-200 is-drawer-close:w-14 is-drawer-open:w-64">
|
||||
<!-- Sidebar content here -->
|
||||
<ul class="menu w-full grow">
|
||||
<!-- List item -->
|
||||
<li>
|
||||
<button class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Homepage">
|
||||
<!-- Home icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block size-4"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path></svg>
|
||||
<span class="is-drawer-close:hidden">Homepage</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- List item -->
|
||||
<li>
|
||||
<button class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
|
||||
<!-- Settings icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block size-4"><path d="M20 7h-9"></path><path d="M14 17H5"></path><circle cx="17" cy="17" r="3"></circle><circle cx="7" cy="7" r="3"></circle></svg>
|
||||
<span class="is-drawer-close:hidden">Settings</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
UserProfileForm(userID: UUID(0), profile: nil, dismiss: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
155
Sources/ViewController/Views/User/UserProfileForm.swift
Normal file
155
Sources/ViewController/Views/User/UserProfileForm.swift
Normal file
@@ -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"))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
Sources/ViewController/Views/User/UserView.swift
Normal file
53
Sources/ViewController/Views/User/UserView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user