WIP: Updates to login / signup forms, rearranges some view routes.

This commit is contained in:
2026-01-02 22:53:22 -05:00
parent 6602c4a8b5
commit 6c6045b4a6
10 changed files with 1641 additions and 194 deletions

View File

@@ -25,7 +25,9 @@ extension SiteRoute.Api.ProjectRoute {
switch self { switch self {
case .create(let request): case .create(let request):
return try await database.projects.create(request) // return try await database.projects.create(request)
// FIX:
fatalError()
case .delete(let id): case .delete(let id):
try await database.projects.delete(id) try await database.projects.delete(id)
return nil return nil

View File

@@ -7,21 +7,20 @@ import ManualDCore
extension DatabaseClient { extension DatabaseClient {
@DependencyClient @DependencyClient
public struct Projects: Sendable { public struct Projects: Sendable {
public var create: @Sendable (Project.Create) async throws -> Project public var create: @Sendable (User.ID, Project.Create) async throws -> Project
public var delete: @Sendable (Project.ID) async throws -> Void public var delete: @Sendable (Project.ID) async throws -> Void
public var get: @Sendable (Project.ID) async throws -> Project? public var get: @Sendable (Project.ID) async throws -> Project?
public var fetch: @Sendable (User.ID) async throws -> [Project]
} }
} }
extension DatabaseClient.Projects: TestDependencyKey { extension DatabaseClient.Projects: TestDependencyKey {
public static let testValue = Self() public static let testValue = Self()
}
extension DatabaseClient.Projects {
public static func live(database: any Database) -> Self { public static func live(database: any Database) -> Self {
.init( .init(
create: { request in create: { userID, request in
let model = try request.toModel() let model = try request.toModel(userID: userID)
try await model.save(on: database) try await model.save(on: database)
return try model.toDTO() return try model.toDTO()
}, },
@@ -33,6 +32,13 @@ extension DatabaseClient.Projects {
}, },
get: { id in get: { id in
try await ProjectModel.find(id, on: database).map { try $0.toDTO() } try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
},
fetch: { userID in
try await ProjectModel.query(on: database)
.with(\.$user)
.filter(\.$user.$id == userID)
.all()
.map { try $0.toDTO() }
} }
) )
} }
@@ -40,14 +46,15 @@ extension DatabaseClient.Projects {
extension Project.Create { extension Project.Create {
func toModel() throws -> ProjectModel { func toModel(userID: User.ID) throws -> ProjectModel {
try validate() try validate()
return .init( return .init(
name: name, name: name,
streetAddress: streetAddress, streetAddress: streetAddress,
city: city, city: city,
state: state, state: state,
zipCode: zipCode zipCode: zipCode,
userID: userID
) )
} }
@@ -84,7 +91,8 @@ extension Project {
.field("zipCode", .string, .required) .field("zipCode", .string, .required)
.field("createdAt", .datetime) .field("createdAt", .datetime)
.field("updatedAt", .datetime) .field("updatedAt", .datetime)
.unique(on: "name") .field("userID", .uuid, .required, .references(UserModel.schema, "id"))
.unique(on: "userID", "name")
.create() .create()
} }
@@ -126,6 +134,9 @@ final class ProjectModel: Model, @unchecked Sendable {
@Children(for: \.$project) @Children(for: \.$project)
var componentLosses: [ComponentLossModel] var componentLosses: [ComponentLossModel]
@Parent(key: "userID")
var user: UserModel
init() {} init() {}
init( init(
@@ -135,6 +146,7 @@ final class ProjectModel: Model, @unchecked Sendable {
city: String, city: String,
state: String, state: String,
zipCode: String, zipCode: String,
userID: User.ID,
createdAt: Date? = nil, createdAt: Date? = nil,
updatedAt: Date? = nil updatedAt: Date? = nil
) { ) {
@@ -145,6 +157,7 @@ final class ProjectModel: Model, @unchecked Sendable {
self.city = city self.city = city
self.state = state self.state = state
self.zipCode = zipCode self.zipCode = zipCode
$user.id = userID
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }

View File

@@ -48,7 +48,7 @@ extension Project {
streetAddress: String, streetAddress: String,
city: String, city: String,
state: String, state: String,
zipCode: String zipCode: String,
) { ) {
self.name = name self.name = name
self.streetAddress = streetAddress self.streetAddress = streetAddress

View File

@@ -7,6 +7,7 @@ extension SiteRoute {
/// ///
/// The routes return html. /// The routes return html.
public enum View: Equatable, Sendable { public enum View: Equatable, Sendable {
case login(LoginRoute)
case project(ProjectRoute) case project(ProjectRoute)
case room(RoomRoute) case room(RoomRoute)
case frictionRate(FrictionRateRoute) case frictionRate(FrictionRateRoute)
@@ -14,6 +15,9 @@ extension SiteRoute {
case user(UserRoute) case user(UserRoute)
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.login)) {
SiteRoute.View.LoginRoute.router
}
Route(.case(Self.project)) { Route(.case(Self.project)) {
SiteRoute.View.ProjectRoute.router SiteRoute.View.ProjectRoute.router
} }
@@ -180,13 +184,9 @@ extension SiteRoute.View.EffectiveLengthRoute {
extension SiteRoute.View { extension SiteRoute.View {
public enum UserRoute: Equatable, Sendable { public enum UserRoute: Equatable, Sendable {
case login(Login)
case signup(Signup) case signup(Signup)
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.login)) {
SiteRoute.View.UserRoute.Login.router
}
Route(.case(Self.signup)) { Route(.case(Self.signup)) {
SiteRoute.View.UserRoute.Signup.router SiteRoute.View.UserRoute.Signup.router
} }
@@ -194,9 +194,9 @@ extension SiteRoute.View {
} }
} }
extension SiteRoute.View.UserRoute { extension SiteRoute.View {
public enum Login: Equatable, Sendable { public enum LoginRoute: Equatable, Sendable {
case index case index
case submit(User.Login) case submit(User.Login)
@@ -220,6 +220,9 @@ extension SiteRoute.View.UserRoute {
} }
} }
} }
}
extension SiteRoute.View.UserRoute {
public enum Signup: Equatable, Sendable { public enum Signup: Equatable, Sendable {
case index case index

View File

@@ -0,0 +1,9 @@
import Elementary
import ManualDCore
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
public static func href(route: SiteRoute.View) -> Self {
href(SiteRoute.View.router.path(for: route))
}
}

View File

@@ -5,6 +5,8 @@ extension ViewController.Request {
func render() async throws -> AnySendableHTML { func render() async throws -> AnySendableHTML {
switch route { switch route {
case .login(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .project(let route): case .project(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .room(let route): case .room(let route):
@@ -17,23 +19,24 @@ extension ViewController.Request {
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
default: default:
// FIX: FIX // FIX: FIX
return mainPage return _render(isHtmxRequest: false) {
div { "Fix me!" }
}
} }
} }
} }
extension SiteRoute.View.ProjectRoute { extension SiteRoute.View.ProjectRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self { _render(isHtmxRequest: isHtmxRequest) {
case .index: switch self {
return MainPage(active: .projects) { case .index:
ProjectView(project: .mock) ProjectView(project: .mock)
case .form(let dismiss):
ProjectForm(dismiss: dismiss)
case .create:
div { "Fix me!" }
} }
case .form(let dismiss):
return ProjectForm(dismiss: dismiss)
case .create:
return mainPage
} }
} }
} }
@@ -44,7 +47,7 @@ extension SiteRoute.View.RoomRoute {
case .form(let dismiss): case .form(let dismiss):
return RoomForm(dismiss: dismiss) return RoomForm(dismiss: dismiss)
case .index: case .index:
return MainPage(active: .rooms) { return _render(isHtmxRequest: isHtmxRequest, active: .rooms) {
RoomsView(rooms: Room.mocks) RoomsView(rooms: Room.mocks)
} }
} }
@@ -55,7 +58,7 @@ extension SiteRoute.View.FrictionRateRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self { switch self {
case .index: case .index:
return MainPage(active: .frictionRate) { return _render(isHtmxRequest: isHtmxRequest, active: .frictionRate) {
FrictionRateView() FrictionRateView()
} }
case .form(let type, let dismiss): case .form(let type, let dismiss):
@@ -86,7 +89,7 @@ extension SiteRoute.View.EffectiveLengthRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self { switch self {
case .index: case .index:
return MainPage(active: .effectiveLength) { return _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) {
EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks) EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks)
} }
case .form(let dismiss): case .form(let dismiss):
@@ -107,12 +110,12 @@ extension SiteRoute.View.UserRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self { switch self {
case .login(.index): // case .login(.index):
return MainPage(active: .projects, showSidebar: false) { // return _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm() // LoginForm()
} // }
case .signup(.index): case .signup(.index):
return MainPage(active: .projects, showSidebar: false) { return _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm(style: .signup) LoginForm(style: .signup)
} }
default: default:
@@ -121,31 +124,33 @@ extension SiteRoute.View.UserRoute {
} }
} }
private let mainPage: AnySendableHTML = { extension SiteRoute.View.LoginRoute {
MainPage(active: .projects) { func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
div { _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
h1 { "It works!" } switch self {
case .index:
LoginForm()
case .submit:
// FIX:
div { "Fix me!" }
}
} }
} }
}() }
@Sendable private func _render<C: HTML>(
private func render<C: HTML>( isHtmxRequest: Bool,
_ mainPage: (C) async throws -> AnySendableHTML, active activeTab: Sidebar.ActiveTab = .projects,
_ isHtmxRequest: Bool, showSidebar: Bool = true,
@HTMLBuilder html: () -> C @HTMLBuilder inner: () -> C
) async rethrows -> AnySendableHTML where C: Sendable { ) -> AnySendableHTML where C: Sendable {
guard isHtmxRequest else { if isHtmxRequest {
return try await mainPage(html()) return inner()
}
return MainPage(
active: activeTab,
showSidebar: showSidebar
) {
inner()
} }
return html()
}
@Sendable
private func render<C: HTML>(
_ mainPage: (C) async throws -> AnySendableHTML,
_ isHtmxRequest: Bool,
_ html: @autoclosure @escaping () -> C
) async rethrows -> AnySendableHTML where C: Sendable {
try await render(mainPage, isHtmxRequest) { html() }
} }

View File

@@ -11,135 +11,77 @@ struct LoginForm: HTML, Sendable {
} }
var body: some HTML { var body: some HTML {
form { div(
fieldset(.class("fieldset bg-base-200 border-base-300 rounded-box w-xl border p-4")) { .id("loginForm"),
legend(.class("fieldset-legend")) { style.title } .class("flex items-center justify-center")
) {
form {
fieldset(.class("fieldset bg-base-200 border-base-300 rounded-box w-xl border p-4")) {
legend(.class("fieldset-legend")) { style.title }
if style == .signup {
label(.class("input validator w-full")) {
SVG(.user)
input(
.type(.text), .required, .placeholder("Username"),
.minlength("3"), .pattern(.username)
)
}
div(.class("validator-hint hidden")) {
"Enter valid username"
br()
"Must be at least 3 characters"
}
}
if style == .signup {
label(.class("input validator w-full")) { label(.class("input validator w-full")) {
SVG(.user) SVG(.email)
input( input(
.type(.text), .required, .placeholder("Username"), .type(.email), .placeholder("Email"), .required
.minlength("3"), .pattern(.username)
) )
} }
div(.class("validator-hint hidden")) { div(.class("validator-hint hidden")) { "Enter valid email address." }
"Enter valid username"
br()
"Must be at least 3 characters"
}
}
label(.class("input validator w-full")) {
SVG(.email)
input(
.type(.email), .placeholder("Email"), .required
)
}
div(.class("validator-hint hidden")) { "Enter valid email address." }
label(.class("input validator w-full")) {
SVG(.key)
input(
.type(.password), .placeholder("Password"), .required,
.pattern(.password), .minlength("8")
)
}
if style == .signup {
label(.class("input validator w-full")) { label(.class("input validator w-full")) {
SVG(.key) SVG(.key)
input( input(
.type(.password), .placeholder("Confirm Password"), .required, .type(.password), .placeholder("Password"), .required,
.pattern(.password), .minlength("8") .pattern(.password), .minlength("8")
) )
} }
}
div(.class("validator-hint hidden")) { if style == .signup {
p { label(.class("input validator w-full")) {
"Must be more than 8 characters, including" SVG(.key)
br() input(
"At least one number" .type(.password), .placeholder("Confirm Password"), .required,
br() .pattern(.password), .minlength("8")
"At least one lowercase letter" )
br() }
"At least one uppercase letter" }
div(.class("validator-hint hidden")) {
p {
"Must be more than 8 characters, including"
br()
"At least one number"
br()
"At least one lowercase letter"
br()
"At least one uppercase letter"
}
}
button(.class("btn btn-secondary mt-4")) { style.title }
a(
.class("btn btn-link mt-4"),
.href(route: style == .signup ? .login(.index) : .user(.signup(.index)))
) {
style == .login ? "Sign Up" : "Login"
} }
} }
button(.class("btn btn-neutral mt-4")) { style.title }
} }
} }
// div(.class("flex items-center justify-center")) {
// div(.class("w-full mx-auto")) {
// h1(.class("text-2xl font-bold")) { style.title }
// form(
// .class("w-full h-screen")
// ) {
// fieldset(.class("fieldset w-full")) {
// legend(.class("fieldset-legend")) { "Email" }
// label(.class("input validator")) {
// SVG(.email)
// input(
// .type(.email), .placeholder("mail@site.com"), .required,
// .autofocus
// )
// }
// div(.class("validator-hint hidden")) { "Enter valid email address." }
// }
//
// if style == .signup {
// fieldset(.class("fieldset")) {
// legend(.class("fieldset-legend")) { "Name" }
// label(.class("input validator")) {
// input(
// .type(.text), .placeholder("Username"), .required,
// .init(name: "pattern", value: "[A-Za-z][A-Za-z0-9\\-]*"),
// .init(name: "minlength", value: "3")
// )
// }
// div(.class("validator-hint hidden")) { "Enter valid email address." }
// }
// }
//
// fieldset(.class("fieldset")) {
// legend(.class("fieldset-legend")) { "Password" }
// label(.class("input validator")) {
// SVG(.key)
// input(
// .type(.password), .placeholder("Password"), .required,
// .init(name: "pattern", value: "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"),
// .init(name: "minlength", value: "8")
// )
// }
// if style == .signup {
// label(.class("input validator")) {
// SVG(.key)
// input(
// .type(.password), .placeholder("Confirm Password"), .required,
// .init(name: "pattern", value: "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"),
// .init(name: "minlength", value: "8")
// )
// }
// }
// div(.class("validator-hint hidden")) {
// p {
// "Must be more than 8 characters, including"
// br()
// "At least one number"
// br()
// "At least one lowercase letter"
// br()
// "At least one uppercase letter"
// }
// }
// }
//
// SubmitButton(title: style.title)
// }
// }
// }
} }
} }

1523
output.css

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
touch .build/browser-dev-sync touch .build/browser-dev-sync
# browser-sync start --proxy localhost:8080 --ws -w --no-notify & browser-sync start --proxy localhost:8080 --ws &
watchexec -w Sources -e .swift -r 'swift build --product App && touch .build/browser-dev-sync' & watchexec -w Sources -e .swift -r 'swift build --product App && touch .build/browser-dev-sync' &
watchexec -w .build/browser-dev-sync -r 'swift run App' watchexec -w .build/browser-dev-sync -r 'swift run App'