Browse Source

Middleware+View+UserState: Add toast messages

master
Riyyi 1 month ago
parent
commit
7f1e78fa08
  1. 14
      Public/css/style.css
  2. 14
      Public/js/site.js
  3. 54
      Sources/App/Middleware/StateMiddeware.swift
  4. 15
      Sources/App/UserState/ToastState.swift
  5. 32
      Sources/App/UserState/UserState.swift
  6. 13
      Sources/App/Views/Components/TodosTableComponent.swift
  7. 19
      Sources/App/Views/Shared/MainLayout.swift
  8. 53
      Sources/App/Views/Shared/ToastView.swift
  9. 6
      Sources/App/configure.swift
  10. 13
      Sources/App/routes.swift

14
Public/css/style.css

@ -1,3 +1,17 @@
/* -------------------------------------- */
/* Toast */
.toast-container {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
z-index: 999;
}
/* -------------------------------------- */
/* Table */
tr.htmx-swapping td { tr.htmx-swapping td {
opacity: 0; opacity: 0;
transition: opacity 0.5s ease-out; transition: opacity 0.5s ease-out;

14
Public/js/site.js

@ -0,0 +1,14 @@
// Activate Bootstrap tooltips
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(element => new bootstrap.Tooltip(element))
// -----------------------------------------
// HTMX events
function runOnceAfterSettle(func) {
// https://htmx.org/docs/#request-operations
document.addEventListener("htmx:afterSettle", function handler(event) {
func();
document.removeEventListener("htmx:afterSwap", handler);
});
}

54
Sources/App/Middleware/StateMiddeware.swift

@ -0,0 +1,54 @@
import Vapor
public func getState(request: Request) throws -> UserState {
guard let state = request.storage[UserStateKey.self] else {
throw Abort(.internalServerError)
}
return state
}
public final class StateMiddleware: AsyncMiddleware {
public func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
// This code is run *before* the route endpoint code
var setCookie: Bool = true
let uuid: UUID
if let sessionID = request.cookies["SWIFTSESSID"]?.string,
let sessionUUID = UUID(uuidString: sessionID) {
setCookie = false
uuid = sessionUUID
if !request.application.manager.states.keys.contains(uuid.uuidString) {
request.application.manager.states[uuid.uuidString] = UserState()
}
} else {
uuid = UUID()
// Register a new user state into the application storage
// https://docs.vapor.codes/advanced/services/
request.application.manager.states[uuid.uuidString] = UserState()
}
// Provide the user state to the request
request.storage[UserStateKey.self] = request.application.manager.states[uuid.uuidString]
let response = try await next.respond(to: request)
// This code is run *after* the route endpoint code
if setCookie {
response.cookies["SWIFTSESSID"] = HTTPCookies.Value(
string: uuid.uuidString,
path: "/",
isSecure: true,
isHTTPOnly: true
)
}
return response
}
}

15
Sources/App/UserState/ToastState.swift

@ -0,0 +1,15 @@
public struct ToastState: Sendable {
enum Level: String {
case success = "success"
case error = "danger"
case warning = "warning"
case info = "info"
case verbose = "secondary"
}
var message: String = ""
var title: String = ""
var level: Level = Level.verbose
}

32
Sources/App/UserState/UserState.swift

@ -0,0 +1,32 @@
import Vapor
// -----------------------------------------
public final class UserState: @unchecked Sendable {
var toast: ToastState = ToastState()
}
// -----------------------------------------
struct UserStateKey: StorageKey {
typealias Value = UserState
}
struct UserStateManager: Sendable {
var states: [String: UserState] = [:]
}
struct UserStateManagerKey : StorageKey {
typealias Value = UserStateManager
}
extension Application {
var manager: UserStateManager {
get {
self.storage[UserStateManagerKey.self]!
}
set {
self.storage[UserStateManagerKey.self] = newValue
}
}
}

13
Sources/App/Views/Components/TodosTableComponent.swift

@ -17,7 +17,10 @@ struct TodosTableComponent: HTML {
tr { tr {
th { "#" } th { "#" }
th { "ID" } th { "ID" }
th { "Title" } th {
"Title "
i(.class("bi bi-arrow-down-circle")) {}
}
th { "Modifier" } th { "Modifier" }
} }
} }
@ -32,10 +35,12 @@ struct TodosTableComponent: HTML {
td { todo.title ?? "" } td { todo.title ?? "" }
td { td {
if let id = todo.id { if let id = todo.id {
button( i(
.class("btn btn-danger"), .class("bi bi-trash3 text-danger"),
.data("bs-toggle", value: "tooltip"),
.data("bs-title", value: "Delete"),
.hx.delete("/\(name)/\(id.uuidString)") .hx.delete("/\(name)/\(id.uuidString)")
) { "Delete" } ) {}
} }
} }
} }

19
Sources/App/Views/Shared/MainLayout.swift

@ -19,17 +19,24 @@ struct MainLayout<Body: HTML>: HTMLDocument {
.href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"), .href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"),
.integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"), .integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"),
.crossorigin(.anonymous)) .crossorigin(.anonymous))
link(
.rel(.stylesheet),
.href("https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css"),
.integrity("sha384-XGjxtQfXaH2tnPFa9x+ruJTuLE3Aa6LhHSWRr1XeTyhezb4abCG4ccI5AkVDxqC+"),
.crossorigin(.anonymous))
link(.rel(.stylesheet), .href("/css/style.css")) link(.rel(.stylesheet), .href("/css/style.css"))
// --------------------------------- // ---------------------------------
// Style // Style
style { style {
""" """
body { body {
padding-top: 56px; padding-top: 56px;
} }
""" """
} }
} }
@ -50,6 +57,9 @@ body {
pageContent pageContent
} }
} }
// Placeholder for all toast messages
ToastView()
} }
// --------------------------------- // ---------------------------------
@ -76,6 +86,7 @@ body {
.integrity("sha384-yhWpPsq2os1hEnx1I8cH7Ius6rclwTm3G2fhXDLF6Pzv7UnSsXY7BAj4fB6PIgSz"), .integrity("sha384-yhWpPsq2os1hEnx1I8cH7Ius6rclwTm3G2fhXDLF6Pzv7UnSsXY7BAj4fB6PIgSz"),
.crossorigin(.anonymous) .crossorigin(.anonymous)
) {} ) {}
script(.src("/js/site.js")) {} script(.src("/js/site.js")) {}
} }

53
Sources/App/Views/Shared/ToastView.swift

@ -0,0 +1,53 @@
import Elementary
import ElementaryHTMX
// Usage:
//
// let state = try getState(request: req)
// state.toast = ToastState(message: "", title: "", level: ToastState.Level.error)
// throw Abort(.badRequest, headers: ["HX-Trigger": "toast"])
//
// The header "HX-Trigger" will make the part refresh and show the toast message
struct ToastView: HTML {
var state: ToastState = ToastState()
// -------------------------------------
var content: some HTML {
div(
.class("toast-container"), .id("cdiv_toast"),
.hx.get("/toast"),
.hx.trigger(HTMLAttributeValue.HTMX.EventTrigger(rawValue: "toast from:body")),
.hx.swap(.outerHTML)
) {
div(.class("toast"), .id("toast")) {
div(.class("toast-header bg-\(state.level.rawValue) text-white")) {
strong(.class("me-auto")) { state.title }
button(
.class("btn-close btn-close-white"), .type(.button),
.data("bs-dismiss", value: "toast")
) {}
}
div(.class("toast-body")) { state.message }
}
if !state.message.isEmpty {
script {
"""
runOnceAfterSettle(function () {
const element = document.getElementById("toast");
const toast = new bootstrap.Toast(element, { autohide: true, delay: 5000 });
toast.show();
element.addEventListener("hidden.bs.toast", function () {
element.remove();
});
});
"""
}
}
}
}
}

6
Sources/App/configure.swift

@ -6,8 +6,14 @@ import Vapor
// Configures your application // Configures your application
public func configure(_ app: Application) async throws { public func configure(_ app: Application) async throws {
app.middleware = .init() app.middleware = .init()
// Global user state management
app.middleware.use(StateMiddleware())
app.manager = .init()
// Error HTML pages or JSON responses // Error HTML pages or JSON responses
app.middleware.use(CustomErrorMiddleware(environment: app.environment)) app.middleware.use(CustomErrorMiddleware(environment: app.environment))
// Serve files from /Public folder // Serve files from /Public folder
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))

13
Sources/App/routes.swift

@ -6,7 +6,7 @@ import VaporElementary
func routes(_ app: Application) throws { func routes(_ app: Application) throws {
app.routes.caseInsensitive = true app.routes.caseInsensitive = true
app.get() { req async throws in app.get { req async throws in
HTMLResponse { HTMLResponse {
MainLayout(title: "Homepage") { MainLayout(title: "Homepage") {
IndexPage() IndexPage()
@ -18,6 +18,8 @@ func routes(_ app: Application) throws {
"Hello, world!" "Hello, world!"
} }
app.get("toast", use: toast)
try app.register(collection: TodoController()) try app.register(collection: TodoController())
try app.group("api") { api in try app.group("api") { api in
@ -25,6 +27,15 @@ func routes(_ app: Application) throws {
} }
} }
@Sendable
func toast(req: Request) throws -> HTMLResponse {
let state = try getState(request: req)
return HTMLResponse {
ToastView(state: state.toast)
}
}
/* /*
Closure Expression Syntax Closure Expression Syntax

Loading…
Cancel
Save