WIP: Begins work on login / signup, adds user database models, authentication needs implemented.

This commit is contained in:
2026-01-02 17:00:50 -05:00
parent 4750842a57
commit 6602c4a8b5
13 changed files with 819 additions and 85 deletions

View File

@@ -6051,6 +6051,9 @@
} }
} }
} }
.mt-4 {
margin-top: calc(var(--spacing) * 4);
}
.mt-8 { .mt-8 {
margin-top: calc(var(--spacing) * 8); margin-top: calc(var(--spacing) * 8);
} }
@@ -6133,6 +6136,12 @@
.mb-2 { .mb-2 {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
.mb-6 {
margin-bottom: calc(var(--spacing) * 6);
}
.mb-8 { .mb-8 {
margin-bottom: calc(var(--spacing) * 8); margin-bottom: calc(var(--spacing) * 8);
} }
@@ -6956,6 +6965,9 @@
.h-36 { .h-36 {
height: calc(var(--spacing) * 36); height: calc(var(--spacing) * 36);
} }
.h-\[1em\] {
height: 1em;
}
.h-\[var\(--radix-select-trigger-height\)\] { .h-\[var\(--radix-select-trigger-height\)\] {
height: var(--radix-select-trigger-height); height: var(--radix-select-trigger-height);
} }
@@ -7135,6 +7147,12 @@
.w-\[100px\] { .w-\[100px\] {
width: 100px; width: 100px;
} }
.w-\[400px\] {
width: 400px;
}
.w-\[600px\] {
width: 600px;
}
.w-auto { .w-auto {
width: auto; width: auto;
} }
@@ -7147,6 +7165,12 @@
.w-screen { .w-screen {
width: 100vw; width: 100vw;
} }
.w-xl {
width: var(--container-xl);
}
.w-xs {
width: var(--container-xs);
}
.max-w-2xl { .max-w-2xl {
max-width: var(--container-2xl); max-width: var(--container-2xl);
} }
@@ -7872,6 +7896,9 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.overflow-x-auto {
overflow-x: auto;
}
.overflow-x-hidden { .overflow-x-hidden {
overflow-x: hidden; overflow-x: hidden;
} }
@@ -8223,6 +8250,10 @@
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 0px; border-width: 0px;
} }
.border-1 {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-2 { .border-2 {
border-style: var(--tw-border-style); border-style: var(--tw-border-style);
border-width: 2px; border-width: 2px;
@@ -8399,6 +8430,18 @@
.border-\[\#fbf0df\] { .border-\[\#fbf0df\] {
border-color: #fbf0df; border-color: #fbf0df;
} }
.border-base-300 {
border-color: var(--color-base-300);
}
.border-base-content\/5 {
border-color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
border-color: color-mix(in oklab, var(--color-base-content) 5%, transparent);
}
}
.border-gray-100 {
border-color: var(--color-gray-100);
}
.border-gray-200 { .border-gray-200 {
border-color: var(--color-gray-200); border-color: var(--color-gray-200);
} }
@@ -8565,6 +8608,12 @@
.bg-\[\#fbf0df\] { .bg-\[\#fbf0df\] {
background-color: #fbf0df; background-color: #fbf0df;
} }
.bg-base-100 {
background-color: var(--color-base-100);
}
.bg-base-200 {
background-color: var(--color-base-200);
}
.bg-blue-400 { .bg-blue-400 {
background-color: var(--color-blue-400); background-color: var(--color-blue-400);
} }
@@ -10168,6 +10217,9 @@
.text-blue-400 { .text-blue-400 {
color: var(--color-blue-400); color: var(--color-blue-400);
} }
.text-error {
color: var(--color-error);
}
.text-gray-400 { .text-gray-400 {
color: var(--color-gray-400); color: var(--color-gray-400);
} }
@@ -10177,6 +10229,9 @@
.text-green-400 { .text-green-400 {
color: var(--color-green-400); color: var(--color-green-400);
} }
.text-info {
color: var(--color-info);
}
.text-primary { .text-primary {
color: var(--color-primary); color: var(--color-primary);
} }
@@ -10186,6 +10241,9 @@
.text-slate-900 { .text-slate-900 {
color: var(--color-slate-900); color: var(--color-slate-900);
} }
.text-success {
color: var(--color-success);
}
.text-white { .text-white {
color: var(--color-white); color: var(--color-white);
} }

View File

@@ -57,6 +57,8 @@ extension DatabaseClient.Migrations: DependencyKey {
public static let liveValue = Self( public static let liveValue = Self(
run: { run: {
[ [
User.Migrate(),
User.Token.Migrate(),
Project.Migrate(), Project.Migrate(),
ComponentPressureLoss.Migrate(), ComponentPressureLoss.Migrate(),
EquipmentInfo.Migrate(), EquipmentInfo.Migrate(),

View File

@@ -0,0 +1,294 @@
import Dependencies
import DependenciesMacros
import Fluent
import ManualDCore
import Vapor
extension DatabaseClient {
@DependencyClient
public struct Users: Sendable {
public var create: @Sendable (User.Create) async throws -> User
public var delete: @Sendable (User.ID) async throws -> Void
public var get: @Sendable (User.ID) async throws -> User?
public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void
// public var token: @Sendable (User.ID) async throws -> User.Token
}
}
extension DatabaseClient.Users: TestDependencyKey {
public static let testValue = Self()
public static func live(database: any Database) -> Self {
.init(
create: { request in
let model = try request.toModel()
try await model.save(on: database)
return try model.toDTO()
},
delete: { id in
guard let model = try await UserModel.find(id, on: database) else {
throw NotFoundError()
}
try await model.delete(on: database)
},
get: { id in
try await UserModel.find(id, on: database).map { try $0.toDTO() }
},
login: { request in
guard
let user = try await UserModel.query(on: database)
.with(\.$token)
.filter(\UserModel.$email == request.email)
.first()
else {
throw NotFoundError()
}
let token: User.Token
// Check if there's a user token
if let userToken = user.token {
token = try userToken.toDTO()
} else {
// generate a new token
let tokenModel = try user.generateToken()
try await tokenModel.save(on: database)
token = try tokenModel.toDTO()
}
return token
},
logout: { tokenID in
guard let token = try await UserTokenModel.find(tokenID, on: database) else { return }
try await token.delete(on: database)
}
// ,
// token: { id in
// }
)
}
}
extension User {
struct Migrate: AsyncMigration {
let name = "CreateUser"
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("created_at", .datetime)
.field("updated_at", .datetime)
.unique(on: "email", "username")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserModel.schema).delete()
}
}
}
extension User.Token {
struct Migrate: AsyncMigration {
let name = "CreateUserToken"
func prepare(on database: any Database) async throws {
try await database.schema(UserTokenModel.schema)
.id()
.field("value", .string, .required)
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
.unique(on: "value")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(UserTokenModel.schema).delete()
}
}
}
extension User {
static func hashPassword(_ password: String) throws -> String {
try Bcrypt.hash(password, cost: 12)
}
}
extension User.Create {
func toModel() throws -> UserModel {
try validate()
return try .init(username: username, 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")
}
guard password.count > 8 else {
throw ValidationError("Password should be more than 8 characters long.")
}
guard password == confirmPassword else {
throw ValidationError("Passwords do not match.")
}
}
}
final class UserModel: Model, @unchecked Sendable {
static let schema = "user"
@ID(key: .id)
var id: UUID?
@Field(key: "username")
var username: String
@Field(key: "email")
var email: String
@Field(key: "password_hash")
var passwordHash: String
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
var createdAt: Date?
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
var updatedAt: Date?
@OptionalChild(for: \.$user)
var token: UserTokenModel?
init() {}
init(
id: UUID? = nil,
username: String,
email: String,
passwordHash: String
) {
self.id = id
self.username = username
self.email = email
self.passwordHash = passwordHash
}
func toDTO() throws -> User {
try .init(
id: requireID(),
email: email,
username: username,
createdAt: createdAt!,
updatedAt: updatedAt!
)
}
func generateToken() throws -> UserTokenModel {
try .init(
value: [UInt8].random(count: 16).base64,
userID: requireID()
)
}
func verifyPassword(_ password: String) throws -> Bool {
try Bcrypt.verify(password, created: passwordHash)
}
}
final class UserTokenModel: Model, Codable, @unchecked Sendable {
static let schema = "user_token"
@ID(key: .id)
var id: UUID?
@Field(key: "value")
var value: String
@Parent(key: "user_id")
var user: UserModel
init() {}
init(id: UUID? = nil, value: String, userID: UserModel.IDValue) {
self.id = id
self.value = value
$user.id = userID
}
func toDTO() throws -> User.Token {
try .init(id: requireID(), userID: $user.id, value: value)
}
}
// MARK: - Authentication
extension User: Authenticatable {}
extension User: SessionAuthenticatable {
public var sessionID: String { username }
}
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
public typealias User = ManualDCore.User
public init() {}
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)
.first(),
try user.verifyPassword(basic.password)
else {
throw Abort(.unauthorized)
}
try request.auth.login(user.toDTO())
}
}
public struct UserTokenAuthenticator: AsyncBearerAuthenticator {
public typealias User = ManualDCore.User
public init() {}
public func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
guard
let token = try await UserTokenModel.query(on: request.db)
.filter(\UserTokenModel.$value == bearer.token)
.with(\UserTokenModel.$user)
.first()
else {
throw Abort(.unauthorized)
}
try request.auth.login(token.user.toDTO())
}
}
public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
public typealias User = ManualDCore.User
public init() {}
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)
.first()
else {
throw Abort(.unauthorized)
}
try request.auth.login(user.toDTO())
}
}

View File

@@ -11,6 +11,7 @@ extension SiteRoute {
case room(RoomRoute) case room(RoomRoute)
case frictionRate(FrictionRateRoute) case frictionRate(FrictionRateRoute)
case effectiveLength(EffectiveLengthRoute) case effectiveLength(EffectiveLengthRoute)
case user(UserRoute)
public static let router = OneOf { public static let router = OneOf {
Route(.case(Self.project)) { Route(.case(Self.project)) {
@@ -25,6 +26,9 @@ extension SiteRoute {
Route(.case(Self.effectiveLength)) { Route(.case(Self.effectiveLength)) {
SiteRoute.View.EffectiveLengthRoute.router SiteRoute.View.EffectiveLengthRoute.router
} }
Route(.case(Self.user)) {
SiteRoute.View.UserRoute.router
}
} }
} }
} }
@@ -173,3 +177,74 @@ extension SiteRoute.View.EffectiveLengthRoute {
case group case group
} }
} }
extension SiteRoute.View {
public enum UserRoute: Equatable, Sendable {
case login(Login)
case signup(Signup)
public static let router = OneOf {
Route(.case(Self.login)) {
SiteRoute.View.UserRoute.Login.router
}
Route(.case(Self.signup)) {
SiteRoute.View.UserRoute.Signup.router
}
}
}
}
extension SiteRoute.View.UserRoute {
public enum Login: Equatable, Sendable {
case index
case submit(User.Login)
static let rootPath = "login"
public static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("email", .string)
Field("password", .string)
}
.map(.memberwise(User.Login.init))
}
}
}
}
public enum Signup: Equatable, Sendable {
case index
case submit(User.Create)
static let rootPath = "signup"
public static let router = OneOf {
Route(.case(Self.index)) {
Path { rootPath }
Method.get
}
Route(.case(Self.submit)) {
Path { rootPath }
Method.post
Body {
FormData {
Field("username", .string)
Field("email", .string)
Field("password", .string)
Field("confirmPassword", .string)
}
.map(.memberwise(User.Create.init))
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
import Dependencies
import Foundation
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
}
}
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
}
}
public struct Login: Codable, Equatable, Sendable {
public let email: String
public let password: String
public init(email: String, password: String) {
self.email = email
self.password = password
}
}
public struct Token: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public let userID: User.ID
public let value: String
public init(id: UUID, userID: User.ID, value: String) {
self.id = id
self.userID = userID
self.value = value
}
}
}

View File

@@ -45,7 +45,7 @@ public struct Input: HTML, Sendable {
.id(id ?? ""), .name(_name), .placeholder(placeholder), .id(id ?? ""), .name(_name), .placeholder(placeholder),
.class( .class(
""" """
w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1 input w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
-outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2 -outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2
focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500 focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500
""" """
@@ -67,4 +67,30 @@ extension HTMLAttribute where Tag == HTMLTag.input {
public static func step(_ value: String) -> Self { public static func step(_ value: String) -> Self {
.init(name: "step", value: value) .init(name: "step", value: value)
} }
public static func minlength(_ value: String) -> Self {
.init(name: "minlength", value: value)
}
public static func pattern(value: String) -> Self {
.init(name: "pattern", value: value)
}
public static func pattern(_ type: PatternType) -> Self {
pattern(value: type.value)
}
}
public enum PatternType: Sendable {
case password
case username
var value: String {
switch self {
case .password:
return "(?=.*\\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"
case .username:
return "[A-Za-z][A-Za-z0-9\\-]*"
}
}
} }

View File

@@ -17,7 +17,10 @@ extension SVG {
public enum Key: Sendable { public enum Key: Sendable {
case circlePlus case circlePlus
case close case close
case email
case key
case squarePen case squarePen
case user
var svg: String { var svg: String {
switch self { switch self {
@@ -29,10 +32,57 @@ 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-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>
""" """
case .email:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
</g>
</svg>
"""
case .key:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<path
d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"
></path>
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor"></circle>
</g>
</svg>
"""
case .squarePen: case .squarePen:
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-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></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-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
""" """
case .user:
return """
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</g>
</svg>
"""
} }
} }
} }

View File

@@ -13,6 +13,8 @@ extension ViewController.Request {
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .effectiveLength(let route): case .effectiveLength(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest) return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .user(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
default: default:
// FIX: FIX // FIX: FIX
return mainPage return mainPage
@@ -101,6 +103,24 @@ extension SiteRoute.View.EffectiveLengthRoute {
} }
} }
extension SiteRoute.View.UserRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self {
case .login(.index):
return MainPage(active: .projects, showSidebar: false) {
LoginForm()
}
case .signup(.index):
return MainPage(active: .projects, showSidebar: false) {
LoginForm(style: .signup)
}
default:
return div { "Fix Me!" }
}
}
}
private let mainPage: AnySendableHTML = { private let mainPage: AnySendableHTML = {
MainPage(active: .projects) { MainPage(active: .projects) {
div { div {

View File

@@ -5,9 +5,15 @@ 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 activeTab: Sidebar.ActiveTab let activeTab: Sidebar.ActiveTab
let showSidebar: Bool
init(active activeTab: Sidebar.ActiveTab, _ inner: () -> Inner) { init(
active activeTab: Sidebar.ActiveTab,
showSidebar: Bool = true,
_ inner: () -> Inner
) {
self.activeTab = activeTab self.activeTab = activeTab
self.showSidebar = showSidebar
self.inner = inner() self.inner = inner()
} }
@@ -24,7 +30,9 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
// div(.class("bg-white dark:bg-gray-800 dark:text-white")) { // div(.class("bg-white dark:bg-gray-800 dark:text-white")) {
div { div {
div(.class("flex flex-row")) { div(.class("flex flex-row")) {
if showSidebar {
Sidebar(active: activeTab) Sidebar(active: activeTab)
}
main(.class("flex flex-col h-screen w-full px-6 py-10")) { main(.class("flex flex-col h-screen w-full px-6 py-10")) {
inner inner
} }

View File

@@ -9,9 +9,9 @@ struct RoomsView: HTML, Sendable {
let rooms: [Room] let rooms: [Room]
var body: some HTML { var body: some HTML {
div(.class("m-10")) { div {
Row { Row {
h1(.class("text-3xl font-bold pb-6")) { "Room Loads" } h1(.class("text-2xl font-bold")) { "Room Loads" }
div( div(
.class("tooltip tooltip-left"), .class("tooltip tooltip-left"),
.data("tip", value: "Add room") .data("tip", value: "Add room")
@@ -20,94 +20,67 @@ struct RoomsView: HTML, Sendable {
.hx.get(route: .room(.form(dismiss: false))), .hx.get(route: .room(.form(dismiss: false))),
.hx.target("#roomForm"), .hx.target("#roomForm"),
.hx.swap(.outerHTML), .hx.swap(.outerHTML),
.class("btn btn-primary w-[40px]") .class("btn btn-primary w-[40px] text-2xl")
) { ) {
"+" "+"
} }
} }
} }
.attributes(.class("pb-6"))
div( div(.class("overflow-x-auto rounded-box border")) {
.id("roomTable"), table(.class("table table-zebra"), .id("roomsTable")) {
.class( thead {
""" tr {
border border-gray-200 rounded-lg shadow-lg th { Label("Name") }
grid grid-cols-5 p-4 th { Label("Heating Load") }
""" th { Label("Cooling Total") }
) th { Label("Cooling Sensible") }
) { th { Label("Register Count") }
// Header
Label("Name")
// Pushes items to right
Row {
div {}
Label("Heating Load")
} }
Row {
div {}
Label("Cooling Total")
} }
Row { tbody {
div {} for room in rooms {
Label("Cooling Sensible") tr {
td { room.name }
td {
Number(room.heatingLoad)
.attributes(.class("text-error"))
} }
Row { td {
div {} Number(room.coolingLoad.total)
Label("Register Count") .attributes(.class("text-success"))
} }
td {
// Divider Number(room.coolingLoad.sensible)
div(.class("border-b border-gray-200 col-span-5 mb-2")) {} .attributes(.class("text-info"))
// Rows
for row in rooms {
span { row.name }
// Pushes items to right
Row {
div {}
Number(row.heatingLoad)
.attributes(.class("text-red-500"))
} }
Row { td {
div {} Number(room.registerCount)
Number(row.coolingLoad.total)
.attributes(.class("text-green-400"))
} }
Row {
div {}
Number(row.coolingLoad.sensible)
.attributes(.class("text-blue-400"))
} }
Row {
div {}
Number(row.registerCount)
} }
// TOTALS
// Divider tr(.class("font-bold text-xl")) {
div(.class("border-b border-gray-200 col-span-5 mb-2")) {} td { Label("Total") }
} td {
// Totals
Label("Total")
Row {
div {}
Number(rooms.heatingTotal) Number(rooms.heatingTotal)
.attributes(.class("badge badge-outline badge-error badge-xl text-xl font-bold")) .attributes(.class("badge badge-outline badge-error badge-xl"))
} }
Row { td {
div {}
Number(rooms.coolingTotal) Number(rooms.coolingTotal)
.attributes(.class("badge badge-outline badge-success badge-xl text-xl font-bold")) .attributes(
.class("badge badge-outline badge-success badge-xl"))
} }
Row { td {
div {}
Number(rooms.coolingSensibleTotal) Number(rooms.coolingSensibleTotal)
.attributes(.class("badge badge-outline badge-info badge-xl text-xl font-bold")) .attributes(.class("badge badge-outline badge-info badge-xl"))
}
td {}
}
}
} }
// Empty register count column
div {}
} }
RoomForm(dismiss: true) RoomForm(dismiss: true)
} }
} }

View File

@@ -2,7 +2,7 @@ import Elementary
import ManualDCore import ManualDCore
import Styleguide import Styleguide
// TODO: Need to add active to sidebar links. // TODO: Update to use DaisyUI drawer.
struct Sidebar: HTML { struct Sidebar: HTML {
let active: ActiveTab let active: ActiveTab
@@ -23,7 +23,7 @@ struct Sidebar: HTML {
Label("Theme") Label("Theme")
input(.type(.checkbox), .class("toggle theme-controller"), .value("light")) input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
} }
.attributes(.class("py-4")) .attributes(.class("p-4"))
row(title: "Project", icon: .mapPin, route: .project(.index)) row(title: "Project", icon: .mapPin, route: .project(.index))
.attributes(.data("active", value: active == .projects ? "true" : "false")) .attributes(.data("active", value: active == .projects ? "true" : "false"))

View File

@@ -0,0 +1,158 @@
import Elementary
import ElementaryHTMX
import Styleguide
struct LoginForm: HTML, Sendable {
let style: Style
init(style: Style = .login) {
self.style = style
}
var body: some HTML {
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"
}
}
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")) {
SVG(.key)
input(
.type(.password), .placeholder("Confirm Password"), .required,
.pattern(.password), .minlength("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"
}
}
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)
// }
// }
// }
}
}
extension LoginForm {
enum Style: Equatable, Sendable {
case login
case signup
var title: String {
switch self {
case .login: return "Login"
case .signup: return "Sign Up"
}
}
}
}