Compare commits
5
Commits
94cef15048
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293503d7a2 | ||
|
|
f0cb958ea2 | ||
|
|
7f1e78fa08 | ||
|
|
6b93045514 | ||
|
|
56dcd4fb35 |
+10
-1
@@ -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",
|
||||||
|
|||||||
+5
-3
@@ -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"),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
+10
@@ -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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.put(use: update)
|
|
||||||
todo.delete(use: delete)
|
todo.delete(use: delete)
|
||||||
}
|
}
|
||||||
// todos.delete(":todoID", use: delete)
|
|
||||||
|
todos.group("sort") { todo in
|
||||||
|
todo.get(use: sort)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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)
|
||||||
throw Abort(.notFound)
|
else {
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
throw Abort(.notFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
try await todo.delete(on: req.db)
|
try await todo.delete(on: req.db)
|
||||||
|
|
||||||
return .noContent
|
return HTMLResponse {} // TODO: Return 204 No Content
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sort: Content {
|
||||||
|
let title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable
|
||||||
|
func sort(req: Request) async throws -> HTMLResponse {
|
||||||
|
let state = try getState(request: req)
|
||||||
|
|
||||||
|
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? {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
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 {
|
||||||
|
body = Response.Body(string: MainLayout(title: "Error \(statusCode))") {
|
||||||
|
ErrorPage(status: statusCode, reason: reason)
|
||||||
|
}.render())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Create a Response with appropriate status
|
// Create a Response with appropriate status
|
||||||
return Response(status: status, headers: headers, body: body)
|
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
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Attempt to render the error to a page
|
|
||||||
let statusCode = String(status.code)
|
|
||||||
body = .init(string: MainLayout(title: "Error \(statusCode))") {
|
|
||||||
ErrorPage(status: statusCode, reason: reason)
|
|
||||||
}.render())
|
|
||||||
headers.contentType = .html
|
|
||||||
}
|
|
||||||
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
private let environment: Environment
|
private let environment: Environment
|
||||||
|
private let errorMiddleware: ErrorMiddleware
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-4
@@ -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")) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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,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))
|
||||||
|
|
||||||
|
|||||||
+22
-12
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ struct AppTests {
|
|||||||
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()} )
|
||||||
})
|
})
|
||||||
@@ -48,7 +48,7 @@ struct AppTests {
|
|||||||
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)
|
||||||
@@ -66,7 +66,7 @@ struct AppTests {
|
|||||||
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# ------------------------------------------
|
# ------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user