From 7f1e78fa0823103f55c74578e29ed6bd795a1dce Mon Sep 17 00:00:00 2001
From: Riyyi <riyyi3@gmail.com>
Date: Thu, 21 Nov 2024 22:48:01 +0100
Subject: [PATCH] Middleware+View+UserState: Add toast messages

---
 Public/css/style.css                          | 14 +++++
 Public/js/site.js                             | 14 +++++
 Sources/App/Middleware/StateMiddeware.swift   | 54 +++++++++++++++++++
 Sources/App/UserState/ToastState.swift        | 15 ++++++
 Sources/App/UserState/UserState.swift         | 32 +++++++++++
 .../Components/TodosTableComponent.swift      | 13 +++--
 Sources/App/Views/Shared/MainLayout.swift     | 21 ++++++--
 Sources/App/Views/Shared/ToastView.swift      | 53 ++++++++++++++++++
 Sources/App/configure.swift                   |  6 +++
 Sources/App/routes.swift                      | 13 ++++-
 10 files changed, 225 insertions(+), 10 deletions(-)
 create mode 100644 Sources/App/Middleware/StateMiddeware.swift
 create mode 100644 Sources/App/UserState/ToastState.swift
 create mode 100644 Sources/App/UserState/UserState.swift
 create mode 100644 Sources/App/Views/Shared/ToastView.swift

diff --git a/Public/css/style.css b/Public/css/style.css
index c980546..7c7c67a 100644
--- a/Public/css/style.css
+++ b/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 {
 	opacity: 0;
 	transition: opacity 0.5s ease-out;
diff --git a/Public/js/site.js b/Public/js/site.js
index e69de29..74edf0e 100644
--- a/Public/js/site.js
+++ b/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);
+    });
+}
diff --git a/Sources/App/Middleware/StateMiddeware.swift b/Sources/App/Middleware/StateMiddeware.swift
new file mode 100644
index 0000000..ed34057
--- /dev/null
+++ b/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
+    }
+}
diff --git a/Sources/App/UserState/ToastState.swift b/Sources/App/UserState/ToastState.swift
new file mode 100644
index 0000000..9fc36eb
--- /dev/null
+++ b/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
+
+}
diff --git a/Sources/App/UserState/UserState.swift b/Sources/App/UserState/UserState.swift
new file mode 100644
index 0000000..b8860ac
--- /dev/null
+++ b/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
+        }
+    }
+}
diff --git a/Sources/App/Views/Components/TodosTableComponent.swift b/Sources/App/Views/Components/TodosTableComponent.swift
index d583249..4950b0d 100644
--- a/Sources/App/Views/Components/TodosTableComponent.swift
+++ b/Sources/App/Views/Components/TodosTableComponent.swift
@@ -17,7 +17,10 @@ struct TodosTableComponent: HTML {
                     tr {
                         th { "#" }
                         th { "ID" }
-                        th { "Title" }
+                        th {
+                            "Title "
+                            i(.class("bi bi-arrow-down-circle")) {}
+                        }
                         th { "Modifier" }
                     }
                 }
@@ -32,10 +35,12 @@ struct TodosTableComponent: HTML {
                             td { todo.title ?? "" }
                             td {
                                 if let id = todo.id {
-                                    button(
-                                        .class("btn btn-danger"),
+                                    i(
+                                        .class("bi bi-trash3 text-danger"),
+                                        .data("bs-toggle", value: "tooltip"),
+                                        .data("bs-title", value: "Delete"),
                                         .hx.delete("/\(name)/\(id.uuidString)")
-                                    ) { "Delete" }
+                                    ) {}
                                 }
                             }
                         }
diff --git a/Sources/App/Views/Shared/MainLayout.swift b/Sources/App/Views/Shared/MainLayout.swift
index 5ae51f0..ce316ee 100644
--- a/Sources/App/Views/Shared/MainLayout.swift
+++ b/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"),
             .integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"),
             .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"))
 
         // ---------------------------------
         // Style
 
         style {
-"""
-body {
-    padding-top: 56px;
-}
-"""
+            """
+            body {
+                padding-top: 56px;
+            }
+            """
         }
     }
 
@@ -50,6 +57,9 @@ body {
                     pageContent
                 }
             }
+
+            // Placeholder for all toast messages
+            ToastView()
         }
 
         // ---------------------------------
@@ -76,6 +86,7 @@ body {
             .integrity("sha384-yhWpPsq2os1hEnx1I8cH7Ius6rclwTm3G2fhXDLF6Pzv7UnSsXY7BAj4fB6PIgSz"),
             .crossorigin(.anonymous)
         ) {}
+
         script(.src("/js/site.js")) {}
     }
 
diff --git a/Sources/App/Views/Shared/ToastView.swift b/Sources/App/Views/Shared/ToastView.swift
new file mode 100644
index 0000000..ef0f0c1
--- /dev/null
+++ b/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();
+                        });
+                    });
+                    """
+                }
+            }
+        }
+    }
+
+}
diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift
index 2bf419b..c534a1b 100644
--- a/Sources/App/configure.swift
+++ b/Sources/App/configure.swift
@@ -6,8 +6,14 @@ import Vapor
 // Configures your application
 public func configure(_ app: Application) async throws {
 	app.middleware = .init()
+
+    // Global user state management
+	app.middleware.use(StateMiddleware())
+    app.manager = .init()
+
     // Error HTML pages or JSON responses
 	app.middleware.use(CustomErrorMiddleware(environment: app.environment))
+
     // Serve files from /Public folder
     app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
 
diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift
index e842b0d..fe7cbf0 100644
--- a/Sources/App/routes.swift
+++ b/Sources/App/routes.swift
@@ -6,7 +6,7 @@ import VaporElementary
 func routes(_ app: Application) throws {
     app.routes.caseInsensitive = true
 
-    app.get() { req async throws in
+    app.get { req async throws in
         HTMLResponse {
             MainLayout(title: "Homepage") {
                 IndexPage()
@@ -18,6 +18,8 @@ func routes(_ app: Application) throws {
         "Hello, world!"
     }
 
+    app.get("toast", use: toast)
+
     try app.register(collection: TodoController())
 
     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