WIP: Changes main page to not include sidebar, that moves to project view.

This commit is contained in:
2026-01-03 16:24:53 -05:00
parent 1d155546ae
commit 1aeb6144d5
15 changed files with 383 additions and 236 deletions

View File

@@ -0,0 +1,28 @@
import DatabaseClient
import Fluent
import ManualDCore
import Vapor
extension DatabaseClient.Projects {
func fetchPage(
userID: User.ID,
page: Int = 1,
limit: Int = 25
) async throws -> Page<Project> {
try await fetch(userID, .init(page: page, per: limit))
}
func fetchPage(
userID: User.ID,
page: PageRequest
) async throws -> Page<Project> {
try await fetch(userID, page)
}
}
extension PageRequest {
static func next<T>(_ currentPage: Page<T>) -> Self {
.init(page: currentPage.metadata.page + 1, per: currentPage.metadata.per)
}
}

View File

@@ -46,9 +46,6 @@ extension ViewController {
self.currentUser = currentUser
}
func authenticate(_ user: User) {
self.authenticateUser(user)
}
}
}
@@ -62,3 +59,30 @@ extension ViewController: DependencyKey {
}
)
}
extension ViewController.Request {
func authenticate(
_ login: User.Login
) async throws -> User {
@Dependency(\.database.users) var users
let token = try await users.login(login)
let user = try await users.get(token.userID)!
authenticateUser(user)
logger.debug("Logged in user: \(user.id)")
return user
}
func createAndAuthenticate(
_ signup: User.Create
) async throws -> User {
@Dependency(\.database.users) var users
let user = try await users.create(signup)
let _ = try await users.login(
.init(email: signup.email, password: signup.password)
)
authenticateUser(user)
logger.debug("Created and logged in user: \(user.id)")
return user
}
}

View File

@@ -14,24 +14,34 @@ extension ViewController.Request {
case .login(let route):
switch route {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
return view {
LoginForm()
}
case .submit(let login):
let token = try await database.users.login(login)
let user = try await database.users.get(token.userID)!
authenticate(user)
let user = try await authenticate(login)
let projects = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
return view {
ProjectsTable(userID: user.id, projects: projects)
}
}
case .signup(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
switch route {
case .index:
return view {
LoginForm(style: .signup)
}
case .submit(let request):
// Create a new user and log them in.
let user = try await createAndAuthenticate(request)
let projects = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
return view {
ProjectsTable(userID: user.id, projects: projects)
}
}
case .project(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
return try await route.renderView(on: self)
case .room(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
return try await route.renderView(on: self)
case .frictionRate(let route):
return try await route.renderView(isHtmxRequest: isHtmxRequest)
case .effectiveLength(let route):
@@ -40,54 +50,84 @@ extension ViewController.Request {
// return try await route.renderView(isHtmxRequest: isHtmxRequest)
default:
// FIX: FIX
return try await _render(isHtmxRequest: false) {
return _render(isHtmxRequest: false) {
div { "Fix me!" }
}
}
}
func view<C: HTML>(
@HTMLBuilder inner: () -> C
) -> AnySendableHTML where C: Sendable {
_render(isHtmxRequest: isHtmxRequest, showSidebar: showSidebar) {
inner()
}
}
var showSidebar: Bool {
switch route {
case .login, .signup, .project(.page):
return false
default:
return true
}
}
}
extension SiteRoute.View.ProjectRoute {
private var shouldShowSidebar: Bool {
func renderView(on request: ViewController.Request) async throws -> AnySendableHTML {
@Dependency(\.database) var database
let user = try request.currentUser()
switch self {
case .index, .page: return false
default: return true
}
}
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
@Dependency(\.database.projects) var projects
return try await _render(
isHtmxRequest: isHtmxRequest,
showSidebar: shouldShowSidebar
) {
switch self {
case .index:
// ProjectView(project: .mock)
let page = try await projects.fetch(UUID(0), .init(page: 1, per: 25))
ProjectsTable(userID: UUID(0), projects: page)
case .page(let page, let limit):
let page = try await projects.fetch(UUID(0), .init(page: page, per: limit))
ProjectsTable.Rows(projects: page)
case .form(let dismiss):
ProjectForm(dismiss: dismiss)
case .create:
div { "Fix me!" }
case .index:
let projects = try await database.projects.fetchPage(userID: user.id)
return request.view {
ProjectsTable(userID: user.id, projects: projects)
}
case .page(let page, let limit):
let projects = try await database.projects.fetchPage(
userID: user.id, page: page, limit: limit)
return ProjectsTable(userID: user.id, projects: projects)
case .form(let dismiss):
return ProjectForm(dismiss: dismiss)
case .create(let form):
let project = try await database.projects.create(user.id, form)
return request.view {
ProjectView(projectID: project.id, activeTab: .projects) {
ProjectDetail(project: project)
}
}
case .detail(let projectID):
let project = try await database.projects.get(projectID)!
return request.view {
ProjectView(projectID: projectID, activeTab: .projects) {
ProjectDetail(project: project)
}
}
}
}
}
extension SiteRoute.View.RoomRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
func renderView(on request: ViewController.Request) async throws -> AnySendableHTML {
@Dependency(\.database) var database
switch self {
case .form(let dismiss):
return RoomForm(dismiss: dismiss)
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, active: .rooms) {
RoomsView(rooms: Room.mocks)
case .index(let projectID):
let rooms = try await database.rooms.fetch(projectID)
return request.view {
ProjectView(projectID: projectID, activeTab: .rooms) {
RoomsView(rooms: rooms)
}
}
}
}
@@ -97,7 +137,7 @@ extension SiteRoute.View.FrictionRateRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, active: .frictionRate) {
return _render(isHtmxRequest: isHtmxRequest, active: .frictionRate) {
FrictionRateView()
}
case .form(let type, let dismiss):
@@ -128,7 +168,7 @@ extension SiteRoute.View.EffectiveLengthRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
switch self {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) {
return _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) {
EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks)
}
case .form(let dismiss):
@@ -145,52 +185,6 @@ extension SiteRoute.View.EffectiveLengthRoute {
}
}
extension SiteRoute.View.SignupRoute {
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
@Dependency(\.database.users) var users
switch self {
case .index:
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm(style: .signup)
}
case .submit(let request):
_ = try await users.create(request)
// FIX: We should just login the new user at this point.
return try await _render(isHtmxRequest: isHtmxRequest, showSidebar: false) {
LoginForm()
}
// default:
// return div { "Fix Me!" }
}
}
}
// extension SiteRoute.View.LoginRoute {
// func renderView(on req: ViewController.Request) async throws -> AnySendableHTML {
//
// @Dependency(\.database) var database
//
// return try await _render(isHtmxRequest: req.isHtmxRequest, showSidebar: false) {
// switch self {
// case .index:
// LoginForm()
// case .submit(let login):
// // FIX:
// // div { "Logged in Success! Fix me!" }
// let token = try await database.users.login(login)
// let user = try await database.users.get(token.userID)!
// _ = req.authenticate(user)
// // req.authenticate(user)
// let page = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
// ProjectsTable(userID: user.id, projects: page)
// }
// }
// }
// }
private func _render<C: HTML>(
isHtmxRequest: Bool,
active activeTab: Sidebar.ActiveTab = .projects,
@@ -201,10 +195,18 @@ private func _render<C: HTML>(
if isHtmxRequest {
return inner
}
return MainPage(
active: activeTab,
showSidebar: showSidebar
) {
inner
}
return MainPage { inner }
}
private func _render<C: HTML>(
isHtmxRequest: Bool,
active activeTab: Sidebar.ActiveTab = .projects,
showSidebar: Bool = true,
@HTMLBuilder inner: () -> C
) -> AnySendableHTML where C: Sendable {
let inner = inner()
if isHtmxRequest {
return inner
}
return MainPage { inner }
}

View File

@@ -1,19 +1,21 @@
import Elementary
public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
public var title: String { "Manual-D" }
public var lang: String { "en" }
let inner: Inner
let activeTab: Sidebar.ActiveTab
let showSidebar: Bool
// let activeTab: Sidebar.ActiveTab
// let showSidebar: Bool
init(
active activeTab: Sidebar.ActiveTab,
showSidebar: Bool = true,
// active activeTab: Sidebar.ActiveTab,
// showSidebar: Bool = true,
_ inner: () -> Inner
) {
self.activeTab = activeTab
self.showSidebar = showSidebar
// self.activeTab = activeTab
// self.showSidebar = showSidebar
self.inner = inner()
}
@@ -27,16 +29,8 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
}
public var body: some HTML {
// div(.class("bg-white dark:bg-gray-800 dark:text-white")) {
div {
div(.class("flex flex-row")) {
if showSidebar {
Sidebar(active: activeTab)
}
main(.class("flex flex-col h-screen w-full px-6 py-10")) {
inner
}
}
inner
}
script(.src("https://unpkg.com/lucide@latest")) {}
script {

View File

@@ -0,0 +1,59 @@
import Elementary
import ElementaryHTMX
import ManualDCore
import Styleguide
struct ProjectDetail: HTML, Sendable {
let project: Project
var body: some HTML {
div(
.class(
"""
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4 m-4
"""
)
) {
Row {
h1(.class("text-2xl font-bold")) { "Project" }
EditButton()
.attributes(
.hx.get(route: .project(.form(dismiss: false))),
.hx.target("#projectForm"),
.hx.swap(.outerHTML)
)
}
Row {
Label("Name")
span { project.name }
}
.attributes(.class("border-b border-gray-200"))
Row {
Label("Address")
span { project.streetAddress }
}
.attributes(.class("border-b border-gray-200"))
Row {
Label("City")
span { project.city }
}
.attributes(.class("border-b border-gray-200"))
Row {
Label("State")
span { project.state }
}
.attributes(.class("border-b border-gray-200"))
Row {
Label("Zip")
span { project.zipCode }
}
}
div(.id("projectForm")) {}
}
}

View File

@@ -19,7 +19,11 @@ struct ProjectForm: HTML, Sendable {
var body: some HTML {
ModalForm(id: "projectForm", dismiss: dismiss) {
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Project" }
form(.class("space-y-4 p-4")) {
form(
.class("space-y-4 p-4"),
.method(.post),
.action(route: .project(.index))
) {
div {
label(.for("name")) { "Name" }
Input(id: "name", placeholder: "Name")

View File

@@ -3,57 +3,113 @@ import ElementaryHTMX
import ManualDCore
import Styleguide
struct ProjectView: HTML, Sendable {
let project: Project
struct ProjectView<Inner: HTML>: HTML, Sendable where Inner: Sendable {
let projectID: Project.ID
let activeTab: Sidebar.ActiveTab
let inner: Inner
init(
projectID: Project.ID,
activeTab: Sidebar.ActiveTab,
@HTMLBuilder inner: () -> Inner
) {
self.projectID = projectID
self.activeTab = activeTab
self.inner = inner()
}
var body: some HTML {
div(
div {
div(.class("flex flex-row")) {
Sidebar(active: activeTab, projectID: projectID)
main(.class("flex flex-col h-screen w-full px-6 py-10")) {
inner
}
}
}
}
}
// TODO: Update to use DaisyUI drawer.
struct Sidebar: HTML {
let active: ActiveTab
let projectID: Project.ID
var body: some HTML {
aside(
.class(
"""
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4 m-4
h-screen sticky top-0 max-w-[280px] flex-none
border-r-2 border-gray-200
shadow-lg
"""
)
) {
Row {
h1(.class("text-2xl font-bold")) { "Project" }
EditButton()
.attributes(
.hx.get(route: .project(.form(dismiss: false))),
.hx.target("#projectForm"),
.hx.swap(.outerHTML)
)
}
// TODO: Move somewhere outside of the sidebar.
Row {
Label("Name")
span { project.name }
Label("Theme")
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
}
.attributes(.class("border-b border-gray-200"))
.attributes(.class("p-4"))
Row {
Label("Address")
span { project.streetAddress }
}
.attributes(.class("border-b border-gray-200"))
row(title: "Project", icon: .mapPin, route: .project(.index))
.attributes(.data("active", value: active == .projects ? "true" : "false"))
Row {
Label("City")
span { project.city }
}
.attributes(.class("border-b border-gray-200"))
row(title: "Rooms", icon: .doorClosed, route: .room(.index(projectID)))
.attributes(.data("active", value: active == .rooms ? "true" : "false"))
Row {
Label("State")
span { project.state }
}
.attributes(.class("border-b border-gray-200"))
row(title: "Equivalent Lengths", icon: .rulerDimensionLine, route: .effectiveLength(.index))
.attributes(.data("active", value: active == .effectiveLength ? "true" : "false"))
Row {
Label("Zip")
span { project.zipCode }
row(title: "Friction Rate", icon: .squareFunction, route: .frictionRate(.index))
.attributes(.data("active", value: active == .frictionRate ? "true" : "false"))
row(title: "Duct Sizes", icon: .wind, href: "#")
.attributes(.data("active", value: active == .ductSizing ? "true" : "false"))
}
}
// TODO: Use SiteRoute.View routes as href.
private func row(
title: String,
icon: Icon.Key,
href: String
) -> some HTML<HTMLTag.a> {
a(
.class(
"""
flex w-full items-center gap-4
hover:bg-gray-300 hover:text-gray-800
data-[active=true]:bg-gray-300 data-[active=true]:text-gray-800
px-4 py-2
"""
),
.href(href)
) {
Icon(icon)
span(.class("text-xl")) {
title
}
}
}
div(.id("projectForm")) {}
private func row(
title: String,
icon: Icon.Key,
route: SiteRoute.View
) -> some HTML<HTMLTag.a> {
row(title: title, icon: icon, href: SiteRoute.View.router.path(for: route))
}
}
extension Sidebar {
enum ActiveTab: Equatable, Sendable {
case projects
case rooms
case effectiveLength
case frictionRate
case ductSizing
}
}

View File

@@ -24,7 +24,10 @@ struct ProjectsTable: HTML, Sendable {
.data("tip", value: "Add project")
) {
button(
.class("btn btn-primary w-[40px] text-2xl")
.class("btn btn-primary w-[40px] text-2xl"),
.hx.get(route: .project(.form(dismiss: false))),
.hx.target("#projectForm"),
.hx.swap(.outerHTML)
) {
"+"
}
@@ -39,6 +42,7 @@ struct ProjectsTable: HTML, Sendable {
th { Label("Date") }
th { Label("Name") }
th { Label("Address") }
th {}
}
}
tbody {
@@ -46,6 +50,8 @@ struct ProjectsTable: HTML, Sendable {
}
}
}
ProjectForm(dismiss: true)
}
}
}
@@ -57,9 +63,15 @@ extension ProjectsTable {
var body: some HTML {
for project in projects.items {
tr(.id("\(project.id)")) {
td { "\(project.createdAt)" }
td { DateView(project.createdAt) }
td { "\(project.name)" }
td { "\(project.streetAddress)" }
td {
a(
.class("btn btn-success"),
.href(route: .project(.detail(project.id)))
) { ">" }
}
}
}
// Have a row that when revealed fetches the next page,

View File

@@ -1,86 +0,0 @@
import Elementary
import ManualDCore
import Styleguide
// TODO: Update to use DaisyUI drawer.
struct Sidebar: HTML {
let active: ActiveTab
var body: some HTML {
aside(
.class(
"""
h-screen sticky top-0 max-w-[280px] flex-none
border-r-2 border-gray-200
shadow-lg
"""
)
) {
// TODO: Move somewhere outside of the sidebar.
Row {
Label("Theme")
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
}
.attributes(.class("p-4"))
row(title: "Project", icon: .mapPin, route: .project(.index))
.attributes(.data("active", value: active == .projects ? "true" : "false"))
row(title: "Rooms", icon: .doorClosed, route: .room(.index))
.attributes(.data("active", value: active == .rooms ? "true" : "false"))
row(title: "Equivalent Lengths", icon: .rulerDimensionLine, route: .effectiveLength(.index))
.attributes(.data("active", value: active == .effectiveLength ? "true" : "false"))
row(title: "Friction Rate", icon: .squareFunction, route: .frictionRate(.index))
.attributes(.data("active", value: active == .frictionRate ? "true" : "false"))
row(title: "Duct Sizes", icon: .wind, href: "#")
.attributes(.data("active", value: active == .ductSizing ? "true" : "false"))
}
}
// TODO: Use SiteRoute.View routes as href.
private func row(
title: String,
icon: Icon.Key,
href: String
) -> some HTML<HTMLTag.a> {
a(
.class(
"""
flex w-full items-center gap-4
hover:bg-gray-300 hover:text-gray-800
data-[active=true]:bg-gray-300 data-[active=true]:text-gray-800
px-4 py-2
"""
),
.href(href)
) {
Icon(icon)
span(.class("text-xl")) {
title
}
}
}
private func row(
title: String,
icon: Icon.Key,
route: SiteRoute.View
) -> some HTML<HTMLTag.a> {
row(title: title, icon: icon, href: SiteRoute.View.router.path(for: route))
}
}
extension Sidebar {
enum ActiveTab: Equatable, Sendable {
case projects
case rooms
case effectiveLength
case frictionRate
case ductSizing
}
}