Middleware+View+UserState: Add toast messages
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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")) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user