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";
@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 componentLoss: ComponentLoss
public var effectiveLength: EffectiveLengthClient
// public var rectangularDuct: RectangularDuct
public var users: Users
public var userProfile: UserProfile
}
extension DatabaseClient: TestDependencyKey {
@@ -30,8 +30,8 @@ extension DatabaseClient: TestDependencyKey {
equipment: .testValue,
componentLoss: .testValue,
effectiveLength: .testValue,
// rectangularDuct: .testValue,
users: .testValue
users: .testValue,
userProfile: .testValue
)
public static func live(database: any Database) -> Self {
@@ -42,8 +42,8 @@ extension DatabaseClient: TestDependencyKey {
equipment: .live(database: database),
componentLoss: .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(),
User.Migrate(),
User.Token.Migrate(),
User.Profile.Migrate(),
ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(),
Room.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()
.field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.field("createdAt", .datetime)
.field("updatedAt", .datetime)
.unique(on: "value")
.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 Foundation
// FIX: Remove username.
public struct User: Codable, Equatable, Identifiable, Sendable {
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 chevronsLeft
case circlePlus
case circleUser
case close
case doorClosed
case email
@@ -56,6 +57,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-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:
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>

View File

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

View File

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

View File

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

View File

@@ -82,13 +82,6 @@ extension ProjectView {
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")) {
row(

View File

@@ -121,6 +121,7 @@ struct RoomsView: HTML, Sendable {
}
public struct RoomRow: HTML, Sendable {
let room: Room
let shr: Double
@@ -137,7 +138,7 @@ struct RoomsView: HTML, Sendable {
}
public var body: some HTML {
tr(.id("roomRow_\(room.name)")) {
tr(.id("roomRow_\(room.id.idString)")) {
td { room.name }
td {
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