feat: Begins user profile, adds database model, need to add views / forms.

This commit is contained in:
2026-01-12 13:33:53 -05:00
parent 6416b29627
commit 894bd561ff
16 changed files with 1439 additions and 3411 deletions

View File

@@ -1,5 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui" { @plugin "daisyui" {
themes: light --default, dark --prefersdark, dracula; themes: all;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,8 @@ public struct DatabaseClient: Sendable {
public var equipment: Equipment public var equipment: Equipment
public var componentLoss: ComponentLoss public var componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient public var effectiveLength: EffectiveLengthClient
// public var rectangularDuct: RectangularDuct
public var users: Users public var users: Users
public var userProfile: UserProfile
} }
extension DatabaseClient: TestDependencyKey { extension DatabaseClient: TestDependencyKey {
@@ -30,8 +30,8 @@ extension DatabaseClient: TestDependencyKey {
equipment: .testValue, equipment: .testValue,
componentLoss: .testValue, componentLoss: .testValue,
effectiveLength: .testValue, effectiveLength: .testValue,
// rectangularDuct: .testValue, users: .testValue,
users: .testValue userProfile: .testValue
) )
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
@@ -42,8 +42,8 @@ extension DatabaseClient: TestDependencyKey {
equipment: .live(database: database), equipment: .live(database: database),
componentLoss: .live(database: database), componentLoss: .live(database: database),
effectiveLength: .live(database: database), effectiveLength: .live(database: database),
// rectangularDuct: .live(database: database), users: .live(database: database),
users: .live(database: database) userProfile: .live(database: database)
) )
} }
} }
@@ -66,11 +66,11 @@ extension DatabaseClient.Migrations: DependencyKey {
Project.Migrate(), Project.Migrate(),
User.Migrate(), User.Migrate(),
User.Token.Migrate(), User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(), ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(), EquipmentInfo.Migrate(),
Room.Migrate(), Room.Migrate(),
EffectiveLength.Migrate(), EffectiveLength.Migrate(),
// DuctSizing.RectangularDuct.Migrate(),
] ]
} }
) )

View File

@@ -0,0 +1,175 @@
import Dependencies
import DependenciesMacros
import Fluent
import ManualDCore
import Vapor
extension DatabaseClient {
@DependencyClient
public struct UserProfile: Sendable {
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
public var delete: @Sendable (User.Profile.ID) async throws -> Void
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
}
}
extension DatabaseClient.UserProfile: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { profile in
try profile.validate()
let model = profile.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
get: { id in
try await UserProfileModel.find(id, on: database)
.map { try $0.toDTO() }
},
update: { id, updates in
guard let model = try await UserProfileModel.find(id, on: database) else {
throw NotFoundError()
}
try updates.validate()
model.applyUpdates(updates)
if model.hasChanges {
try await model.save(on: database)
}
return try model.toDTO()
}
)
}
}
extension User.Profile.Create {
func validate() throws(ValidationError) {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
}
func toModel() -> UserProfileModel {
.init(userID: userID, firstName: firstName, lastName: lastName, theme: theme)
}
}
extension User.Profile.Update {
func validate() throws(ValidationError) {
if let firstName {
guard !firstName.isEmpty else {
throw ValidationError("User first name should not be empty.")
}
}
if let lastName {
guard !lastName.isEmpty else {
throw ValidationError("User last name should not be empty.")
}
}
}
}
extension User.Profile {
struct Migrate: AsyncMigration {
let name = "Create UserProfile"
func prepare(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema)
.id()
.field("firstName", .string, .required)
.field("lastName", .string, .required)
.field("theme", .string)
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "userID")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserProfileModel.schema).delete()
}
}
}
final class UserProfileModel: Model, @unchecked Sendable {
static let schema = "user_profile"
@ID(key: .id)
var id: UUID?
@Parent(key: "userID")
var user: UserModel
@Field(key: "firstName")
var firstName: String
@Field(key: "lastName")
var lastName: String
@Field(key: "theme")
var theme: String?
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
init() {}
init(
id: UUID? = nil,
userID: User.ID,
firstName: String,
lastName: String,
theme: Theme? = nil
) {
self.id = id
$user.id = userID
self.firstName = firstName
self.lastName = lastName
self.theme = theme?.rawValue
}
func toDTO() throws -> User.Profile {
try .init(
id: requireID(),
userID: $user.id,
firstName: firstName,
lastName: lastName,
theme: self.theme.flatMap(Theme.init),
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func applyUpdates(_ updates: User.Profile.Update) {
if let firstName = updates.firstName, firstName != self.firstName {
self.firstName = firstName
}
if let lastName = updates.lastName, lastName != self.lastName {
self.lastName = lastName
}
if let theme = updates.theme, theme.rawValue != self.theme {
self.theme = theme.rawValue
}
}
}

View File

@@ -104,6 +104,8 @@ extension User.Token {
.id() .id()
.field("value", .string, .required) .field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id")) .field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "value") .unique(on: "value")
.create() .create()
} }

View File

@@ -0,0 +1,30 @@
import Foundation
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
case aqua
case cupcake
case cyberpunk
case dark
case dracula
case light
case night
case nord
case retro
case synthwave
public static let darkThemes = [
Self.aqua,
Self.dark,
Self.dracula,
Self.night,
Self.synthwave,
]
public static let lightThems = [
Self.cupcake,
Self.cyberpunk,
Self.light,
Self.nord,
Self.retro,
]
}

View File

@@ -1,6 +1,7 @@
import Dependencies import Dependencies
import Foundation import Foundation
// FIX: Remove username.
public struct User: Codable, Equatable, Identifiable, Sendable { public struct User: Codable, Equatable, Identifiable, Sendable {
public let id: UUID public let id: UUID

View File

@@ -0,0 +1,70 @@
import Foundation
extension User {
public struct Profile: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let userID: User.ID
public let firstName: String
public let lastName: String
public let theme: Theme?
public let createdAt: Date
public let updatedAt: Date
public init(
id: UUID,
userID: User.ID,
firstName: String,
lastName: String,
theme: Theme? = nil,
createdAt: Date,
updatedAt: Date
) {
self.id = id
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.theme = theme
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
}
extension User.Profile {
public struct Create: Codable, Equatable, Sendable {
public let userID: User.ID
public let firstName: String
public let lastName: String
public let theme: Theme?
public init(
userID: User.ID,
firstName: String,
lastName: String,
theme: Theme? = nil
) {
self.userID = userID
self.firstName = firstName
self.lastName = lastName
self.theme = theme
}
}
public struct Update: Codable, Equatable, Sendable {
public let firstName: String?
public let lastName: String?
public let theme: Theme?
public init(
firstName: String? = nil,
lastName: String? = nil,
theme: Theme? = nil
) {
self.firstName = firstName
self.lastName = lastName
self.theme = theme
}
}
}

View File

@@ -20,6 +20,7 @@ extension SVG {
case chevronRight case chevronRight
case chevronsLeft case chevronsLeft
case circlePlus case circlePlus
case circleUser
case close case close
case doorClosed case doorClosed
case email case email
@@ -56,6 +57,10 @@ extension SVG {
return """ 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-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg> <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-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
""" """
case .circleUser:
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-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
"""
case .close: case .close:
return """ 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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg> <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-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

View File

@@ -54,37 +54,27 @@ extension ViewController.Request {
} }
case .project(let route): case .project(let route):
return await route.renderView(on: self) return await route.renderView(on: self)
default:
// FIX: FIX
return _render(isHtmxRequest: false) {
div { "Fix me!" }
}
} }
} }
func view<C: HTML>( func view<C: HTML>(
@HTMLBuilder inner: () -> C @HTMLBuilder inner: () -> C
) -> AnySendableHTML where C: Sendable { ) -> AnySendableHTML where C: Sendable {
_render(isHtmxRequest: isHtmxRequest, showSidebar: showSidebar) { MainPage(theme: theme) { inner() }
inner()
}
} }
func view<C: HTML>( func view<C: HTML>(
@HTMLBuilder inner: () async -> C @HTMLBuilder inner: () async -> C
) async -> AnySendableHTML where C: Sendable { ) async -> AnySendableHTML where C: Sendable {
await _render(isHtmxRequest: isHtmxRequest, showSidebar: showSidebar) { let inner = await inner()
await inner()
return MainPage(theme: theme) {
inner
} }
} }
var showSidebar: Bool { var theme: Theme? {
switch route { .dracula
case .login, .signup, .project(.page):
return false
default:
return true
}
} }
} }
@@ -261,7 +251,6 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
return await roomsView(on: request, projectID: projectID) return await roomsView(on: request, projectID: projectID)
case .submit(let form): case .submit(let form):
// FIX: Just return a room row.
return await roomsView(on: request, projectID: projectID) { return await roomsView(on: request, projectID: projectID) {
_ = try await database.rooms.create(form) _ = try await database.rooms.create(form)
} }
@@ -587,41 +576,53 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
} }
} }
private func _render<C: HTML>( // private func _render<C: HTML>(
isHtmxRequest: Bool, // isHtmxRequest: Bool,
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, // active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,
showSidebar: Bool = true, // showSidebar: Bool = true,
@HTMLBuilder inner: () async throws -> C // theme: Theme? = nil,
) async throws -> AnySendableHTML where C: Sendable { // @HTMLBuilder inner: () async throws -> C
let inner = try await inner() // ) async throws -> AnySendableHTML where C: Sendable {
if isHtmxRequest { // let inner = try await inner()
return inner // if isHtmxRequest {
} // return div(.class("h-screen w-full")) {
return MainPage { inner } // inner
} // }
// .attributes(.data("theme", value: theme!.rawValue), when: theme != nil)
private func _render<C: HTML>( // }
isHtmxRequest: Bool, // return MainPage(theme: theme) { inner }
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, // }
showSidebar: Bool = true, //
@HTMLBuilder inner: () async -> C // private func _render<C: HTML>(
) async -> AnySendableHTML where C: Sendable { // isHtmxRequest: Bool,
let inner = await inner() // active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,
if isHtmxRequest { // showSidebar: Bool = true,
return inner // theme: Theme? = nil,
} // @HTMLBuilder inner: () async -> C
return MainPage { inner } // ) async -> AnySendableHTML where C: Sendable {
} // let inner = await inner()
// if isHtmxRequest {
private func _render<C: HTML>( // return div(.class("h-screen w-full")) {
isHtmxRequest: Bool, // inner
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms, // }
showSidebar: Bool = true, // .attributes(.data("theme", value: theme!.rawValue), when: theme != nil)
@HTMLBuilder inner: () -> C // }
) -> AnySendableHTML where C: Sendable { // return MainPage(theme: theme) { inner }
let inner = inner() // }
if isHtmxRequest { //
return inner // private func _render<C: HTML>(
} // isHtmxRequest: Bool,
return MainPage { inner } // 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 }
// }

View File

@@ -9,10 +9,13 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
public var lang: String { "en" } public var lang: String { "en" }
let inner: Inner let inner: Inner
let theme: Theme?
init( init(
theme: Theme? = nil,
_ inner: () -> Inner _ inner: () -> Inner
) { ) {
self.theme = theme
self.inner = inner() self.inner = inner()
} }
@@ -54,10 +57,7 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
div(.class("h-screen w-full")) { div(.class("h-screen w-full")) {
inner inner
} }
script(.src("https://unpkg.com/lucide@latest")) {} .attributes(.data("theme", value: theme!.rawValue), when: theme != nil)
script {
"lucide.createIcons();"
}
} }
} }

View File

@@ -42,10 +42,34 @@ struct Navbar: HTML, Sendable {
} }
} }
div(.class("flex-none")) { div(.class("flex-none")) {
button(.class("w-fit px-4 py-2")) { details(.class("dropdown dropdown-left dropdown-bottom")) {
"User Menu" 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()
} }
.navButton()
} }
} }
} }

View File

@@ -82,13 +82,6 @@ extension ProjectView {
ul(.class("w-full")) { ul(.class("w-full")) {
// FIX: Move to user profile / settings page.
li(.class("w-full is-drawer-close:hidden")) {
div(.class("flex justify-between p-4")) {
Label("Theme")
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
}
}
li(.class("flex w-full")) { li(.class("flex w-full")) {
row( row(

View File

@@ -121,6 +121,7 @@ struct RoomsView: HTML, Sendable {
} }
public struct RoomRow: HTML, Sendable { public struct RoomRow: HTML, Sendable {
let room: Room let room: Room
let shr: Double let shr: Double
@@ -137,7 +138,7 @@ struct RoomsView: HTML, Sendable {
} }
public var body: some HTML { public var body: some HTML {
tr(.id("roomRow_\(room.name)")) { tr(.id("roomRow_\(room.id.idString)")) {
td { room.name } td { room.name }
td { td {
div(.class("flex justify-center")) { div(.class("flex justify-center")) {

View File

@@ -1,8 +0,0 @@
@import "tailwindcss";
@source not "./tailwindcss";
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));

3167
output.css

File diff suppressed because it is too large Load Diff