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: 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