Compare commits

...

5 Commits

  1. 11
      Package.resolved
  2. 8
      Package.swift
  3. 18
      Public/css/style.css
  4. 20
      Public/js/site.js
  5. 10
      README.org
  6. 84
      Sources/App/Controllers/API/TodoAPIController.swift
  7. 30
      Sources/App/Controllers/TestController.swift
  8. 110
      Sources/App/Controllers/TodoController.swift
  9. 12
      Sources/App/DTOs/TodoDTO.swift
  10. 24
      Sources/App/Extensions/Elementary.swift
  11. 61
      Sources/App/Middleware/CustomErrorMiddleware.swift
  12. 54
      Sources/App/Middleware/StateMiddeware.swift
  13. 15
      Sources/App/UserState/ToastState.swift
  14. 33
      Sources/App/UserState/UserState.swift
  15. 51
      Sources/App/Views/Components/ToastComponent.swift
  16. 35
      Sources/App/Views/Components/TodosFormComponent.swift
  17. 84
      Sources/App/Views/Components/TodosTableComponent.swift
  18. 38
      Sources/App/Views/Layouts/MainLayout.swift
  19. 82
      Sources/App/Views/Pages/IndexPage.swift
  20. 5
      Sources/App/Views/Shared/NavMenu.swift
  21. 35
      Sources/App/Views/Shared/ScriptAfterLoad.swift
  22. 6
      Sources/App/configure.swift
  23. 34
      Sources/App/routes.swift
  24. 26
      Tests/AppTests/AppTests.swift
  25. 10
      requests

11
Package.resolved

@ -1,5 +1,5 @@
{ {
"originHash" : "928a4b649897cc0b7c73d0862d9400289c0503386d6f44998334e327c5931856", "originHash" : "d39a0a797e191acb95e23a306e38d51e93c56adbbcfd76f4476a7deecf94537b",
"pins" : [ "pins" : [
{ {
"identity" : "async-http-client", "identity" : "async-http-client",
@ -37,6 +37,15 @@
"version" : "0.4.1" "version" : "0.4.1"
} }
}, },
{
"identity" : "elementary-htmx",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sliemeobn/elementary-htmx.git",
"state" : {
"revision" : "3e09519a605be410e8332627b260cebaf74bb9fa",
"version" : "0.3.0"
}
},
{ {
"identity" : "fluent", "identity" : "fluent",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

8
Package.swift

@ -17,14 +17,16 @@ let package = Package(
.package(url: "https://github.com/apple/swift-nio.git", from: "2.76.1"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.76.1"),
// //
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.0"), .package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.2.0"),
//
.package(url: "https://github.com/sliemeobn/elementary-htmx.git", from: "0.3.0"),
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "App", name: "App",
dependencies: [ dependencies: [
// .product(name: "ElementaryHTMX", package: "elementary-htmx"), .product(name: "ElementaryHTMX", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"), .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXWS", package: "elementary-htmx"), .product(name: "ElementaryHTMXWS", package: "elementary-htmx"),
.product(name: "Fluent", package: "fluent"), .product(name: "Fluent", package: "fluent"),
.product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"), .product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"),
.product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"),

18
Public/css/style.css

@ -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;
}

20
Public/js/site.js

@ -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);
});
}

10
README.org

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

84
Sources/App/Controllers/API/TodoAPIController.swift

@ -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)
}
}

30
Sources/App/Controllers/TestController.swift

@ -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 { }
}
}

110
Sources/App/Controllers/TodoController.swift

@ -1,72 +1,111 @@
import Elementary
import ElementaryHTMX
import ElementaryHTMXSSE
import ElementaryHTMXWS
import Fluent import Fluent
import Vapor import Vapor
import VaporElementary
struct TodoController: RouteCollection { struct TodoController: RouteCollection {
func boot(routes: RoutesBuilder) throws { func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos") routes.group("todos") { todos in
todos.get(use: index)
todos.get(use: index) todos.post(use: create)
todos.post(use: create)
todos.group(":id") { todo in todos.group(":id") { todo in
todo.get(use: show) todo.delete(use: delete)
todo.put(use: update) }
todo.delete(use: delete)
todos.group("sort") { todo in
todo.get(use: sort)
}
} }
// todos.delete(":todoID", use: delete)
} }
@Sendable @Sendable
func index(req: Request) async throws -> [TodoDTO] { func index(req: Request) async throws -> HTMLResponse {
try await Todo.query(on: req.db).all().map { $0.toDTO() } let state = try getState(request: req)
state.todos.table.name = "todos"
state.todos.table.sort["title"] = state.todos.table.sort["title"] ?? .ascending
let todos = try await todos(db: req.db, title: state.todos.table.sort["title"]!)
state.todos.table.todos = todos
state.todos.table.refresh = false
return HTMLResponse {
MainLayout(title: "Todos") {
TodosPage(table: state.todos.table)
}
}
} }
@Sendable @Sendable
func show(req: Request) async throws -> TodoDTO { func create(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), do {
let todo = try await Todo.find(uuid, on: req.db) else { try TodoDTO.validate(content: req)
throw Abort(.notFound) } catch let error as ValidationsError {
return HTMLResponse {
TodosFormComponent(
name: "todos-form", target: "todos", errors: ["title": error.description])
}
} }
return todo.toDTO()
}
@Sendable
func create(req: Request) async throws -> TodoDTO {
let todo = try req.content.decode(TodoDTO.self).toModel() let todo = try req.content.decode(TodoDTO.self).toModel()
try await todo.save(on: req.db) try await todo.save(on: req.db)
return todo.toDTO() let state = try getState(request: req)
let todos = try await todos(db: req.db, title: state.todos.table.sort["title"] ?? .ascending)
state.todos.table.todos = todos
state.todos.table.refresh = true
return HTMLResponse {
// Return the empty form
TodosFormComponent(name: "todos-form", target: "todos")
// Also update the todos table
TodosTableComponent(state: state.todos.table) // TODO: Put component names inside variables
}
} }
@Sendable @Sendable
func update(req: Request) async throws -> TodoDTO { func delete(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), guard let uuid = hexToUUID(hex: req.parameters.get("id")!),
let todo = try await Todo.find(uuid, on: req.db) else { let todo = try await Todo.find(uuid, on: req.db)
else {
throw Abort(.notFound) throw Abort(.notFound)
} }
let updatedTodo = try req.content.decode(Todo.self) try await todo.delete(on: req.db)
todo.title = updatedTodo.title
try await todo.save(on: req.db) return HTMLResponse {} // TODO: Return 204 No Content
}
return todo.toDTO() struct Sort: Content {
let title: String
} }
@Sendable @Sendable
func delete(req: Request) async throws -> HTTPStatus { func sort(req: Request) async throws -> HTMLResponse {
guard let uuid = hexToUUID(hex: req.parameters.get("id")!), let state = try getState(request: req)
let todo = try await Todo.find(uuid, on: req.db) else {
throw Abort(.notFound)
}
try await todo.delete(on: req.db) let sort = try req.query.decode(Sort.self)
state.todos.table.sort["title"] = sort.title == "descending" ? .descending : .ascending
return .noContent let todos = try await todos(db: req.db, title: state.todos.table.sort["title"]!)
state.todos.table.todos = todos
state.todos.table.refresh = true
return HTMLResponse {
TodosTableComponent(state: state.todos.table)
}
} }
// ------------------------------------- // -------------------------------------
@Sendable
private func todos(db: any Database, title: DatabaseQuery.Sort.Direction = .ascending) async throws -> [TodoDTO] {
try await Todo.query(on: db).sort("title", title).all().map { $0.toDTO() }
}
private func hexToUUID(hex: String) -> UUID? { private func hexToUUID(hex: String) -> UUID? {
var uuid: String = hex.replacingOccurrences(of: "-", with: "") var uuid: String = hex.replacingOccurrences(of: "-", with: "")
@ -79,4 +118,5 @@ struct TodoController: RouteCollection {
return UUID(uuidString: uuid) return UUID(uuidString: uuid)
} }
} }

12
Sources/App/DTOs/TodoDTO.swift

@ -4,10 +4,10 @@ import Vapor
struct TodoDTO: Content { struct TodoDTO: Content {
var id: UUID? var id: UUID?
var title: String? var title: String?
func toModel() -> Todo { func toModel() -> Todo {
let model = Todo() let model = Todo()
model.id = self.id model.id = self.id
if let title = self.title { if let title = self.title {
model.title = title model.title = title
@ -15,3 +15,11 @@ struct TodoDTO: Content {
return model return model
} }
} }
extension TodoDTO: Validatable {
static func validations(_ validations: inout Validations) {
validations.add(
"title", as: String.self, is: !.empty, required: true,
customFailureDescription: "Title is required")
}
}

24
Sources/App/Extensions/Elementary.swift

@ -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)
}
}
}

61
Sources/App/Middleware/CustomErrorMiddleware.swift

@ -11,17 +11,25 @@ public final class CustomErrorMiddleware: Middleware {
public init(environment: Environment) { public init(environment: Environment) {
self.environment = environment self.environment = environment
self.errorMiddleware = ErrorMiddleware.`default`(environment: environment)
} }
public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> { public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> {
next.respond(to: request).flatMapErrorThrowing { error in
self.makeResponse(with: request, reason: error) // Let ErrorMiddleware handle API endpoint errors
if let acceptHeader = request.headers.first(name: "Accept"),
acceptHeader == "application/json" {
return errorMiddleware.respond(to: request, chainingTo: next)
}
return next.respond(to: request).flatMapErrorThrowing { error in
try self.makeResponse(with: request, reason: error)
} }
} }
// ------------------------------------- // -------------------------------------
private func makeResponse(with req: Request, reason error: Error) -> Response { private func makeResponse(with req: Request, reason error: Error) throws -> Response {
let reason: String let reason: String
let status: HTTPResponseStatus let status: HTTPResponseStatus
var headers: HTTPHeaders var headers: HTTPHeaders
@ -47,45 +55,34 @@ public final class CustomErrorMiddleware: Middleware {
// Report the error // Report the error
req.logger.report(error: error, file: source.file, function: source.function, line: source.line) req.logger.report(error: error, file: source.file, function: source.function, line: source.line)
let body = makeResponseBody(with: req, reason: reason, status: status, headers: &headers) headers.contentType = .html
// Create a Response with appropriate status let statusCode = String(status.code)
return Response(status: status, headers: headers, body: body) var body = Response.Body()
}
private func makeResponseBody(with req: Request, reason: String, status: HTTPResponseStatus, // Display error in toast message for HTMX requests
headers: inout HTTPHeaders) -> Response.Body { if let isHTMXHeader = req.headers.first(name: "HX-Request"), isHTMXHeader == "true" {
let body: Response.Body let state = try getState(request: req)
// Only set a new toast if the endpoint hasnt already
if let acceptHeader = req.headers.first(name: "Accept"), if state.toast.message.isEmpty {
acceptHeader == "application/json" { state.toast = ToastState(message: reason,
// Attempt to serialize the error to JSON title: "Error \(statusCode)",
do { level: ToastState.Level.error)
let encoder = try ContentConfiguration.global.requireEncoder(for: .json)
var byteBuffer = req.byteBufferAllocator.buffer(capacity: 0)
try encoder.encode(ErrorResponse(error: true, reason: reason), to: &byteBuffer, headers: &headers)
body = .init(
buffer: byteBuffer,
byteBufferAllocator: req.byteBufferAllocator
)
} catch {
body = .init(string: "Oops: \(String(describing: error))\nWhile encoding error: \(reason)",
byteBufferAllocator: req.byteBufferAllocator)
headers.contentType = .plainText
} }
headers.add(name: "HX-Trigger", value: "toast")
} }
// Render error to a page
else { else {
// Attempt to render the error to a page body = Response.Body(string: MainLayout(title: "Error \(statusCode))") {
let statusCode = String(status.code)
body = .init(string: MainLayout(title: "Error \(statusCode))") {
ErrorPage(status: statusCode, reason: reason) ErrorPage(status: statusCode, reason: reason)
}.render()) }.render())
headers.contentType = .html
} }
return body
// Create a Response with appropriate status
return Response(status: status, headers: headers, body: body)
} }
private let environment: Environment private let environment: Environment
private let errorMiddleware: ErrorMiddleware
} }

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
}

33
Sources/App/UserState/UserState.swift

@ -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
}
}
}

51
Sources/App/Views/Components/ToastComponent.swift

@ -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();
});
"""
}
}
}
}
}

35
Sources/App/Views/Components/TodosFormComponent.swift

@ -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)." }
}
}
}
}
}

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

@ -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" }
}
}

38
Sources/App/Views/Shared/MainLayout.swift → Sources/App/Views/Layouts/MainLayout.swift

@ -1,11 +1,12 @@
import Elementary import Elementary
// https://www.srihash.org/
extension MainLayout: Sendable where Body: Sendable {} extension MainLayout: Sendable where Body: Sendable {}
struct MainLayout<Body: HTML>: HTMLDocument { struct MainLayout<Body: HTML>: HTMLDocument {
var title: String var title: String
@HTMLBuilder var pageContent: Body // This var name can't be changed! @HTMLBuilder var pageContent: Body // This var name can't be changed!
// https://www.srihash.org/
var head: some HTML { var head: some HTML {
meta(.charset(.utf8)) meta(.charset(.utf8))
meta(.name(.viewport), .content("width=device-width, initial-scale=1.0")) meta(.name(.viewport), .content("width=device-width, initial-scale=1.0"))
@ -18,7 +19,17 @@ 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("/style.css"))
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 { style {
""" """
@ -38,14 +49,17 @@ struct MainLayout<Body: HTML>: HTMLDocument {
} }
// --------------------------------- // ---------------------------------
// Body // Content
main { main {
div(.class("cotainer mt-4")) { div(.class("container mt-4")) {
div(.class("content px-4 pb-4")) { div(.class("content px-4 pb-4")) {
pageContent pageContent
} }
} }
// Placeholder for all toast messages
ToastComponent()
} }
// --------------------------------- // ---------------------------------
@ -57,6 +71,22 @@ struct MainLayout<Body: HTML>: HTMLDocument {
.integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"), .integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"),
.crossorigin(.anonymous) .crossorigin(.anonymous)
) {} ) {}
script(
.src("https://cdnjs.cloudflare.com/ajax/libs/htmx/2.0.3/htmx.min.js"),
.integrity("sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq"),
.crossorigin(.anonymous)
) {}
script(
.src("https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2/sse.min.js"),
.integrity("sha384-yhS+rWHB2hwrHEg86hWiQV7XL6u+PH9X+3BlmS2+CNBaGYU8Nd7RZ2rZ9DWXgTdr"),
.crossorigin(.anonymous)
) {}
script(
.src("https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.1/ws.min.js"),
.integrity("sha384-yhWpPsq2os1hEnx1I8cH7Ius6rclwTm3G2fhXDLF6Pzv7UnSsXY7BAj4fB6PIgSz"),
.crossorigin(.anonymous)
) {}
script(.src("/js/site.js")) {} script(.src("/js/site.js")) {}
} }

82
Sources/App/Views/Pages/IndexPage.swift

@ -8,51 +8,51 @@ struct IndexPage: HTML {
} }
p { "The navigation bar will stay at the top of the page as you scroll down." } p { "The navigation bar will stay at the top of the page as you scroll down." }
p { p {
""" """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu
sit amet leo rutrum luctus. Sed metus mi, consectetur vitae dui at, sodales sit amet leo rutrum luctus. Sed metus mi, consectetur vitae dui at, sodales
dignissim odio. Curabitur a nisi eros. Suspendisse semper ac justo non gravida. dignissim odio. Curabitur a nisi eros. Suspendisse semper ac justo non gravida.
Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis
libero. Pellentesque habitant morbi tristique senectus et netus et malesuada libero. Pellentesque habitant morbi tristique senectus et netus et malesuada
fames ac turpis egestas. Integer congue, nisl in tempus ultricies, ligula est fames ac turpis egestas. Integer congue, nisl in tempus ultricies, ligula est
laoreet elit, sed elementum erat dui ac nisl. Aenean pulvinar arcu eget urna laoreet elit, sed elementum erat dui ac nisl. Aenean pulvinar arcu eget urna
venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis
lobortis, nisi sapien vestibulum arcu, ut mattis arcu elit ut arcu. Nulla vitae lobortis, nisi sapien vestibulum arcu, ut mattis arcu elit ut arcu. Nulla vitae
sem ac eros ullamcorper efficitur ut id arcu. Vestibulum euismod arcu eget sem ac eros ullamcorper efficitur ut id arcu. Vestibulum euismod arcu eget
aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum
mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula. mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula.
Quisque viverra diam velit, quis ultricies nisl lacinia vitae. Quisque viverra diam velit, quis ultricies nisl lacinia vitae.
Aliquam libero nibh, luctus vel augue at, congue feugiat risus. Mauris volutpat Aliquam libero nibh, luctus vel augue at, congue feugiat risus. Mauris volutpat
eget eros ac congue. Duis venenatis, arcu vel sodales accumsan, diam mi posuere eget eros ac congue. Duis venenatis, arcu vel sodales accumsan, diam mi posuere
mi, vitae rutrum ex mauris nec libero. Sed eleifend nulla magna, eu lobortis mi, vitae rutrum ex mauris nec libero. Sed eleifend nulla magna, eu lobortis
mauris fringilla nec. In posuere dignissim eros, ut hendrerit quam lacinia eu. mauris fringilla nec. In posuere dignissim eros, ut hendrerit quam lacinia eu.
Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc
vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in
viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet. viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet.
Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum, Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum,
sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris
magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu
vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam
consequat enim, sit amet sodales erat quam eget massa. consequat enim, sit amet sodales erat quam eget massa.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis
urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor. urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor.
Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae
ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper
fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla
nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla
facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae
neque venenatis porta. neque venenatis porta.
Vestibulum nunc diam, mattis eu bibendum at, sagittis vitae nunc. Duis lacinia Vestibulum nunc diam, mattis eu bibendum at, sagittis vitae nunc. Duis lacinia
sodales enim, et elementum libero posuere et. Donec ut fringilla orci. Donec at sodales enim, et elementum libero posuere et. Donec ut fringilla orci. Donec at
aliquet ipsum, quis mattis risus. Donec malesuada enim in egestas blandit. Proin aliquet ipsum, quis mattis risus. Donec malesuada enim in egestas blandit. Proin
sagittis mauris magna, elementum faucibus metus tempus eu. Sed in sem ut tellus sagittis mauris magna, elementum faucibus metus tempus eu. Sed in sem ut tellus
porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis
condimentum nulla. condimentum nulla.
""" """
} }
} }
} }

5
Sources/App/Views/Shared/NavMenu.swift

@ -15,7 +15,10 @@ struct NavMenu: HTML {
div(.class("collapse navbar-collapse"), .id("navbarNav")) { div(.class("collapse navbar-collapse"), .id("navbarNav")) {
ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) { ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) {
li(.class("nav-item")) { li(.class("nav-item")) {
a(.class("nav-link active"), .href("#")) { "Home" } a(.class("nav-link active"), .href("/")) { "Home" }
}
li(.class("nav-item")) {
a(.class("nav-link active"), .href("/todos")) { "Todos" }
} }
li(.class("nav-item")) { li(.class("nav-item")) {
a(.class("nav-link"), .href("#")) { "About" } a(.class("nav-link"), .href("#")) { "About" }

35
Sources/App/Views/Shared/ScriptAfterLoad.swift

@ -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)
});
"""
}
}
}
}

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

34
Sources/App/routes.swift

@ -7,26 +7,36 @@ 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 {
let todo = Todo(title: "Test Todo") MainLayout(title: "Homepage") {
try await todo.save(on: req.db) IndexPage()
}
return "It works!" }
} }
app.get("hello") { req async -> String in app.get("hello") { req async -> String in
"Hello, world!" "Hello, world!"
} }
app.get("test") { _ in app.get("toast", use: toast)
HTMLResponse {
MainLayout(title: "Test123") {
IndexPage()
}
}
}
try app.register(collection: TodoController()) try app.register(collection: TodoController())
try app.register(collection: TestController())
try app.group("api") { api in
try api.register(collection: TodoAPIController())
}
}
@Sendable
func toast(req: Request) throws -> HTMLResponse {
let state = try getState(request: req)
let toast = state.toast
state.toast = ToastState() // Clear toast
return HTMLResponse {
ToastComponent(state: toast)
}
} }
/* /*

26
Tests/AppTests/AppTests.swift

@ -9,9 +9,9 @@ struct AppTests {
let app = try await Application.make(.testing) let app = try await Application.make(.testing)
do { do {
try await configure(app) try await configure(app)
try await app.autoMigrate() try await app.autoMigrate()
try await test(app) try await test(app)
try await app.autoRevert() try await app.autoRevert()
} }
catch { catch {
try await app.asyncShutdown() try await app.asyncShutdown()
@ -19,7 +19,7 @@ struct AppTests {
} }
try await app.asyncShutdown() try await app.asyncShutdown()
} }
@Test("Test Hello World Route") @Test("Test Hello World Route")
func helloWorld() async throws { func helloWorld() async throws {
try await withApp { app in try await withApp { app in
@ -29,26 +29,26 @@ struct AppTests {
}) })
} }
} }
@Test("Getting all the Todos") @Test("Getting all the Todos")
func getAllTodos() async throws { func getAllTodos() async throws {
try await withApp { app in try await withApp { app in
let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")] let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")]
try await sampleTodos.create(on: app.db) try await sampleTodos.create(on: app.db)
try await app.test(.GET, "todos", afterResponse: { res async throws in try await app.test(.GET, "api/todos", afterResponse: { res async throws in
#expect(res.status == .ok) #expect(res.status == .ok)
#expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} ) #expect(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} )
}) })
} }
} }
@Test("Creating a Todo") @Test("Creating a Todo")
func createTodo() async throws { func createTodo() async throws {
let newDTO = TodoDTO(id: nil, title: "test") let newDTO = TodoDTO(id: nil, title: "test")
try await withApp { app in try await withApp { app in
try await app.test(.POST, "todos", beforeRequest: { req in try await app.test(.POST, "api/todos", beforeRequest: { req in
try req.content.encode(newDTO) try req.content.encode(newDTO)
}, afterResponse: { res async throws in }, afterResponse: { res async throws in
#expect(res.status == .ok) #expect(res.status == .ok)
@ -58,15 +58,15 @@ struct AppTests {
}) })
} }
} }
@Test("Deleting a Todo") @Test("Deleting a Todo")
func deleteTodo() async throws { func deleteTodo() async throws {
let testTodos = [Todo(title: "test1"), Todo(title: "test2")] let testTodos = [Todo(title: "test1"), Todo(title: "test2")]
try await withApp { app in try await withApp { app in
try await testTodos.create(on: app.db) try await testTodos.create(on: app.db)
try await app.test(.DELETE, "todos/\(testTodos[0].requireID())", afterResponse: { res async throws in try await app.test(.DELETE, "api/todos/\(testTodos[0].requireID())", afterResponse: { res async throws in
#expect(res.status == .noContent) #expect(res.status == .noContent)
let model = try await Todo.find(testTodos[0].id, on: app.db) let model = try await Todo.find(testTodos[0].id, on: app.db)
#expect(model == nil) #expect(model == nil)

10
requests

@ -14,7 +14,7 @@
# ------------------------------------------ # ------------------------------------------
GET http://localhost:8080/todos GET http://localhost:8080/api/todos
-> jq-set-var :id .[0].id -> jq-set-var :id .[0].id
Accept: application/json Accept: application/json
@ -22,12 +22,12 @@ Accept: application/json
# ------------------------------------------ # ------------------------------------------
GET http://localhost:8080/todos/:id GET http://localhost:8080/api/todos/:id
Accept: application/json Accept: application/json
# ------------------------------------------ # ------------------------------------------
POST http://localhost:8080/todos POST http://localhost:8080/api/todos
-> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read)))) -> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read))))
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
@ -38,7 +38,7 @@ Content-Type: application/json
# ------------------------------------------ # ------------------------------------------
PUT http://localhost:8080/todos/:id PUT http://localhost:8080/api/todos/:id
-> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read)))) -> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read))))
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
@ -55,7 +55,7 @@ Content-Type: application/json
# INSERT(INSERT(INSERT(INSERT(HEX(id), 9, 0, '-'), 14, 0, '-'), 19, 0, '-'), 24, 0, '-') AS id, # INSERT(INSERT(INSERT(INSERT(HEX(id), 9, 0, '-'), 14, 0, '-'), 19, 0, '-'), 24, 0, '-') AS id,
# title FROM riyyi.todos; # title FROM riyyi.todos;
DELETE http://localhost:8080/todos/:id DELETE http://localhost:8080/api/todos/:id
Accept: application/json Accept: application/json
# ------------------------------------------ # ------------------------------------------

Loading…
Cancel
Save