feat: Working on layout / css.

This commit is contained in:
2025-01-15 14:31:36 -05:00
parent cf28e52fa2
commit 24570e7191
15 changed files with 285 additions and 118 deletions

View File

@@ -23,6 +23,30 @@ header {
border-bottom: 1px solid grey; border-bottom: 1px solid grey;
} }
.col-1 {width: 8.33%;}
.col-2 {width: 16.66%;}
.col-3 {width: 25%;}
.col-4 {width: 33.33%;}
.col-5 {width: 41.66%;}
.col-6 {width: 50%;}
.col-7 {width: 58.33%;}
.col-8 {width: 66.66%;}
.col-9 {width: 75%;}
.col-10 {width: 83.33%;}
.col-11 {width: 91.66%;}
.col-12 {width: 100%;}
[class*="col-"] {
float: left;
padding: 15px;
}
.row::after {
content: "";
clear: both;
display: table;
}
#logo { #logo {
float: left; float: left;
font-size: 1.5em; font-size: 1.5em;
@@ -101,6 +125,7 @@ input[type=text], input[type=password], input[type=email], input[type=number] {
border: none; border: none;
border-bottom: 2px solid #555; border-bottom: 2px solid #555;
padding: 5px; padding: 5px;
font-size: 1.2em;
} }
select { select {
@@ -401,35 +426,25 @@ button.edit {
opacity: 0.8; opacity: 0.8;
} }
.row {
display: flex;
margin: 10px 0;
width: 100%;
}
.row .container {
display: inline;
}
.input { .input {
display: inline; display: inline;
width: 100%; width: 100%;
} }
.row .input { .row .input {
display: inline;
border: none; border: none;
} }
.row label { .row label {
display: inline-block; display: inline;
width: 400px;
margin-right: 20px;
} }
.htmx-swapping { .row .label {
opacity: 0; font-size: 1.25em;
transition: opacity 1s ease-in-out; }
.row .date {
font-size: 1.25em;
} }
#employee-detail form input[type=text] { #employee-detail form input[type=text] {
@@ -453,6 +468,11 @@ button.edit {
display: inline-block; display: inline-block;
} }
.htmx-swapping {
opacity: 0;
transition: opacity 1s ease-in-out;
}
.htmx-indicator { .htmx-indicator {
display: none; display: none;
} }
@@ -465,14 +485,28 @@ button.edit {
display: inline; display: inline;
} }
.btn-detail { .btn-detail, .btn-add, .btn-close {
border: none; border: none;
color: grey; color: grey;
text-decoration: .none; text-decoration: none;
background-color: inherit;
}
.btn-detail {
margin-left: 10px; margin-left: 10px;
} }
button:hover {
background-color: #444;
opacity: 0.8;
}
.btn-detail:hover { .btn-detail:hover {
background-color: #444; background-color: #444;
opacity: 0.8; opacity: 0.8;
} }
.btn-close {
float: right;
font-size: 1.5em;
}

View File

@@ -13,27 +13,68 @@ struct UserViewController: RouteCollection {
// let users = routes.protected.grouped("users") // let users = routes.protected.grouped("users")
let users = routes.grouped("users") let users = routes.grouped("users")
users.get(use: index) users.get(use: index)
users.post(use: create)
users.get("create", use: form)
users.group(":id") { users.group(":id") {
$0.post(use: update)
$0.get(use: get) $0.get(use: get)
$0.delete(use: delete)
} }
} }
@Sendable @Sendable
func index(req: Request) async throws -> HTMLResponse { func index(req: Request) async throws -> HTMLResponse {
HTMLResponse { try await req.render {
MainPage(route: .users) { try await mainPage(UserDetail(user: nil))
div(.class("container")) {
UserDetail(user: nil)
UserTable()
}
}
} }
} }
@Sendable @Sendable
func get(req: Request) async throws -> HTMLResponse { func get(req: Request) async throws -> HTMLResponse {
let user = try await users.get(req.ensureIDPathComponent()) let user = try await users.get(req.ensureIDPathComponent())
return HTMLResponse { UserDetail(user: user) } let detail = UserDetail(user: user)
guard req.isHtmxRequest else {
return try await req.render { try await mainPage(detail) }
}
return await req.render { UserDetail(user: user) }
}
@Sendable
func create(req: Request) async throws -> HTMLResponse {
_ = try await users.create(req.content.decode(User.Create.self))
let users = try await users.fetchAll()
// return req.redirect(to: "/users")
return await req.render { UserTable(users: users) }
}
@Sendable
func delete(req: Request) async throws -> HTTPStatus {
try await users.delete(req.ensureIDPathComponent())
return .ok
}
@Sendable
func form(req: Request) async throws -> HTMLResponse {
await req.render { UserForm(context: .create) }
}
@Sendable
func update(req: Request) async throws -> HTMLResponse {
let updates = try req.content.decode(User.Update.self)
req.logger.info("\(updates)")
let user = try await users.update(req.ensureIDPathComponent(), updates)
return await req.render { UserTable.Row(user: user) }
}
private func mainPage<C: HTML>(_ html: C) async throws -> some SendableHTMLDocument where C: Sendable {
let users = try await users.fetchAll()
return MainPage(displayNav: true, route: .users) {
div(.class("container")) {
html
UserTable(users: users)
}
}
} }
} }

View File

@@ -1,4 +1,6 @@
import Elementary
import Vapor import Vapor
import VaporElementary
extension Request { extension Request {
func ensureValidContent<T>(_ decoding: T.Type) throws -> T where T: Content, T: Validatable { func ensureValidContent<T>(_ decoding: T.Type) throws -> T where T: Content, T: Validatable {
@@ -19,4 +21,11 @@ extension Request {
var isHtmxRequest: Bool { var isHtmxRequest: Bool {
headers.contains(name: "hx-request") headers.contains(name: "hx-request")
} }
func render<C: HTML>(
@HTMLBuilder html: () async throws -> C
) async rethrows -> HTMLResponse where C: Sendable {
let html = try await html()
return HTMLResponse { html }
}
} }

View File

@@ -10,11 +10,29 @@ struct ToggleFormButton: HTML {
enum Button { enum Button {
static func add() -> some HTML<HTMLTag.button> {
button(.class("btn-add")) { "+" }
}
static func danger<C: HTML>(@HTMLBuilder body: () -> C) -> some HTML<HTMLTag.button> { static func danger<C: HTML>(@HTMLBuilder body: () -> C) -> some HTML<HTMLTag.button> {
button(.class("danger")) { body() } button(.class("danger")) { body() }
} }
static func close(id: String) -> some HTML<HTMLTag.button> { static func close(id: String, resetURL: String? = nil) -> some HTML<HTMLTag.button> {
button(.class("btn-add"), .on(.click, "toggleContent('\(id)')")) { "x" } button(.class("btn-close"), .on(.click, makeOnClick(id, resetURL))) {
"x"
}
}
static func update() -> some HTML<HTMLTag.button> {
button(.class("btn-update")) { "Update" }
}
private static func makeOnClick(_ id: String, _ resetURL: String?) -> String {
var output = "toggleContent('\(id)');"
if let resetURL {
return "\(output) window.location.href='\(resetURL)';"
}
return output
} }
} }

View File

@@ -3,21 +3,43 @@ import Elementary
struct Float<C: HTML>: HTML { struct Float<C: HTML>: HTML {
let id: String let id: String
let shouldDisplay: Bool
let body: C? let body: C?
let resetURL: String?
init(id: String = "float") { init(id: String = "float") {
self.id = id self.id = id
self.shouldDisplay = false
self.resetURL = nil
self.body = nil self.body = nil
} }
init(id: String = "float", @HTMLBuilder body: () -> C) { init(
id: String = "float",
shouldDisplay: Bool,
resetURL: String? = nil,
@HTMLBuilder body: () -> C
) {
self.id = id self.id = id
self.shouldDisplay = shouldDisplay
self.resetURL = resetURL
self.body = body() self.body = body()
} }
var content: some HTML { private var classString: String {
div(.id(id), .class("float")) { shouldDisplay ? "float" : ""
if let body { }
private var display: String {
shouldDisplay ? "block" : "hidden"
}
var content: some HTML<HTMLTag.div> {
div(.id(id), .class(classString), .style("display: \(display);")) {
if let body, shouldDisplay {
div(.class("btn-row")) {
Button.close(id: id, resetURL: resetURL)
}
body body
} }
} }

View File

@@ -1,7 +1,7 @@
import Elementary import Elementary
import ElementaryHTMX import ElementaryHTMX
struct MainPage<Inner: HTML>: HTMLDocument { struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
var title: String { "Purchase Orders" } var title: String { "Purchase Orders" }
@@ -38,7 +38,29 @@ struct MainPage<Inner: HTML>: HTMLDocument {
} }
} }
extension MainPage: Sendable where Inner: Sendable {} struct RouteHeaderView: HTML {
let title: String
let description: String
init(title: String, description: String) {
self.title = title
self.description = description
}
init(route: ViewRoute) {
self.init(title: route.title, description: route.description)
}
var content: some HTML {
div(.class("container"), .style("padding: 20px 20px;")) {
h1 { title }
br()
p { description }
br()
}
}
}
struct Logo: HTML, Sendable { struct Logo: HTML, Sendable {
@@ -48,3 +70,5 @@ struct Logo: HTML, Sendable {
} }
} }
} }
protocol SendableHTMLDocument: HTMLDocument, Sendable {}

View File

@@ -1,7 +1,7 @@
import Elementary import Elementary
import ElementaryHTMX import ElementaryHTMX
struct Navbar: HTML { struct Navbar: HTML, Sendable {
var content: some HTML { var content: some HTML {
div(.class("sidepanel"), .id("sidepanel")) { div(.class("sidepanel"), .id("sidepanel")) {
a(.href("javascript:void(0)"), .class("closebtn"), .on(.click, "closeSidepanel()")) { a(.href("javascript:void(0)"), .class("closebtn"), .on(.click, "closeSidepanel()")) {

View File

@@ -1,26 +0,0 @@
import Elementary
import ElementaryHTMX
struct RouteHeaderView: HTML {
let title: String
let description: String
init(title: String, description: String) {
self.title = title
self.description = description
}
init(route: ViewRoute) {
self.init(title: route.title, description: route.description)
}
var content: some HTML {
div(.class("container"), .style("padding: 20px 20px;")) {
h1 { title }
br()
p { description }
br()
}
}
}

View File

@@ -8,42 +8,41 @@ struct UserDetail: HTML, Sendable {
let user: User? let user: User?
var classString: String {
user != nil ? "float" : ""
}
var display: String {
user != nil ? "block" : "hidden"
}
var content: some HTML { var content: some HTML {
div( Float(shouldDisplay: user != nil, resetURL: "/users") {
.id("float"),
.class(classString),
.style("display: \(display);")
) {
if let user { if let user {
Button.close(id: "float") form(
form { .hx.post("/users/\(user.id)"),
.hx.swap(.outerHTML),
.hx.target("#user_\(user.id)"),
.custom(name: "hx-on::after-request", value: "toggleContent('float'); window.location.href='/users';")
) {
div(.class("row")) { div(.class("row")) {
makeLabel(for: "username", value: "Username:") makeLabel(for: "username", value: "Username:")
input(.type(.text), .name("username"), .value(user.username)) input(.class("col-5"), .type(.text), .id("username"), .name("username"), .value(user.username), .required)
makeLabel(for: "email", value: "Email:") makeLabel(for: "email", value: "Email:")
input(.type(.email), .name("email"), .value(user.username)) input(.class("col-5"), .type(.email), .id("email"), .name("email"), .value(user.email), .required)
} }
div(.class("row")) { div(.class("row")) {
div(.style("display: inline-block;")) { span(.class("label col-1")) { "Created:" }
h3(.class("label")) { "Created:" } span(.class("date col-4")) { dateFormatter.formattedDate(user.createdAt) }
h3 { dateFormatter.formattedDate(user.createdAt) } span(.class("label col-1")) { "Updated:" }
} span(.class("date col-4")) { dateFormatter.formattedDate(user.updatedAt) }
div(.style("display: inline-block;")) {
h3(.class("label")) { "Updated:" }
h3 { dateFormatter.formattedDate(user.updatedAt) }
}
}
} }
div(.class("btn-row user-buttons")) { div(.class("btn-row user-buttons")) {
button(
.type(.submit),
.style("background-color: blue; color: white;")
) { "Update" }
Button.danger { "Delete" } Button.danger { "Delete" }
.attributes(
.hx.delete("/users/\(user.id)"),
.hx.trigger(.event(.click)),
.hx.swap(.outerHTML),
.hx.target("#user_\(user.id)"),
.custom(name: "hx-on::after-request", value: "toggleContent('float'); window.location.href='/users';")
)
}
} }
} }
} }
@@ -53,7 +52,7 @@ struct UserDetail: HTML, Sendable {
for name: String, for name: String,
value: String value: String
) -> some HTML { ) -> some HTML {
label(.for(name)) { h3 { value } } label(.for(name), .class("col-1")) { span(.class("label")) { value } }
} }
func row(_ label: String, _ value: String) -> some HTML<HTMLTag.tr> { func row(_ label: String, _ value: String) -> some HTML<HTMLTag.tr> {

View File

@@ -5,12 +5,27 @@ struct UserForm: HTML, Sendable {
let context: Context let context: Context
var content: some HTML { var content: some HTML {
if context == .create {
Float(shouldDisplay: true) {
makeForm()
}
} else {
makeForm()
}
}
private func makeForm() -> some HTML {
form( form(
.id("user-form"), .id("user-form"),
.class("user-form"), .class("user-form"),
.hx.post(context.targetURL), .hx.post(context.targetURL),
.hx.pushURL(context.pushURL), .hx.pushURL(context.pushURL),
.custom(name: "hx-on::after-request", value: "if(event.detail.successful) this.reset(); toggleContent('form');") .hx.target(context.target),
.hx.swap(.outerHTML),
.custom(
name: "hx-on::after-request",
value: "if(event.detail.successful) this.reset(); toggleContent('float');"
)
) { ) {
input(.type(.text), .id("username"), .name("username"), .placeholder("Username"), .autofocus, .required) input(.type(.text), .id("username"), .name("username"), .placeholder("Username"), .autofocus, .required)
br() br()
@@ -21,14 +36,20 @@ struct UserForm: HTML, Sendable {
input(.type(.password), .id("password"), .name("password"), .placeholder("Password"), .required) input(.type(.password), .id("password"), .name("password"), .placeholder("Password"), .required)
br() br()
if context.showConfirmPassword { if context.showConfirmPassword {
input(.type(.password), .id("confirmPassword"), .name("confirmPassword"), .required) input(
.type(.password),
.id("confirmPassword"),
.name("confirmPassword"),
.placeholder("Confirm Password"),
.required
)
br() br()
} }
input(.type(.submit), .value(context.buttonLabel)) input(.type(.submit), .value(context.buttonLabel))
} }
} }
enum Context { enum Context: Equatable {
case create case create
case login(next: String?) case login(next: String?)
@@ -62,6 +83,15 @@ struct UserForm: HTML, Sendable {
} }
} }
var target: String {
switch self {
case .create:
return "next table"
case .login:
return "body"
}
}
var targetURL: String { var targetURL: String {
switch self { switch self {
case .create: case .create:

View File

@@ -1,19 +0,0 @@
import DatabaseClient
import Elementary
import SharedModels
struct UserIndex: HTML {
let user: User?
init(user: User? = nil) {
self.user = user
}
var content: some HTML {
div {
// UserDetail(user: user)
div(.id("float"), .class("float")) {}
UserTable()
}
}
}

View File

@@ -6,7 +6,7 @@ import SharedModels
struct UserTable: HTML { struct UserTable: HTML {
@Dependency(\.database.users.fetchAll) var fetchAll let users: [User]
var content: some HTML { var content: some HTML {
table(.id("user-table")) { table(.id("user-table")) {
@@ -14,11 +14,17 @@ struct UserTable: HTML {
tr { tr {
th { "Username" } th { "Username" }
th { "Email" } th { "Email" }
th(.style("width: 50px;")) { ToggleFormButton() } th(.style("width: 50px;")) {
Button.add()
.attributes(
.hx.get("/users/create"),
.hx.target("#float"),
.hx.swap(.outerHTML)
)
} }
} }
tbody { }
let users = try await fetchAll() tbody(.id("user-table-body")) {
for user in users { for user in users {
Row(user: user) Row(user: user)
} }
@@ -29,8 +35,12 @@ struct UserTable: HTML {
struct Row: HTML { struct Row: HTML {
let user: User let user: User
init(user: User) {
self.user = user
}
var content: some HTML<HTMLTag.tr> { var content: some HTML<HTMLTag.tr> {
tr { tr(.id("user_\(user.id)")) {
td { user.username } td { user.username }
td { user.email } td { user.email }
td { td {
@@ -38,6 +48,7 @@ struct UserTable: HTML {
.hx.get("/users/\(user.id.uuidString)"), .hx.get("/users/\(user.id.uuidString)"),
.hx.target("#float"), .hx.target("#float"),
.hx.swap(.outerHTML), .hx.swap(.outerHTML),
.hx.pushURL(true),
.class("btn-detail") .class("btn-detail")
) { ) {
"" ""

View File

@@ -16,12 +16,14 @@ public extension DatabaseClient {
public var login: @Sendable (User.Login) async throws -> User.Token public var login: @Sendable (User.Login) async throws -> User.Token
public var logout: @Sendable (User.Token.ID) async throws -> Void public var logout: @Sendable (User.Token.ID) async throws -> Void
public var token: @Sendable (User.ID) async throws -> User.Token public var token: @Sendable (User.ID) async throws -> User.Token
public var update: @Sendable (User.ID, User.Update) async throws -> User
} }
} }
extension User: Content {} extension User: Content {}
extension User.Create: Content {} extension User.Create: Content {}
extension User.Token: Content {} extension User.Token: Content {}
extension User.Update: Content {}
extension DatabaseClient.Users: TestDependencyKey { extension DatabaseClient.Users: TestDependencyKey {
public static let testValue: DatabaseClient.Users = Self() public static let testValue: DatabaseClient.Users = Self()

View File

@@ -66,6 +66,23 @@ public extension DatabaseClient.Users {
} }
return try token.toDTO() return try token.toDTO()
} update: { id, updates in
guard let user = try await UserModel.find(id, on: database) else {
throw Abort(.notFound)
}
var hasChanges = false
if let username = updates.username {
hasChanges = true
user.username = username
}
if let email = updates.email {
hasChanges = true
user.email = email
}
guard hasChanges else { return try user.toDTO() }
try await user.save(on: database)
return try user.toDTO()
} }
} }
} }

View File

@@ -77,6 +77,11 @@ public extension User {
} }
} }
struct Update: Codable, Equatable, Sendable {
public let username: String?
public let email: String?
}
} }
// public extension User { // public extension User {