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. 100
      Sources/App/Controllers/TodoController.swift
  9. 8
      Sources/App/DTOs/TodoDTO.swift
  10. 24
      Sources/App/Extensions/Elementary.swift
  11. 63
      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. 6
      Tests/AppTests/AppTests.swift
  25. 10
      requests

11
Package.resolved

@ -1,5 +1,5 @@
{
"originHash" : "928a4b649897cc0b7c73d0862d9400289c0503386d6f44998334e327c5931856",
"originHash" : "d39a0a797e191acb95e23a306e38d51e93c56adbbcfd76f4476a7deecf94537b",
"pins" : [
{
"identity" : "async-http-client",
@ -37,6 +37,15 @@
"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",
"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/vapor-community/vapor-elementary.git", from: "0.2.0"),
//
.package(url: "https://github.com/sliemeobn/elementary-htmx.git", from: "0.3.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
// .product(name: "ElementaryHTMX", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXSSE", package: "elementary-htmx"),
// .product(name: "ElementaryHTMXWS", package: "elementary-htmx"),
.product(name: "ElementaryHTMX", package: "elementary-htmx"),
.product(name: "ElementaryHTMXSSE", package: "elementary-htmx"),
.product(name: "ElementaryHTMXWS", package: "elementary-htmx"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"),
.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 { }
}
}

100
Sources/App/Controllers/TodoController.swift

@ -1,72 +1,111 @@
import Elementary
import ElementaryHTMX
import ElementaryHTMXSSE
import ElementaryHTMXWS
import Fluent
import Vapor
import VaporElementary
struct TodoController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let todos = routes.grouped("todos")
routes.group("todos") { todos in
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(":todoID", use: delete)
todos.group("sort") { todo in
todo.get(use: sort)
}
}
}
@Sendable
func index(req: Request) async throws -> [TodoDTO] {
try await Todo.query(on: req.db).all().map { $0.toDTO() }
func index(req: Request) async throws -> HTMLResponse {
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
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)
func create(req: Request) async throws -> HTMLResponse {
do {
try TodoDTO.validate(content: req)
} 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()
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
func update(req: Request) async throws -> TodoDTO {
func delete(req: Request) async throws -> HTMLResponse {
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)
}
let updatedTodo = try req.content.decode(Todo.self)
todo.title = updatedTodo.title
try await todo.save(on: req.db)
try await todo.delete(on: req.db)
return todo.toDTO()
return HTMLResponse {} // TODO: Return 204 No Content
}
@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)
struct Sort: Content {
let title: String
}
try await todo.delete(on: req.db)
@Sendable
func sort(req: Request) async throws -> HTMLResponse {
let state = try getState(request: req)
return .noContent
let sort = try req.query.decode(Sort.self)
state.todos.table.sort["title"] = sort.title == "descending" ? .descending : .ascending
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? {
var uuid: String = hex.replacingOccurrences(of: "-", with: "")
@ -79,4 +118,5 @@ struct TodoController: RouteCollection {
return UUID(uuidString: uuid)
}
}

8
Sources/App/DTOs/TodoDTO.swift

@ -15,3 +15,11 @@ struct TodoDTO: Content {
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)
}
}
}

63
Sources/App/Middleware/CustomErrorMiddleware.swift

@ -11,17 +11,25 @@ public final class CustomErrorMiddleware: Middleware {
public init(environment: Environment) {
self.environment = environment
self.errorMiddleware = ErrorMiddleware.`default`(environment: environment)
}
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 status: HTTPResponseStatus
var headers: HTTPHeaders
@ -47,45 +55,34 @@ public final class CustomErrorMiddleware: Middleware {
// Report the error
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)
// Create a Response with appropriate status
return Response(status: status, headers: headers, body: body)
}
private func makeResponseBody(with req: Request, reason: String, status: HTTPResponseStatus,
headers: inout HTTPHeaders) -> Response.Body {
let body: Response.Body
headers.contentType = .html
if let acceptHeader = req.headers.first(name: "Accept"),
acceptHeader == "application/json" {
// Attempt to serialize the error to JSON
do {
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
let statusCode = String(status.code)
var body = Response.Body()
// Display error in toast message for HTMX requests
if let isHTMXHeader = req.headers.first(name: "HX-Request"), isHTMXHeader == "true" {
let state = try getState(request: req)
// Only set a new toast if the endpoint hasnt already
if state.toast.message.isEmpty {
state.toast = ToastState(message: reason,
title: "Error \(statusCode)",
level: ToastState.Level.error)
}
headers.add(name: "HX-Trigger", value: "toast")
}
// Render error to a page
else {
// Attempt to render the error to a page
let statusCode = String(status.code)
body = .init(string: MainLayout(title: "Error \(statusCode))") {
body = Response.Body(string: MainLayout(title: "Error \(statusCode))") {
ErrorPage(status: statusCode, reason: reason)
}.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 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
// https://www.srihash.org/
extension MainLayout: Sendable where Body: Sendable {}
struct MainLayout<Body: HTML>: HTMLDocument {
var title: String
@HTMLBuilder var pageContent: Body // This var name can't be changed!
// https://www.srihash.org/
var head: some HTML {
meta(.charset(.utf8))
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"),
.integrity("sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"),
.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 {
"""
@ -38,14 +49,17 @@ struct MainLayout<Body: HTML>: HTMLDocument {
}
// ---------------------------------
// Body
// Content
main {
div(.class("cotainer mt-4")) {
div(.class("container mt-4")) {
div(.class("content px-4 pb-4")) {
pageContent
}
}
// Placeholder for all toast messages
ToastComponent()
}
// ---------------------------------
@ -57,6 +71,22 @@ struct MainLayout<Body: HTML>: HTMLDocument {
.integrity("sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"),
.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")) {}
}

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 {
"""
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu
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.
Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis
libero. Pellentesque habitant morbi tristique senectus et netus et malesuada
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
venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis
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
aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum
mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula.
Quisque viverra diam velit, quis ultricies nisl lacinia vitae.
"""
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam tincidunt arcu
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.
Vestibulum accumsan interdum varius. Morbi at diam luctus, mattis mi nec, mollis
libero. Pellentesque habitant morbi tristique senectus et netus et malesuada
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
venenatis, at dictum arcu ultrices. Etiam hendrerit, purus vitae sagittis
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
aliquet tempor. Nullam nec consequat magna. Etiam posuere, ipsum id condimentum
mollis, massa libero efficitur lectus, nec tempor mauris tellus ut ligula.
Quisque viverra diam velit, quis ultricies nisl lacinia vitae.
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
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.
Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc
vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in
viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet.
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
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.
Praesent vestibulum arcu enim, hendrerit convallis risus facilisis eu. Nunc
vitae mauris eu nulla laoreet rhoncus. Nullam ligula tellus, vulputate in
viverra nec, eleifend eu nulla. Praesent suscipit rutrum imperdiet.
Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum,
sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris
magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu
vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam
consequat enim, sit amet sodales erat quam eget massa.
Curabitur in lacus eu diam cursus viverra non eget turpis. Donec a ornare ipsum,
sed egestas orci. Vivamus congue gravida elementum. Pellentesque vitae mauris
magna. Phasellus blandit urna vitae auctor consectetur. Aenean iaculis eget arcu
vitae ultricies. Nunc maximus, massa hendrerit faucibus fringilla, eros quam
consequat enim, sit amet sodales erat quam eget massa.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis
urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor.
Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae
ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper
fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla
nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla
facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae
neque venenatis porta.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum in venenatis
urna. Vestibulum lectus arcu, scelerisque in ipsum vitae, feugiat cursus tortor.
Maecenas aliquam nunc enim, id fringilla est mattis et. Vivamus vitae
ullamcorper erat. Sed quis vehicula felis, quis bibendum nunc. Duis semper
fermentum ante, id fermentum neque varius convallis. Donec dui leo, fringilla
nec massa nec, fermentum molestie purus. Donec eget feugiat velit. Nulla
facilisi. Cras maximus felis eu libero mollis consectetur. Nulla molestie vitae
neque venenatis porta.
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
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
porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis
condimentum nulla.
"""
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
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
porta luctus et sed diam. Donec felis ante, euismod a est vitae, mollis
condimentum nulla.
"""
}
}
}

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

@ -15,7 +15,10 @@ struct NavMenu: HTML {
div(.class("collapse navbar-collapse"), .id("navbarNav")) {
ul(.class("navbar-nav me-auto mb-2 mb-lg-0")) {
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")) {
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
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))

34
Sources/App/routes.swift

@ -7,26 +7,36 @@ func routes(_ app: Application) throws {
app.routes.caseInsensitive = true
app.get { req async throws in
let todo = Todo(title: "Test Todo")
try await todo.save(on: req.db)
return "It works!"
HTMLResponse {
MainLayout(title: "Homepage") {
IndexPage()
}
}
}
app.get("hello") { req async -> String in
"Hello, world!"
}
app.get("test") { _ in
HTMLResponse {
MainLayout(title: "Test123") {
IndexPage()
}
}
}
app.get("toast", use: toast)
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)
}
}
/*

6
Tests/AppTests/AppTests.swift

@ -36,7 +36,7 @@ struct AppTests {
let sampleTodos = [Todo(title: "sample1"), Todo(title: "sample2")]
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(try res.content.decode([TodoDTO].self) == sampleTodos.map { $0.toDTO()} )
})
@ -48,7 +48,7 @@ struct AppTests {
let newDTO = TodoDTO(id: nil, title: "test")
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)
}, afterResponse: { res async throws in
#expect(res.status == .ok)
@ -66,7 +66,7 @@ struct AppTests {
try await withApp { app in
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)
let model = try await Todo.find(testTodos[0].id, on: app.db)
#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
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
# ------------------------------------------
POST http://localhost:8080/todos
POST http://localhost:8080/api/todos
-> run-hook (restclient-set-var ":id" (cdr (assq 'id (json-read))))
Accept: 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))))
Accept: 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,
# title FROM riyyi.todos;
DELETE http://localhost:8080/todos/:id
DELETE http://localhost:8080/api/todos/:id
Accept: application/json
# ------------------------------------------

Loading…
Cancel
Save