Compare commits
	
		
			5 Commits 
		
	
	
		
			94cef15048
			...
			293503d7a2
		
	
	| Author | SHA1 | Date | 
|---|---|---|
| 
							
							
								 | 
						293503d7a2 | 12 months ago | 
| 
							
							
								 | 
						f0cb958ea2 | 12 months ago | 
| 
							
							
								 | 
						7f1e78fa08 | 12 months ago | 
| 
							
							
								 | 
						6b93045514 | 12 months ago | 
| 
							
							
								 | 
						56dcd4fb35 | 12 months ago | 
				 25 changed files with 747 additions and 149 deletions
			
			
		@ -0,0 +1,18 @@
					 | 
				
			||||
/* -------------------------------------- */ | 
				
			||||
/* 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; | 
				
			||||
} | 
				
			||||
@ -0,0 +1,20 @@
					 | 
				
			||||
window.web = {}; | 
				
			||||
 | 
				
			||||
// -----------------------------------------
 | 
				
			||||
 | 
				
			||||
// Activate Bootstrap tooltips
 | 
				
			||||
web.tooltips = function() { | 
				
			||||
	const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]') | 
				
			||||
	const tooltipList = [...tooltipTriggerList].map(element => new bootstrap.Tooltip(element)) | 
				
			||||
} | 
				
			||||
 | 
				
			||||
// -----------------------------------------
 | 
				
			||||
// HTMX events
 | 
				
			||||
 | 
				
			||||
web.afterLoad = function(func) { | 
				
			||||
	// https://htmx.org/docs/#request-operations
 | 
				
			||||
	document.addEventListener("htmx:afterSettle", function handler(event) { | 
				
			||||
        func(); | 
				
			||||
        document.removeEventListener("htmx:afterSwap", handler); | 
				
			||||
    }); | 
				
			||||
} | 
				
			||||
@ -0,0 +1,10 @@
					 | 
				
			||||
* Website | 
				
			||||
 | 
				
			||||
Website, written in Swift 6. | 
				
			||||
 | 
				
			||||
* Libraries | 
				
			||||
 | 
				
			||||
- [[https://github.com/vapor/vapor][Vapor]] HTTP web framework | 
				
			||||
- [[https://github.com/vapor/fluent.git][Fluent]] ORM | 
				
			||||
- [[https://github.com/sliemeobn/elementary][Elementary]] HTML templating | 
				
			||||
- [[https://github.com/bigskysoftware/htmx][htmx]] front-end JS | 
				
			||||
@ -0,0 +1,84 @@
					 | 
				
			||||
import Fluent | 
				
			||||
import Vapor | 
				
			||||
 | 
				
			||||
// The code in this file is unused | 
				
			||||
 | 
				
			||||
struct TodoAPIController: RouteCollection { | 
				
			||||
    func boot(routes: RoutesBuilder) throws { | 
				
			||||
        let todos = routes.grouped("todos") | 
				
			||||
 | 
				
			||||
        todos.get(use: index) | 
				
			||||
        todos.post(use: create) | 
				
			||||
        todos.group(":id") { todo in | 
				
			||||
            todo.get(use: show) | 
				
			||||
            todo.put(use: update) | 
				
			||||
            todo.delete(use: delete) | 
				
			||||
        } | 
				
			||||
        // todos.delete(":id", use: delete) | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    @Sendable | 
				
			||||
    func index(req: Request) async throws -> [TodoDTO] { | 
				
			||||
        try await Todo.query(on: req.db).all().map { $0.toDTO() } | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    @Sendable | 
				
			||||
    func show(req: Request) async throws -> TodoDTO { | 
				
			||||
        guard let uuid = hexToUUID(hex: req.parameters.get("id")!), | 
				
			||||
              let todo = try await Todo.find(uuid, on: req.db) else { | 
				
			||||
            throw Abort(.notFound) | 
				
			||||
        } | 
				
			||||
 | 
				
			||||
        return todo.toDTO() | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    @Sendable | 
				
			||||
    func create(req: Request) async throws -> TodoDTO { | 
				
			||||
        let todo = try req.content.decode(TodoDTO.self).toModel() | 
				
			||||
 | 
				
			||||
        try await todo.save(on: req.db) | 
				
			||||
 | 
				
			||||
        return todo.toDTO() | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    @Sendable | 
				
			||||
    func update(req: Request) async throws -> TodoDTO { | 
				
			||||
        guard let uuid = hexToUUID(hex: req.parameters.get("id")!), | 
				
			||||
              let todo = try await Todo.find(uuid, on: req.db) else { | 
				
			||||
            throw Abort(.notFound) | 
				
			||||
        } | 
				
			||||
 | 
				
			||||
        let updatedTodo = try req.content.decode(Todo.self) | 
				
			||||
        todo.title = updatedTodo.title | 
				
			||||
        try await todo.save(on: req.db) | 
				
			||||
 | 
				
			||||
        return todo.toDTO() | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    @Sendable | 
				
			||||
    func delete(req: Request) async throws -> HTTPStatus { | 
				
			||||
        guard let uuid = hexToUUID(hex: req.parameters.get("id")!), | 
				
			||||
              let todo = try await Todo.find(uuid, on: req.db) else { | 
				
			||||
            throw Abort(.notFound) | 
				
			||||
        } | 
				
			||||
 | 
				
			||||
        try await todo.delete(on: req.db) | 
				
			||||
 | 
				
			||||
        return .noContent | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    // ------------------------------------- | 
				
			||||
 | 
				
			||||
    private func hexToUUID(hex: String) -> UUID? { | 
				
			||||
        var uuid: String = hex.replacingOccurrences(of: "-", with: "") | 
				
			||||
 | 
				
			||||
        guard uuid.count == 32 else { return nil } | 
				
			||||
 | 
				
			||||
        uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 8)) | 
				
			||||
        uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 12 + 1)) | 
				
			||||
        uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 16 + 2)) | 
				
			||||
        uuid.insert("-", at: uuid.index(uuid.startIndex, offsetBy: 20 + 3)) | 
				
			||||
 | 
				
			||||
        return UUID(uuidString: uuid) | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -0,0 +1,30 @@
					 | 
				
			||||
import Elementary | 
				
			||||
import ElementaryHTMX | 
				
			||||
import ElementaryHTMXSSE | 
				
			||||
import ElementaryHTMXWS | 
				
			||||
import Fluent | 
				
			||||
import Vapor | 
				
			||||
import VaporElementary | 
				
			||||
 | 
				
			||||
struct TestController: RouteCollection { | 
				
			||||
 | 
				
			||||
    func boot(routes: RoutesBuilder) throws { | 
				
			||||
        routes.group("test") { test in | 
				
			||||
			test.get("toast", use: toast) | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    @Sendable | 
				
			||||
    func toast(req: Request) async throws -> HTMLResponse { | 
				
			||||
 | 
				
			||||
        let state = try getState(request: req) | 
				
			||||
        state.toast = ToastState(message: "Wow!", | 
				
			||||
                                 title: "This is my title", | 
				
			||||
                                 level: ToastState.Level.success) | 
				
			||||
 | 
				
			||||
        throw Abort(.badRequest, headers: ["HX-Trigger": "toast"]) | 
				
			||||
 | 
				
			||||
        // return HTMLResponse { } | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
} | 
				
			||||
@ -0,0 +1,24 @@
					 | 
				
			||||
import Elementary | 
				
			||||
import ElementaryHTMX | 
				
			||||
 | 
				
			||||
// ----------------------------------------- | 
				
			||||
// Elementary | 
				
			||||
 | 
				
			||||
public extension HTMLAttribute { | 
				
			||||
    static func empty() -> HTMLAttribute { | 
				
			||||
        return HTMLAttribute(name: "", value: "") | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
 | 
				
			||||
// ----------------------------------------- | 
				
			||||
// ElementaryHTMX | 
				
			||||
 | 
				
			||||
public extension HTMLAttribute.hx { | 
				
			||||
    static func swap(_ value: HTMLAttributeValue.HTMX.ModifiedSwapTarget, _ spec: String? = nil) -> HTMLAttribute { | 
				
			||||
        if let spec { | 
				
			||||
            .init(name: "hx-swap", value: "\(value.rawValue) \(spec)") | 
				
			||||
        } else { | 
				
			||||
            .init(name: "hx-swap", value: value.rawValue) | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -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,33 @@
					 | 
				
			||||
import Vapor | 
				
			||||
 | 
				
			||||
// ----------------------------------------- | 
				
			||||
 | 
				
			||||
public final class UserState: @unchecked Sendable { | 
				
			||||
	var toast: ToastState = ToastState() | 
				
			||||
    var todos: TodosState = TodosState() | 
				
			||||
} | 
				
			||||
 | 
				
			||||
// ----------------------------------------- | 
				
			||||
 | 
				
			||||
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 | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -0,0 +1,51 @@
					 | 
				
			||||
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 ToastComponent: 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 { | 
				
			||||
                ScriptAfterLoad(initial: false) { | 
				
			||||
                    """ | 
				
			||||
                    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(); | 
				
			||||
                    }); | 
				
			||||
                    """ | 
				
			||||
                } | 
				
			||||
            } | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
} | 
				
			||||
@ -0,0 +1,35 @@
					 | 
				
			||||
import Elementary | 
				
			||||
import Fluent | 
				
			||||
 | 
				
			||||
struct TodosFormComponent: HTML { | 
				
			||||
 | 
				
			||||
    var name: String | 
				
			||||
    var target: String | 
				
			||||
    var errors: [String: String] = [:] | 
				
			||||
 | 
				
			||||
    // ------------------------------------- | 
				
			||||
 | 
				
			||||
    var content: some HTML { | 
				
			||||
        div(.id("cdiv_" + name)) { | 
				
			||||
            form( | 
				
			||||
                .id("\(name)-form"), | 
				
			||||
                .hx.post("/\(target)"), .hx.target("#cdiv_" + name), .hx.swap(.outerHTML) | 
				
			||||
            ) { | 
				
			||||
                div(.class("row")) { | 
				
			||||
                    div(.class("col-11")) { | 
				
			||||
                        input( | 
				
			||||
                            .class("form-control"), .type(.text), .id("\(name)-title"), | 
				
			||||
                            .name("title"), .placeholder("Title"))  // .required | 
				
			||||
                    } | 
				
			||||
                    div(.class("col-1")) { | 
				
			||||
                        button(.class("btn btn-success"), .type(.submit)) { "Add" } | 
				
			||||
                    } | 
				
			||||
                } | 
				
			||||
                if let error = errors["title"] { | 
				
			||||
                    div(.class("form-text text-danger px-2")) { "\(error)." } | 
				
			||||
                } | 
				
			||||
            } | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
} | 
				
			||||
@ -0,0 +1,84 @@
					 | 
				
			||||
import Elementary | 
				
			||||
import ElementaryHTMX | 
				
			||||
import Fluent | 
				
			||||
 | 
				
			||||
struct TodosTableComponent: HTML { | 
				
			||||
 | 
				
			||||
    var state: TodosTableState = TodosTableState() | 
				
			||||
 | 
				
			||||
    // ------------------------------------- | 
				
			||||
 | 
				
			||||
    var content: some HTML { | 
				
			||||
        div(.id("cdiv_" + state.name), state.refresh ? .hx.swapOOB(.outerHTML) : .empty()) { | 
				
			||||
            table(.class("table")) { | 
				
			||||
                thead { | 
				
			||||
                    tr { | 
				
			||||
                        th { "#" } | 
				
			||||
                        th { "ID" } | 
				
			||||
                        th { | 
				
			||||
                            let order = state.sort["title"]?.description ?? "ascending" | 
				
			||||
                            span(.style("cursor: pointer;"), | 
				
			||||
                                 .hx.get("/\(state.name)/sort?title=\(order == "descending" ? "ascending" : "descending")"), | 
				
			||||
                                 .hx.target("closest div")) { | 
				
			||||
                                "Title " | 
				
			||||
                                i(.class("bi bi-arrow-\(order == "descending" ? "down" : "up")-circle")) {} | 
				
			||||
                            } | 
				
			||||
                        } | 
				
			||||
                        th { "Modifier" } | 
				
			||||
                    } | 
				
			||||
                } | 
				
			||||
                tbody( | 
				
			||||
                    .hx.confirm("Are you sure?"), .hx.target("closest tr"), | 
				
			||||
                    .hx.swap(.outerHTML, "swap:0.5s") | 
				
			||||
                ) { | 
				
			||||
                    for (index, todo) in state.todos.enumerated() { | 
				
			||||
                        tr { | 
				
			||||
                            td { "\(index)" } | 
				
			||||
                            td { todo.id?.uuidString ?? "" } | 
				
			||||
                            td { todo.title ?? "" } | 
				
			||||
                            td { | 
				
			||||
                                if let id = todo.id { | 
				
			||||
                                    i( | 
				
			||||
                                        .class("bi bi-trash3 text-danger"), | 
				
			||||
                                        .data("bs-toggle", value: "tooltip"), | 
				
			||||
                                        .data("bs-title", value: "Delete"), | 
				
			||||
                                        .hx.delete("/\(state.name)/\(id.uuidString)") | 
				
			||||
                                    ) {} | 
				
			||||
                                } | 
				
			||||
                            } | 
				
			||||
                        } | 
				
			||||
                    } | 
				
			||||
                } | 
				
			||||
            } | 
				
			||||
 | 
				
			||||
            ScriptAfterLoad(initial: !state.refresh) { "web.tooltips();" }; | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
} | 
				
			||||
 | 
				
			||||
struct Modal: HTML { | 
				
			||||
    var content: some HTML { | 
				
			||||
        div(.class("modal fade"), .id("alertModal"), .tabindex(-1)) { | 
				
			||||
            div(.class("modal-dialog modal-dialog-centered")) { | 
				
			||||
                div(.class("modal-content")) { | 
				
			||||
                    div(.class("modal-header border-bottom-0")) { | 
				
			||||
                        button( | 
				
			||||
                            .class("btn-close"), .type(.button), .data("bs-dismiss", value: "modal") | 
				
			||||
                        ) {} | 
				
			||||
                    } | 
				
			||||
                    div(.class("modal-body")) { | 
				
			||||
                        div(.class("alert alert-danger")) { | 
				
			||||
                            "Toby123" | 
				
			||||
                        } | 
				
			||||
                    } | 
				
			||||
                } | 
				
			||||
            } | 
				
			||||
        } | 
				
			||||
        button( | 
				
			||||
            .class("btn btn-primary"), .type(.button), .data("bs-toggle", value: "modal"), | 
				
			||||
            .data("bs-target", value: "#alertModal") | 
				
			||||
        ) { "Show" } | 
				
			||||
 | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -0,0 +1,35 @@
					 | 
				
			||||
import Elementary | 
				
			||||
 | 
				
			||||
struct ScriptAfterLoad: HTML { | 
				
			||||
 | 
				
			||||
    var initial: Bool = false | 
				
			||||
    var js: String = "" | 
				
			||||
 | 
				
			||||
    init(initial: Bool = false, js: () -> String) { | 
				
			||||
        self.initial = initial | 
				
			||||
        self.js = js() | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
    // ------------------------------------- | 
				
			||||
 | 
				
			||||
    var content: some HTML { | 
				
			||||
        if initial { | 
				
			||||
            script { | 
				
			||||
                """ | 
				
			||||
                document.addEventListener("DOMContentLoaded", function() { | 
				
			||||
                    \(js) | 
				
			||||
                }); | 
				
			||||
                """ | 
				
			||||
            } | 
				
			||||
        } else { | 
				
			||||
            script { | 
				
			||||
                """ | 
				
			||||
                web.afterLoad(function () { | 
				
			||||
                    \(js) | 
				
			||||
                }); | 
				
			||||
                """ | 
				
			||||
            } | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
 | 
				
			||||
} | 
				
			||||
					Loading…
					
					
				
		Reference in new issue