Version 3 #67
@ -6,32 +6,6 @@ module MyPrayerJournal.Handlers
|
|||||||
|
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open Giraffe.Htmx
|
open Giraffe.Htmx
|
||||||
open MyPrayerJournal.Data.Extensions
|
|
||||||
|
|
||||||
let writeView view : HttpHandler =
|
|
||||||
fun next ctx -> task {
|
|
||||||
return! ctx.WriteHtmlViewAsync view
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a partial result if this is not a full page load
|
|
||||||
let partialIfNotRefresh (pageTitle : string) content : HttpHandler =
|
|
||||||
fun next ctx ->
|
|
||||||
(next, ctx)
|
|
||||||
||> match ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh with
|
|
||||||
| true ->
|
|
||||||
ctx.Response.Headers.["X-Page-Title"] <- Microsoft.Extensions.Primitives.StringValues pageTitle
|
|
||||||
withHxTriggerAfterSettle "menu-refresh" >=> writeView content
|
|
||||||
| false -> writeView (Views.Layout.view pageTitle content)
|
|
||||||
|
|
||||||
/// Handler to return Vue files
|
|
||||||
module Vue =
|
|
||||||
|
|
||||||
/// The application index page
|
|
||||||
let app : HttpHandler =
|
|
||||||
withHxTrigger "menu-refresh"
|
|
||||||
>=> partialIfNotRefresh "" (ViewEngine.HtmlElements.str "It works")
|
|
||||||
|
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
|
||||||
/// Handlers for error conditions
|
/// Handlers for error conditions
|
||||||
@ -46,26 +20,26 @@ module Error =
|
|||||||
|
|
||||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||||
let notFound : HttpHandler =
|
let notFound : HttpHandler =
|
||||||
fun next ctx ->
|
setStatusCode 404 >=> text "Not found"
|
||||||
[ "/journal"; "/legal"; "/request"; "/user" ]
|
|
||||||
|> List.filter ctx.Request.Path.Value.StartsWith
|
|
||||||
|> List.length
|
|
||||||
|> function
|
|
||||||
| 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx
|
|
||||||
| _ -> Vue.app next ctx
|
|
||||||
|
|
||||||
open Cuid
|
|
||||||
open LiteDB
|
|
||||||
open System.Security.Claims
|
|
||||||
open Microsoft.Net.Http.Headers
|
|
||||||
|
|
||||||
/// Handler helpers
|
/// Handler helpers
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module private Helpers =
|
module private Helpers =
|
||||||
|
|
||||||
|
open Cuid
|
||||||
|
open LiteDB
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
|
open Microsoft.Net.Http.Headers
|
||||||
|
open System.Security.Claims
|
||||||
open System.Threading.Tasks
|
open System.Threading.Tasks
|
||||||
|
|
||||||
|
let debug (ctx : HttpContext) message =
|
||||||
|
let fac = ctx.GetService<ILoggerFactory>()
|
||||||
|
let log = fac.CreateLogger "Debug"
|
||||||
|
log.LogInformation message
|
||||||
|
|
||||||
/// Get the LiteDB database
|
/// Get the LiteDB database
|
||||||
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
|
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
|
||||||
|
|
||||||
@ -107,20 +81,31 @@ module private Helpers =
|
|||||||
let authorize : HttpHandler =
|
let authorize : HttpHandler =
|
||||||
fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx
|
fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx
|
||||||
|
|
||||||
/// Flip JSON result so we can pipe into it
|
|
||||||
let asJson<'T> next ctx (o : 'T) =
|
|
||||||
json o next ctx
|
|
||||||
|
|
||||||
/// Trigger a menu item refresh
|
|
||||||
let withMenuRefresh : HttpHandler =
|
|
||||||
withHxTriggerAfterSettle "menu-refresh"
|
|
||||||
|
|
||||||
/// Render a component result
|
/// Render a component result
|
||||||
let renderComponent nodes : HttpHandler =
|
let renderComponent nodes : HttpHandler =
|
||||||
fun next ctx -> task {
|
fun next ctx -> task {
|
||||||
return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes)
|
return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Composable handler to write a view to the output
|
||||||
|
let writeView view : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
return! ctx.WriteHtmlViewAsync view
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a partial result if this is not a full page load
|
||||||
|
let partialIfNotRefresh (pageTitle : string) content : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
(next, ctx)
|
||||||
|
||> match ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh with
|
||||||
|
| true ->
|
||||||
|
ctx.Response.Headers.["X-Page-Title"] <- Microsoft.Extensions.Primitives.StringValues pageTitle
|
||||||
|
withHxTriggerAfterSettle "menu-refresh" >=> writeView content
|
||||||
|
| false -> writeView (Views.Layout.view pageTitle content)
|
||||||
|
|
||||||
|
/// Add a success message header to the response
|
||||||
|
let withSuccessMessage : string -> HttpHandler =
|
||||||
|
sprintf "success|||%s" >> setHttpHeader "X-Toast"
|
||||||
|
|
||||||
/// Strongly-typed models for post requests
|
/// Strongly-typed models for post requests
|
||||||
module Models =
|
module Models =
|
||||||
@ -131,7 +116,7 @@ module Models =
|
|||||||
/// The status of the history update
|
/// The status of the history update
|
||||||
status : string
|
status : string
|
||||||
/// The text of the update
|
/// The text of the update
|
||||||
updateText : string
|
updateText : string option
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An additional note
|
/// An additional note
|
||||||
@ -154,15 +139,15 @@ module Models =
|
|||||||
[<CLIMutable>]
|
[<CLIMutable>]
|
||||||
type Request = {
|
type Request = {
|
||||||
/// The ID of the request
|
/// The ID of the request
|
||||||
id : string
|
requestId : string
|
||||||
/// The text of the request
|
/// The text of the request
|
||||||
requestText : string
|
requestText : string
|
||||||
/// The additional status to record
|
/// The additional status to record
|
||||||
status : string option
|
status : string option
|
||||||
/// The recurrence type
|
/// The recurrence type
|
||||||
recurType : string
|
recurType : string
|
||||||
/// The recurrence count
|
/// The recurrence count
|
||||||
recurCount : int16 option
|
recurCount : int16 option
|
||||||
/// The recurrence interval
|
/// The recurrence interval
|
||||||
recurInterval : string option
|
recurInterval : string option
|
||||||
}
|
}
|
||||||
@ -175,6 +160,8 @@ module Models =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
open MyPrayerJournal.Data.Extensions
|
||||||
|
|
||||||
/// Handlers for less-than-full-page HTML requests
|
/// Handlers for less-than-full-page HTML requests
|
||||||
module Components =
|
module Components =
|
||||||
|
|
||||||
@ -193,6 +180,34 @@ module Components =
|
|||||||
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
|
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
return! renderComponent [ Views.Journal.journalItems jrnl ] next ctx
|
return! renderComponent [ Views.Journal.journalItems jrnl ] next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /components/request/[req-id]/edit
|
||||||
|
let requestEdit requestId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match requestId with
|
||||||
|
| "new" ->
|
||||||
|
return! partialIfNotRefresh "Add Prayer Request"
|
||||||
|
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx
|
||||||
|
| _ ->
|
||||||
|
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
||||||
|
| Some req ->
|
||||||
|
return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /components/request-item/[req-id]
|
||||||
|
let requestItem reqId : HttpHandler =
|
||||||
|
authorize
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
match! Data.tryJournalById (RequestId.ofString reqId) (userId ctx) (db ctx) with
|
||||||
|
| Some req ->
|
||||||
|
debug ctx "Found the item"
|
||||||
|
return! renderComponent [ Views.Request.reqListItem req ] next ctx
|
||||||
|
| None ->
|
||||||
|
debug ctx "Did not find the item"
|
||||||
|
return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// / URL
|
/// / URL
|
||||||
@ -200,7 +215,7 @@ module Home =
|
|||||||
|
|
||||||
// GET /
|
// GET /
|
||||||
let home : HttpHandler =
|
let home : HttpHandler =
|
||||||
withMenuRefresh >=> partialIfNotRefresh "Welcome!" Views.Home.home
|
partialIfNotRefresh "Welcome!" Views.Home.home
|
||||||
|
|
||||||
// GET /user/log-on
|
// GET /user/log-on
|
||||||
let logOn : HttpHandler =
|
let logOn : HttpHandler =
|
||||||
@ -213,7 +228,6 @@ module Journal =
|
|||||||
// GET /journal
|
// GET /journal
|
||||||
let journal : HttpHandler =
|
let journal : HttpHandler =
|
||||||
authorize
|
authorize
|
||||||
>=> withMenuRefresh
|
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let usr = ctx.Request.Headers.["X-Given-Name"].[0]
|
let usr = ctx.Request.Headers.["X-Given-Name"].[0]
|
||||||
return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx
|
return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx
|
||||||
@ -225,11 +239,11 @@ module Legal =
|
|||||||
|
|
||||||
// GET /legal/privacy-policy
|
// GET /legal/privacy-policy
|
||||||
let privacyPolicy : HttpHandler =
|
let privacyPolicy : HttpHandler =
|
||||||
withMenuRefresh >=> partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy
|
partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy
|
||||||
|
|
||||||
// GET /legal/terms-of-service
|
// GET /legal/terms-of-service
|
||||||
let termsOfService : HttpHandler =
|
let termsOfService : HttpHandler =
|
||||||
withMenuRefresh >=> partialIfNotRefresh "Terms of Service" Views.Legal.termsOfService
|
partialIfNotRefresh "Terms of Service" Views.Legal.termsOfService
|
||||||
|
|
||||||
|
|
||||||
/// Alias for the Ply task module (The F# "task" CE can't handle differing types well within the same CE)
|
/// Alias for the Ply task module (The F# "task" CE can't handle differing types well within the same CE)
|
||||||
@ -239,72 +253,6 @@ module Ply = FSharp.Control.Tasks.Affine
|
|||||||
/// /api/request and /request(s) URLs
|
/// /api/request and /request(s) URLs
|
||||||
module Request =
|
module Request =
|
||||||
|
|
||||||
// GET /request/[req-id]/edit
|
|
||||||
let edit requestId : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
match requestId with
|
|
||||||
| "new" ->
|
|
||||||
return! partialIfNotRefresh "Add Prayer Request"
|
|
||||||
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx
|
|
||||||
| _ ->
|
|
||||||
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
|
||||||
| Some req -> return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a new prayer request
|
|
||||||
let private addRequest (form : Models.Request) : HttpHandler =
|
|
||||||
fun next ctx -> task {
|
|
||||||
let db = db ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
let now = jsNow ()
|
|
||||||
let req =
|
|
||||||
{ Request.empty with
|
|
||||||
userId = usrId
|
|
||||||
enteredOn = now
|
|
||||||
showAfter = Ticks 0L
|
|
||||||
recurType = Recurrence.fromString (match form.recurInterval with Some x -> x | _ -> "Immediate")
|
|
||||||
recurCount = defaultArg form.recurCount (int16 0)
|
|
||||||
history = [
|
|
||||||
{ asOf = now
|
|
||||||
status = Created
|
|
||||||
text = Some form.requestText
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Data.addRequest req db
|
|
||||||
do! db.saveChanges ()
|
|
||||||
return! (withHxRedirect "/journal" >=> createdAt (RequestId.toString req.id |> sprintf "/request/%s")) next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a prayer request
|
|
||||||
let private updateRequest (form : Models.Request) : HttpHandler =
|
|
||||||
fun next ctx -> Ply.task {
|
|
||||||
let db = db ctx
|
|
||||||
let usrId = userId ctx
|
|
||||||
match! Data.tryJournalById (RequestId.ofString form.id) usrId db with
|
|
||||||
| Some req ->
|
|
||||||
// TODO: Update recurrence if changed
|
|
||||||
let text =
|
|
||||||
match form.requestText.Trim () = req.text with
|
|
||||||
| true -> None
|
|
||||||
| false -> form.requestText.Trim () |> Some
|
|
||||||
do! Data.addHistory req.requestId usrId
|
|
||||||
{ asOf = jsNow (); status = (Option.get >> RequestAction.fromString) form.status; text = text } db
|
|
||||||
return! setStatusCode 200 next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /request
|
|
||||||
let save : HttpHandler =
|
|
||||||
authorize
|
|
||||||
>=> fun next ctx -> task {
|
|
||||||
let! form = ctx.BindModelAsync<Models.Request> ()
|
|
||||||
let func = match form.id with "new" -> addRequest | _ -> updateRequest
|
|
||||||
return! func form next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/request/[req-id]/history
|
/// POST /api/request/[req-id]/history
|
||||||
let addHistory requestId : HttpHandler =
|
let addHistory requestId : HttpHandler =
|
||||||
authorize
|
authorize
|
||||||
@ -320,7 +268,7 @@ module Request =
|
|||||||
do! Data.addHistory reqId usrId
|
do! Data.addHistory reqId usrId
|
||||||
{ asOf = now
|
{ asOf = now
|
||||||
status = act
|
status = act
|
||||||
text = match hist.updateText with null | "" -> None | x -> Some x
|
text = match hist.updateText with None | Some "" -> None | x -> x
|
||||||
} db
|
} db
|
||||||
match act with
|
match act with
|
||||||
| Prayed ->
|
| Prayed ->
|
||||||
@ -338,7 +286,6 @@ module Request =
|
|||||||
/// POST /api/request/[req-id]/note
|
/// POST /api/request/[req-id]/note
|
||||||
let addNote requestId : HttpHandler =
|
let addNote requestId : HttpHandler =
|
||||||
authorize
|
authorize
|
||||||
// >=> allowSyncIO
|
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -355,7 +302,6 @@ module Request =
|
|||||||
/// GET /requests/active
|
/// GET /requests/active
|
||||||
let active : HttpHandler =
|
let active : HttpHandler =
|
||||||
authorize
|
authorize
|
||||||
>=> withMenuRefresh
|
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx
|
return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx
|
||||||
@ -364,7 +310,6 @@ module Request =
|
|||||||
/// GET /requests/answered
|
/// GET /requests/answered
|
||||||
let answered : HttpHandler =
|
let answered : HttpHandler =
|
||||||
authorize
|
authorize
|
||||||
>=> withMenuRefresh
|
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let! reqs = Data.answeredRequests (userId ctx) (db ctx)
|
let! reqs = Data.answeredRequests (userId ctx) (db ctx)
|
||||||
return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx
|
return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx
|
||||||
@ -382,7 +327,6 @@ module Request =
|
|||||||
/// GET /request/[req-id]/full
|
/// GET /request/[req-id]/full
|
||||||
let getFull requestId : HttpHandler =
|
let getFull requestId : HttpHandler =
|
||||||
authorize
|
authorize
|
||||||
>=> withMenuRefresh
|
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
match! Data.tryFullRequestById (toReqId requestId) (userId ctx) (db ctx) with
|
match! Data.tryFullRequestById (toReqId requestId) (userId ctx) (db ctx) with
|
||||||
| Some req -> return! partialIfNotRefresh "Full Prayer Request" (Views.Request.full req) next ctx
|
| Some req -> return! partialIfNotRefresh "Full Prayer Request" (Views.Request.full req) next ctx
|
||||||
@ -448,6 +392,69 @@ module Request =
|
|||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a recurrence and interval from its primitive representation in the form
|
||||||
|
let private parseRecurrence (form : Models.Request) =
|
||||||
|
(Recurrence.fromString (match form.recurInterval with Some x -> x | _ -> "Immediate"),
|
||||||
|
defaultArg form.recurCount (int16 0))
|
||||||
|
|
||||||
|
// POST /request
|
||||||
|
let add : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
let now = jsNow ()
|
||||||
|
let (recur, interval) = parseRecurrence form
|
||||||
|
let req =
|
||||||
|
{ Request.empty with
|
||||||
|
userId = usrId
|
||||||
|
enteredOn = now
|
||||||
|
showAfter = Ticks 0L
|
||||||
|
recurType = recur
|
||||||
|
recurCount = interval
|
||||||
|
history = [
|
||||||
|
{ asOf = now
|
||||||
|
status = Created
|
||||||
|
text = Some form.requestText
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Data.addRequest req db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
// TODO: this is not right
|
||||||
|
return! (withHxRedirect "/journal" >=> createdAt (RequestId.toString req.id |> sprintf "/request/%s")) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /request
|
||||||
|
let update : HttpHandler =
|
||||||
|
fun next ctx -> Ply.task {
|
||||||
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
|
let db = db ctx
|
||||||
|
let usrId = userId ctx
|
||||||
|
match! Data.tryJournalById (RequestId.ofString form.requestId) usrId db with
|
||||||
|
| Some req ->
|
||||||
|
// step 1 - update recurrence if changed
|
||||||
|
let (recur, interval) = parseRecurrence form
|
||||||
|
match recur = req.recurType && interval = req.recurCount with
|
||||||
|
| true -> ()
|
||||||
|
| false ->
|
||||||
|
do! Data.updateRecurrence req.requestId usrId recur interval db
|
||||||
|
match recur with
|
||||||
|
| Immediate -> do! Data.updateShowAfter req.requestId usrId (Ticks 0L) db
|
||||||
|
| _ -> ()
|
||||||
|
// step 2 - append history
|
||||||
|
let upd8Text = form.requestText.Trim ()
|
||||||
|
let text = match upd8Text = req.text with true -> None | false -> Some upd8Text
|
||||||
|
do! Data.addHistory req.requestId usrId
|
||||||
|
{ asOf = jsNow (); status = (Option.get >> RequestAction.fromString) form.status; text = text } db
|
||||||
|
do! db.saveChanges ()
|
||||||
|
// step 3 - return updated view
|
||||||
|
return! (withSuccessMessage "Prayer request updated successfully"
|
||||||
|
>=> Components.requestItem (RequestId.toString req.requestId)) next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
open Giraffe.EndpointRouting
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
@ -456,8 +463,10 @@ let routes =
|
|||||||
[ GET_HEAD [ route "/" Home.home ]
|
[ GET_HEAD [ route "/" Home.home ]
|
||||||
subRoute "/components/" [
|
subRoute "/components/" [
|
||||||
GET_HEAD [
|
GET_HEAD [
|
||||||
route "journal-items" Components.journalItems
|
route "journal-items" Components.journalItems
|
||||||
route "nav-items" Components.navItems
|
route "nav-items" Components.navItems
|
||||||
|
routef "request/%s/edit" Components.requestEdit
|
||||||
|
routef "request/%s/item" Components.requestItem
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/journal" Journal.journal ]
|
GET_HEAD [ route "/journal" Journal.journal ]
|
||||||
@ -472,10 +481,12 @@ let routes =
|
|||||||
route "s/active" Request.active
|
route "s/active" Request.active
|
||||||
route "s/answered" Request.answered
|
route "s/answered" Request.answered
|
||||||
routef "/%s/full" Request.getFull
|
routef "/%s/full" Request.getFull
|
||||||
routef "/%s/edit" Request.edit
|
]
|
||||||
|
PATCH [
|
||||||
|
route "" Request.update
|
||||||
]
|
]
|
||||||
POST [
|
POST [
|
||||||
route "/request" Request.save
|
route "" Request.add
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/user/log-on" Home.logOn ]
|
GET_HEAD [ route "/user/log-on" Home.logOn ]
|
||||||
|
@ -63,81 +63,85 @@ module Legal =
|
|||||||
|
|
||||||
/// View for the "Privacy Policy" page
|
/// View for the "Privacy Policy" page
|
||||||
let privacyPolicy = article [ _class "container mt-3" ] [
|
let privacyPolicy = article [ _class "container mt-3" ] [
|
||||||
|
h2 [ _class "mb-2" ] [ str "Privacy Policy" ]
|
||||||
|
h6 [ _class "text-muted pb-3" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
|
||||||
|
p [] [
|
||||||
|
str "The nature of the service is one where privacy is a must. The items below will help you understand the data "
|
||||||
|
str "we collect, access, and store on your behalf as you use this service."
|
||||||
|
]
|
||||||
div [ _class "card" ] [
|
div [ _class "card" ] [
|
||||||
h5 [ _class "card-header" ] [ str "Privacy Policy" ]
|
div [ _class "list-group list-group-flush" ] [
|
||||||
div [ _class "card-body" ] [
|
div [ _class "list-group-item"] [
|
||||||
h6 [ _class "card-subtitle text-muted" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
|
h3 [] [ str "Third Party Services" ]
|
||||||
p [ _class "card-text" ] [
|
p [ _class "card-text" ] [
|
||||||
str "The nature of the service is one where privacy is a must. The items below will help you understand "
|
str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
|
||||||
str "the data we collect, access, and store on your behalf as you use this service."
|
str "yourself with the privacy policy for "
|
||||||
]
|
a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ]
|
||||||
hr []
|
str ", as well as your chosen provider ("
|
||||||
h3 [] [ str "Third Party Services" ]
|
a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [ str "Microsoft"]
|
||||||
p [ _class "card-text" ] [
|
str " or "
|
||||||
str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
|
a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ]
|
||||||
str "yourself with the privacy policy for "
|
str ")."
|
||||||
a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ]
|
|
||||||
str ", as well as your chosen provider ("
|
|
||||||
a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [ str "Microsoft"]
|
|
||||||
str " or "
|
|
||||||
a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ]
|
|
||||||
str ")."
|
|
||||||
]
|
|
||||||
hr []
|
|
||||||
h3 [] [ str "What We Collect" ]
|
|
||||||
h4 [] [ str "Identifying Data" ]
|
|
||||||
ul [] [
|
|
||||||
li [] [
|
|
||||||
rawText "The only identifying data myPrayerJournal stores is the subscriber (“sub”) field "
|
|
||||||
str "from the token we receive from Auth0, once you have signed in through their hosted service. "
|
|
||||||
str "All information is associated with you via this field."
|
|
||||||
]
|
|
||||||
li [] [
|
|
||||||
str "While you are signed in, within your browser, the service has access to your first and last names, "
|
|
||||||
str "along with a URL to the profile picture (provided by your selected identity provider). This "
|
|
||||||
rawText "information is not transmitted to the server, and is removed when “Log Off” is "
|
|
||||||
str "clicked."
|
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
h4 [] [ str "User Provided Data" ]
|
div [ _class "list-group-item" ] [
|
||||||
ul [] [
|
h3 [] [ str "What We Collect" ]
|
||||||
li [] [
|
h4 [] [ str "Identifying Data" ]
|
||||||
str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, "
|
ul [] [
|
||||||
str "and notes; and the date/time when certain actions are taken."
|
li [] [
|
||||||
|
rawText "The only identifying data myPrayerJournal stores is the subscriber (“sub”) field "
|
||||||
|
str "from the token we receive from Auth0, once you have signed in through their hosted service. "
|
||||||
|
str "All information is associated with you via this field."
|
||||||
|
]
|
||||||
|
li [] [
|
||||||
|
str "While you are signed in, within your browser, the service has access to your first and last names, "
|
||||||
|
str "along with a URL to the profile picture (provided by your selected identity provider). This "
|
||||||
|
rawText "information is not transmitted to the server, and is removed when “Log Off” is "
|
||||||
|
str "clicked."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
h4 [] [ str "User Provided Data" ]
|
||||||
|
ul [ _class "mb-0" ] [
|
||||||
|
li [] [
|
||||||
|
str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, "
|
||||||
|
str "and notes; and the date/time when certain actions are taken."
|
||||||
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
hr []
|
div [ _class "list-group-item" ] [
|
||||||
h3 [] [ str "How Your Data Is Accessed / Secured" ]
|
h3 [] [ str "How Your Data Is Accessed / Secured" ]
|
||||||
ul [] [
|
ul [ _class "mb-0" ] [
|
||||||
li [] [
|
li [] [
|
||||||
str "Your provided data is returned to you, as required, to display your journal or your answered "
|
str "Your provided data is returned to you, as required, to display your journal or your answered "
|
||||||
str "requests. On the server, it is stored in a controlled-access database."
|
str "requests. On the server, it is stored in a controlled-access database."
|
||||||
]
|
]
|
||||||
li [] [
|
li [] [
|
||||||
str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; "
|
str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; "
|
||||||
str "backups are preserved for the prior 7 days, and backups from the 1"
|
str "backups are preserved for the prior 7 days, and backups from the 1"
|
||||||
sup [] [ str "st" ]
|
sup [] [ str "st" ]
|
||||||
str " and 15"
|
str " and 15"
|
||||||
sup [] [ str "th" ]
|
sup [] [ str "th" ]
|
||||||
str " are preserved for 3 months. These backups are stored in a private cloud data repository."
|
str " are preserved for 3 months. These backups are stored in a private cloud data repository."
|
||||||
]
|
]
|
||||||
li [] [
|
li [] [
|
||||||
str "The data collected and stored is the absolute minimum necessary for the functionality of the "
|
str "The data collected and stored is the absolute minimum necessary for the functionality of the "
|
||||||
rawText "service. There are no plans to “monetize” this service, and storing the minimum "
|
rawText "service. There are no plans to “monetize” this service, and storing the minimum "
|
||||||
str "amount of information means that the data we have is not interesting to purchasers (or those who "
|
str "amount of information means that the data we have is not interesting to purchasers (or those who "
|
||||||
str "may have more nefarious purposes)."
|
str "may have more nefarious purposes)."
|
||||||
]
|
]
|
||||||
li [] [
|
li [] [
|
||||||
str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
|
str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
|
||||||
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
hr []
|
div [ _class "list-group-item" ] [
|
||||||
h3 [] [ str "Removing Your Data" ]
|
h3 [] [ str "Removing Your Data" ]
|
||||||
p [ _class "card-text" ] [
|
p [ _class "card-text" ] [
|
||||||
str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways "
|
str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways "
|
||||||
str "to revoke access from this application. However, if you want your data removed from the database, "
|
str "to revoke access from this application. However, if you want your data removed from the database, "
|
||||||
str "please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to "
|
str "please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to "
|
||||||
str "ensure we can determine which subscriber ID belongs to you."
|
str "ensure we can determine which subscriber ID belongs to you."
|
||||||
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@ -145,55 +149,64 @@ module Legal =
|
|||||||
|
|
||||||
/// View for the "Terms of Service" page
|
/// View for the "Terms of Service" page
|
||||||
let termsOfService = article [ _class "container mt-3" ] [
|
let termsOfService = article [ _class "container mt-3" ] [
|
||||||
|
h2 [ _class "mb-2" ] [ str "Terms of Service" ]
|
||||||
|
h6 [ _class "text-muted pb-3"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
|
||||||
div [ _class "card" ] [
|
div [ _class "card" ] [
|
||||||
h5 [ _class "card-header" ] [ str "Terms of Service" ]
|
div [ _class "list-group list-group-flush" ] [
|
||||||
div [ _class "card-body" ] [
|
div [ _class "list-group-item" ] [
|
||||||
h6 [ _class "card-subtitle text-muted"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
|
h3 [] [ str "1. Acceptance of Terms" ]
|
||||||
h3 [] [ str "1. Acceptance of Terms" ]
|
p [ _class "card-text" ] [
|
||||||
p [ _class "card-text" ] [
|
str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you "
|
||||||
str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you "
|
str "are responsible to ensure that your use of this site complies with all applicable laws. Your "
|
||||||
str "are responsible to ensure that your use of this site complies with all applicable laws. Your continued "
|
str "continued use of this site implies your acceptance of these terms."
|
||||||
str "use of this site implies your acceptance of these terms."
|
]
|
||||||
]
|
]
|
||||||
h3 [] [ str "2. Description of Service and Registration" ]
|
div [ _class "list-group-item" ] [
|
||||||
p [ _class "card-text" ] [
|
h3 [] [ str "2. Description of Service and Registration" ]
|
||||||
str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It "
|
p [ _class "card-text" ] [
|
||||||
str "requires no registration by itself, but access is granted based on a successful login with an external "
|
str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It "
|
||||||
str "identity provider. See "
|
str "requires no registration by itself, but access is granted based on a successful login with an "
|
||||||
pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ]
|
str "external identity provider. See "
|
||||||
str " for details on how that information is accessed and stored."
|
pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ]
|
||||||
|
str " for details on how that information is accessed and stored."
|
||||||
|
]
|
||||||
]
|
]
|
||||||
h3 [] [ str "3. Third Party Services" ]
|
div [ _class "list-group-item" ] [
|
||||||
p [ _class "card-text" ] [
|
h3 [] [ str "3. Third Party Services" ]
|
||||||
str "This service utilizes a third-party service provider for identity management. Review the terms of "
|
p [ _class "card-text" ] [
|
||||||
str "service for"
|
str "This service utilizes a third-party service provider for identity management. Review the terms of "
|
||||||
a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
|
str "service for "
|
||||||
str ", as well as those for the selected authorization provider ("
|
a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
|
||||||
a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
|
str ", as well as those for the selected authorization provider ("
|
||||||
str " or "
|
a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
|
||||||
a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
|
str " or "
|
||||||
str ")."
|
a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
|
||||||
|
str ")."
|
||||||
|
]
|
||||||
]
|
]
|
||||||
h3 [] [ str "4. Liability" ]
|
div [ _class "list-group-item" ] [
|
||||||
p [ _class "card-text" ] [
|
h3 [] [ str "4. Liability" ]
|
||||||
rawText "This service is provided “as is”, and no warranty (express or implied) exists. The "
|
p [ _class "card-text" ] [
|
||||||
str "service and its developers may not be held liable for any damages that may arise through the use of "
|
rawText "This service is provided “as is”, and no warranty (express or implied) exists. The "
|
||||||
str "this service."
|
str "service and its developers may not be held liable for any damages that may arise through the use of "
|
||||||
|
str "this service."
|
||||||
|
]
|
||||||
]
|
]
|
||||||
h3 [] [ str "5. Updates to Terms" ]
|
div [ _class "list-group-item" ] [
|
||||||
p [ _class "card-text" ] [
|
h3 [] [ str "5. Updates to Terms" ]
|
||||||
str "These terms and conditions may be updated at any time, and this service does not have the capability to "
|
p [ _class "card-text" ] [
|
||||||
str "notify users when these change. The date at the top of the page will be updated when any of the text of "
|
str "These terms and conditions may be updated at any time, and this service does not have the capability "
|
||||||
str "these terms is updated."
|
str "to notify users when these change. The date at the top of the page will be updated when any of the "
|
||||||
]
|
str "text of these terms is updated."
|
||||||
hr []
|
]
|
||||||
p [ _class "card-text" ] [
|
|
||||||
str "You may also wish to review our "
|
|
||||||
pageLink "/legal/privacy-policy" [] [ str "privacy policy" ]
|
|
||||||
str " to learn how we handle your data."
|
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
p [ _class "pt-3" ] [
|
||||||
|
str "You may also wish to review our "
|
||||||
|
pageLink "/legal/privacy-policy" [] [ str "privacy policy" ]
|
||||||
|
str " to learn how we handle your data."
|
||||||
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -272,59 +285,49 @@ module Request =
|
|||||||
let isAnswered = req.lastStatus = Answered
|
let isAnswered = req.lastStatus = Answered
|
||||||
let isSnoozed = Ticks.toLong req.snoozedUntil > jsNow
|
let isSnoozed = Ticks.toLong req.snoozedUntil > jsNow
|
||||||
let isPending = (not isSnoozed) && Ticks.toLong req.showAfter > jsNow
|
let isPending = (not isSnoozed) && Ticks.toLong req.showAfter > jsNow
|
||||||
let btnClass = _class "btn btn-light"
|
let btnClass = _class "btn btn-light mx-2"
|
||||||
tr [] [
|
div [
|
||||||
td [ _class "action-cell" ] [
|
_class "list-group-item px-0 d-flex flex-row align-items-start"
|
||||||
div [ _class "btn-group btn-group-sm"; Accessibility._roleGroup ] [
|
_hxTarget "this"
|
||||||
pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
|
_hxSwap HxSwap.OuterHtml
|
||||||
if not isAnswered then
|
] [
|
||||||
pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ]
|
pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
|
||||||
// TODO: these next two should use hx-patch, targeting replacement of this tr when complete
|
if not isAnswered then
|
||||||
if isSnoozed then
|
button [ btnClass; _hxGet $"/components/request/{reqId}/edit"; _title "Edit Request" ] [ icon "edit" ]
|
||||||
pageLink $"/request/{reqId}/cancel-snooze" [ btnClass; _title "Cancel Snooze" ] [ icon "restore" ]
|
// TODO: these next two should use hx-patch, targeting replacement of this tr when complete
|
||||||
if isPending then
|
if isSnoozed then
|
||||||
pageLink $"/request/{reqId}/show-now" [ btnClass; _title "Show Now" ] [ icon "restore" ]
|
pageLink $"/request/{reqId}/cancel-snooze" [ btnClass; _title "Cancel Snooze" ] [ icon "restore" ]
|
||||||
]
|
if isPending then
|
||||||
]
|
pageLink $"/request/{reqId}/show-now" [ btnClass; _title "Show Now" ] [ icon "restore" ]
|
||||||
td [] [
|
p [ _class "mpj-request-text mb-0" ] [
|
||||||
p [ _class "mpj-request-text mb-0" ] [
|
str req.text
|
||||||
str req.text
|
if isSnoozed || isPending || isAnswered then
|
||||||
if isSnoozed || isPending || isAnswered then
|
br []
|
||||||
br []
|
small [ _class "text-muted" ] [
|
||||||
small [ _class "text-muted" ] [
|
em [] [
|
||||||
em [] [
|
if isSnoozed then
|
||||||
if isSnoozed then
|
str "Snooze expires "
|
||||||
str "Snooze expires "
|
relativeDate req.snoozedUntil
|
||||||
relativeDate req.snoozedUntil
|
if isPending then
|
||||||
if isPending then
|
str "Request appears next "
|
||||||
str "Request appears next "
|
relativeDate req.showAfter
|
||||||
relativeDate req.showAfter
|
if isAnswered then
|
||||||
if isAnswered then
|
str "Answered "
|
||||||
str "Answered "
|
relativeDate req.asOf
|
||||||
relativeDate req.asOf
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Create a list of requests
|
/// Create a list of requests
|
||||||
let reqList reqs =
|
let reqList reqs =
|
||||||
table [ _class "table table-hover table-sm align-top" ] [
|
reqs
|
||||||
thead [] [
|
|> List.map reqListItem
|
||||||
tr [] [
|
|> div [ _class "list-group" ]
|
||||||
th [ _scope "col" ] [ str "Actions" ]
|
|
||||||
th [ _scope "col" ] [ str "Request" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
reqs
|
|
||||||
|> List.map reqListItem
|
|
||||||
|> tbody []
|
|
||||||
]
|
|
||||||
|
|
||||||
/// View for Active Requests page
|
/// View for Active Requests page
|
||||||
let active reqs = article [ _class "container mt-3" ] [
|
let active reqs = article [ _class "container mt-3" ] [
|
||||||
h2 [] [ str "Active Requests" ]
|
h2 [ _class "pb-3" ] [ str "Active Requests" ]
|
||||||
match reqs |> List.isEmpty with
|
match reqs |> List.isEmpty with
|
||||||
| true ->
|
| true ->
|
||||||
noResults "No Active Requests" "/journal" "Return to your journal"
|
noResults "No Active Requests" "/journal" "Return to your journal"
|
||||||
@ -334,7 +337,7 @@ module Request =
|
|||||||
|
|
||||||
/// View for Answered Requests page
|
/// View for Answered Requests page
|
||||||
let answered reqs = article [ _class "container mt-3" ] [
|
let answered reqs = article [ _class "container mt-3" ] [
|
||||||
h2 [] [ str "Answered Requests" ]
|
h2 [ _class "pb-3" ] [ str "Answered Requests" ]
|
||||||
match reqs |> List.isEmpty with
|
match reqs |> List.isEmpty with
|
||||||
| true ->
|
| true ->
|
||||||
noResults "No Active Requests" "/journal" "Return to your journal" [
|
noResults "No Active Requests" "/journal" "Return to your journal" [
|
||||||
@ -346,7 +349,7 @@ module Request =
|
|||||||
|
|
||||||
/// View for Snoozed Requests page
|
/// View for Snoozed Requests page
|
||||||
let snoozed reqs = article [ _class "container mt-3" ] [
|
let snoozed reqs = article [ _class "container mt-3" ] [
|
||||||
h2 [] [ str "Snoozed Requests" ]
|
h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
|
||||||
reqList reqs
|
reqList reqs
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -409,110 +412,124 @@ module Request =
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
let edit (req : JournalRequest) isNew = article [ _class "container mt-3" ] [
|
/// View for the edit request component
|
||||||
form [ _hxPost "/request" ] [
|
let edit (req : JournalRequest) isNew =
|
||||||
input [
|
let cancelUrl = req.requestId |> (RequestId.toString >> sprintf "/components/request/%s/item")
|
||||||
_type "hidden"
|
section [ _class "container list-group-item"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
|
||||||
_name "requestId"
|
h5 [ _class "pb-3" ] [ (match isNew with true -> "Add" | false -> "Edit") |> strf "%s Prayer Request" ]
|
||||||
_value (match isNew with true -> "new" | false -> RequestId.toString req.requestId)
|
form [ "/request" |> match isNew with true -> _hxPost | false -> _hxPatch ] [
|
||||||
]
|
input [
|
||||||
div [ _class "form-floating mb-3" ] [
|
_type "hidden"
|
||||||
textarea [
|
_name "requestId"
|
||||||
_id "requestText"
|
_value (match isNew with true -> "new" | false -> RequestId.toString req.requestId)
|
||||||
_name "requestText"
|
|
||||||
_class "form-control"
|
|
||||||
_style "min-height: 4rem;"
|
|
||||||
_placeholder "Enter the text of the request"
|
|
||||||
_autofocus; _required
|
|
||||||
] [ str req.text ]
|
|
||||||
label [ _for "requestText" ] [ str "Prayer Request" ]
|
|
||||||
]
|
|
||||||
br []
|
|
||||||
match isNew with
|
|
||||||
| true ->
|
|
||||||
label [] [ str "Also Mark As" ]
|
|
||||||
br []
|
|
||||||
div [ _class "form-check form-check-inline" ] [
|
|
||||||
input [ _type "radio"; _class "form-check-input"; _id "sU"; _name "status"; _value "Updated"; _checked ]
|
|
||||||
label [ _for "sU" ] [ str "Updated" ]
|
|
||||||
]
|
|
||||||
div [ _class "form-check form-check-inline" ] [
|
|
||||||
input [ _type "radio"; _class "form-check-input"; _id "sP"; _name "status"; _value "Prayed" ]
|
|
||||||
label [ _for "sP" ] [ str "Prayed" ]
|
|
||||||
]
|
|
||||||
div [ _class "form-check form-check-inline" ] [
|
|
||||||
input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ]
|
|
||||||
label [ _for "sA" ] [ str "Answered" ]
|
|
||||||
]
|
|
||||||
br []
|
|
||||||
| false -> ()
|
|
||||||
label [] [
|
|
||||||
rawText "Recurrence "
|
|
||||||
em [ _class "text-muted" ] [ rawText "After prayer, request reappears…" ]
|
|
||||||
]
|
|
||||||
br []
|
|
||||||
div [ _class "row" ] [
|
|
||||||
div [ _class "col-4 form-check" ] [
|
|
||||||
input [
|
|
||||||
_type "radio"
|
|
||||||
_class "form-check-input"
|
|
||||||
_id "rI"
|
|
||||||
_name "recurType"
|
|
||||||
_value "Immediate"
|
|
||||||
_onclick "toggleRecurrence"
|
|
||||||
match req.recurType with Immediate -> _checked | _ -> ()
|
|
||||||
]
|
|
||||||
label [ _for "rI" ] [ str "Immediately" ]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-3 form-check"] [
|
div [ _class "form-floating pb-3" ] [
|
||||||
input [
|
textarea [
|
||||||
_type "radio"
|
_id "requestText"
|
||||||
_class "form-check-input"
|
_name "requestText"
|
||||||
_id "rO"
|
|
||||||
_name "recurType"
|
|
||||||
_value "Other"
|
|
||||||
_onclick "toggleRecurrence"
|
|
||||||
match req.recurType with Immediate -> () | _ -> _checked
|
|
||||||
]
|
|
||||||
label [ _for "rO" ] [ rawText "Every…" ]
|
|
||||||
]
|
|
||||||
div [ _class "col-2 form-floating"] [
|
|
||||||
input [
|
|
||||||
_type "number"
|
|
||||||
_class "form-control"
|
_class "form-control"
|
||||||
_id "recurCount"
|
_style "min-height: 8rem;"
|
||||||
_name "recurCount"
|
_placeholder "Enter the text of the request"
|
||||||
_placeholder "0"
|
_autofocus; _required
|
||||||
_value (string req.recurCount)
|
] [ str req.text ]
|
||||||
_required
|
label [ _for "requestText" ] [ str "Prayer Request" ]
|
||||||
]
|
|
||||||
label [ _for "recurCount" ] [ str "Count" ]
|
|
||||||
]
|
]
|
||||||
div [ _class "col-3 form-floating" ] [
|
br []
|
||||||
select [ _class "form-control"; _id "recurInterval"; _name "recurInterval"; _required ] [
|
match isNew with
|
||||||
option [ _value "Hours"; match req.recurType with Hours -> _selected | _ -> () ] [ str "hours" ]
|
| true -> ()
|
||||||
option [ _value "Days"; match req.recurType with Days -> _selected | _ -> () ] [ str "days" ]
|
| false ->
|
||||||
option [ _value "Weeks"; match req.recurType with Weeks -> _selected | _ -> () ] [ str "weeks" ]
|
div [ _class "pb-3" ] [
|
||||||
|
label [] [ str "Also Mark As" ]
|
||||||
|
br []
|
||||||
|
div [ _class "form-check form-check-inline" ] [
|
||||||
|
input [ _type "radio"; _class "form-check-input"; _id "sU"; _name "status"; _value "Updated"; _checked ]
|
||||||
|
label [ _for "sU" ] [ str "Updated" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-check form-check-inline" ] [
|
||||||
|
input [ _type "radio"; _class "form-check-input"; _id "sP"; _name "status"; _value "Prayed" ]
|
||||||
|
label [ _for "sP" ] [ str "Prayed" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-check form-check-inline" ] [
|
||||||
|
input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ]
|
||||||
|
label [ _for "sA" ] [ str "Answered" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "row" ] [
|
||||||
|
div [ _class "col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6" ] [
|
||||||
|
p [] [
|
||||||
|
strong [] [ rawText "Recurrence " ]
|
||||||
|
em [ _class "text-muted" ] [ rawText "After prayer, request reappears…" ]
|
||||||
|
]
|
||||||
|
div [ _class "d-flex flex-row flex-wrap justify-content-center align-items-center" ] [
|
||||||
|
div [ _class "form-check mx-2" ] [
|
||||||
|
input [
|
||||||
|
_type "radio"
|
||||||
|
_class "form-check-input"
|
||||||
|
_id "rI"
|
||||||
|
_name "recurType"
|
||||||
|
_value "Immediate"
|
||||||
|
_onclick "mpj.edit.toggleRecurrence(event)"
|
||||||
|
match req.recurType with Immediate -> _checked | _ -> ()
|
||||||
|
]
|
||||||
|
label [ _for "rI" ] [ str "Immediately" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-check mx-2"] [
|
||||||
|
input [
|
||||||
|
_type "radio"
|
||||||
|
_class "form-check-input"
|
||||||
|
_id "rO"
|
||||||
|
_name "recurType"
|
||||||
|
_value "Other"
|
||||||
|
_onclick "mpj.edit.toggleRecurrence(event)"
|
||||||
|
match req.recurType with Immediate -> () | _ -> _checked
|
||||||
|
]
|
||||||
|
label [ _for "rO" ] [ rawText "Every…" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-floating mx-2"] [
|
||||||
|
input [
|
||||||
|
_type "number"
|
||||||
|
_class "form-control"
|
||||||
|
_id "recurCount"
|
||||||
|
_name "recurCount"
|
||||||
|
_placeholder "0"
|
||||||
|
_value (string req.recurCount)
|
||||||
|
_style "width:6rem;"
|
||||||
|
_required
|
||||||
|
match req.recurType with Immediate -> _disabled | _ -> ()
|
||||||
|
]
|
||||||
|
label [ _for "recurCount" ] [ str "Count" ]
|
||||||
|
]
|
||||||
|
div [ _class "form-floating mx-2" ] [
|
||||||
|
select [
|
||||||
|
_class "form-control"
|
||||||
|
_id "recurInterval"
|
||||||
|
_name "recurInterval"
|
||||||
|
_style "width:6rem;"
|
||||||
|
_required
|
||||||
|
match req.recurType with Immediate -> _disabled | _ -> ()
|
||||||
|
] [
|
||||||
|
option [ _value "Hours"; match req.recurType with Hours -> _selected | _ -> () ] [ str "hours" ]
|
||||||
|
option [ _value "Days"; match req.recurType with Days -> _selected | _ -> () ] [ str "days" ]
|
||||||
|
option [ _value "Weeks"; match req.recurType with Weeks -> _selected | _ -> () ] [ str "weeks" ]
|
||||||
|
]
|
||||||
|
label [ _form "recurInterval" ] [ str "Interval" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
]
|
]
|
||||||
label [ _form "recurInterval" ] [ str "Interval" ]
|
]
|
||||||
|
div [ _class "text-end pt-3" ] [
|
||||||
|
button [ _class "btn btn-primary me-2"; _type "submit" ] [ icon "save"; str " Save" ]
|
||||||
|
a [ _class "btn btn-secondary ms-2"; _href cancelUrl; _hxGet cancelUrl ] [icon "arrow_back"; str " Cancel"]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
div [ _class "text-right" ] [
|
|
||||||
button [ _class "btn btn-primary"; _type "submit" ] [ icon "save"; str " Save" ]
|
|
||||||
a [ _class "btn btn-secondary"; _href "#"; _onclick "history.go(-1)" ] [icon "arrow_back"; str " Cancel"]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
script [] [
|
|
||||||
rawText """toggleRecurrence ({ target }) {
|
|
||||||
const isDisabled = target.value === "Immediate"
|
|
||||||
;["recurCount","recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled)
|
|
||||||
}"""
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
/// Layout views
|
/// Layout views
|
||||||
module Layout =
|
module Layout =
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine.Accessibility
|
||||||
|
|
||||||
|
/// The HTML `head` element
|
||||||
let htmlHead pageTitle =
|
let htmlHead pageTitle =
|
||||||
head [] [
|
head [] [
|
||||||
title [] [ str pageTitle; rawText " « myPrayerJournal" ]
|
title [] [ str pageTitle; rawText " « myPrayerJournal" ]
|
||||||
@ -531,6 +548,13 @@ module Layout =
|
|||||||
] []
|
] []
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// Element used to display toasts
|
||||||
|
let toaster =
|
||||||
|
div [ _ariaLive "polite"; _ariaAtomic "true"; _id "toastHost" ] [
|
||||||
|
div [ _class "toast-container position-absolute p-3 bottom-0 end-0"; _id "toasts" ] []
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The page's `footer` element
|
||||||
let htmlFoot =
|
let htmlFoot =
|
||||||
footer [ _class "container-fluid" ] [
|
footer [ _class "container-fluid" ] [
|
||||||
p [ _class "text-muted text-end" ] [
|
p [ _class "text-muted text-end" ] [
|
||||||
@ -564,7 +588,8 @@ module Layout =
|
|||||||
htmlHead pageTitle
|
htmlHead pageTitle
|
||||||
body [ _hxHeaders "" ] [
|
body [ _hxHeaders "" ] [
|
||||||
Navigation.navBar
|
Navigation.navBar
|
||||||
main [ _hxTrigger "setTitle from:body" ] [ content ]
|
main [] [ content ]
|
||||||
|
toaster
|
||||||
htmlFoot
|
htmlFoot
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
@ -7,7 +7,7 @@ const mpj = {
|
|||||||
/** The Auth0 client */
|
/** The Auth0 client */
|
||||||
auth0: null,
|
auth0: null,
|
||||||
/** Configure the Auth0 client */
|
/** Configure the Auth0 client */
|
||||||
configureClient: async () => {
|
async configureClient () {
|
||||||
const response = await fetch("/auth-config.json")
|
const response = await fetch("/auth-config.json")
|
||||||
const config = await response.json()
|
const config = await response.json()
|
||||||
mpj.auth.auth0 = await createAuth0Client({
|
mpj.auth.auth0 = await createAuth0Client({
|
||||||
@ -21,16 +21,72 @@ const mpj = {
|
|||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
/** Whether we should redirect to the journal the next time the menu items are refreshed */
|
/** Whether we should redirect to the journal the next time the menu items are refreshed */
|
||||||
redirToJournal: false,
|
redirToJournal: false,
|
||||||
/** Process a log on request */
|
/**
|
||||||
logOn: async (e) => {
|
* Process a log on request
|
||||||
|
* @param {Event} e The HTML event from the `onclick` event
|
||||||
|
*/
|
||||||
|
async logOn (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await mpj.auth.auth0.loginWithRedirect({ redirect_uri: `${window.location.origin}/user/log-on` })
|
await mpj.auth.auth0.loginWithRedirect({ redirect_uri: `${window.location.origin}/user/log-on` })
|
||||||
},
|
},
|
||||||
/** Log the user off */
|
/**
|
||||||
logOff: (e) => {
|
* Log the user off
|
||||||
|
* @param {Event} e The HTML event from the `onclick` event
|
||||||
|
*/
|
||||||
|
logOff (e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
mpj.auth.auth0.logout({ returnTo: window.location.origin })
|
mpj.auth.auth0.logout({ returnTo: window.location.origin })
|
||||||
}
|
},
|
||||||
|
/**
|
||||||
|
* Show a message via toast
|
||||||
|
* @param {string} message The message to show
|
||||||
|
*/
|
||||||
|
showToast (message) {
|
||||||
|
const [level, msg] = message.split("|||")
|
||||||
|
|
||||||
|
let header
|
||||||
|
if (level !== "success") {
|
||||||
|
const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
|
||||||
|
|
||||||
|
header = document.createElement("div")
|
||||||
|
header.className = "toast-header"
|
||||||
|
header.innerHTML = heading(level === "warning" ? level : "error")
|
||||||
|
|
||||||
|
const close = document.createElement("button")
|
||||||
|
close.type = "button"
|
||||||
|
close.className = "btn-close"
|
||||||
|
close.setAttribute("data-bs-dismiss", "toast")
|
||||||
|
close.setAttribute("aria-label", "Close")
|
||||||
|
header.appendChild(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = document.createElement("div")
|
||||||
|
body.className = "toast-body"
|
||||||
|
body.innerText = msg
|
||||||
|
|
||||||
|
const toastEl = document.createElement("div")
|
||||||
|
toastEl.className = `toast bg-${level} text-white`
|
||||||
|
toastEl.setAttribute("role", "alert")
|
||||||
|
toastEl.setAttribute("aria-live", "assertlive")
|
||||||
|
toastEl.setAttribute("aria-atomic", "true")
|
||||||
|
toastEl.addEventListener("hidden.bs.toast", e => e.target.remove())
|
||||||
|
if (header) toastEl.appendChild(header)
|
||||||
|
|
||||||
|
toastEl.appendChild(body)
|
||||||
|
document.getElementById("toasts").appendChild(toastEl)
|
||||||
|
new bootstrap.Toast(toastEl, { autohide: level === "success" }).show()
|
||||||
|
},
|
||||||
|
/** Script for the request edit component */
|
||||||
|
edit: {
|
||||||
|
/**
|
||||||
|
* Toggle the recurrence input fields
|
||||||
|
* @param {Event} e The click event
|
||||||
|
*/
|
||||||
|
toggleRecurrence ({ target }) {
|
||||||
|
const isDisabled = target.value === "Immediate"
|
||||||
|
;["recurCount","recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = async () => {
|
window.onload = async () => {
|
||||||
@ -65,12 +121,17 @@ window.onload = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
htmx.on("htmx:afterOnLoad", function (evt) {
|
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||||
|
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
||||||
// Set the page title if a header was in the response
|
// Set the page title if a header was in the response
|
||||||
if (evt.detail.xhr.getAllResponseHeaders().indexOf("x-page-title") >= 0) {
|
if (hdrs.indexOf("x-page-title") >= 0) {
|
||||||
const title = document.querySelector("title")
|
const title = document.querySelector("title")
|
||||||
title.innerText = evt.detail.xhr.getResponseHeader("x-page-title")
|
title.innerText = evt.detail.xhr.getResponseHeader("x-page-title")
|
||||||
title.innerHTML += " « myPrayerJournal"
|
title.innerHTML += " « myPrayerJournal"
|
||||||
}
|
}
|
||||||
|
// Show a message if there was one in the response
|
||||||
|
if (hdrs.indexOf("x-toast") >= 0) {
|
||||||
|
mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
htmx.on("htmx:afterSettle", function (evt) {
|
htmx.on("htmx:afterSettle", function (evt) {
|
||||||
// Redirect to the journal (once menu items load after log on)
|
// Redirect to the journal (once menu items load after log on)
|
||||||
|
@ -34,6 +34,13 @@ form {
|
|||||||
.action-cell .material-icons {
|
.action-cell .material-icons {
|
||||||
font-size: 1.1rem ;
|
font-size: 1.1rem ;
|
||||||
}
|
}
|
||||||
|
.material-icons {
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
#toastHost {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
border-top: solid 1px lightgray;
|
border-top: solid 1px lightgray;
|
||||||
|
Loading…
Reference in New Issue
Block a user