Request update works; toasts work
This commit is contained in:
parent
dad273fad3
commit
765636ee88
|
@ -6,32 +6,6 @@ module MyPrayerJournal.Handlers
|
|||
|
||||
open Giraffe
|
||||
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
|
||||
|
||||
/// 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
|
||||
let notFound : HttpHandler =
|
||||
fun next ctx ->
|
||||
[ "/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
|
||||
setStatusCode 404 >=> text "Not found"
|
||||
|
||||
open Cuid
|
||||
open LiteDB
|
||||
open System.Security.Claims
|
||||
open Microsoft.Net.Http.Headers
|
||||
|
||||
/// Handler helpers
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Cuid
|
||||
open LiteDB
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.Logging
|
||||
open Microsoft.Net.Http.Headers
|
||||
open System.Security.Claims
|
||||
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
|
||||
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
|
||||
|
||||
|
@ -107,20 +81,31 @@ module private Helpers =
|
|||
let authorize : HttpHandler =
|
||||
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
|
||||
let renderComponent nodes : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
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
|
||||
module Models =
|
||||
|
@ -131,7 +116,7 @@ module Models =
|
|||
/// The status of the history update
|
||||
status : string
|
||||
/// The text of the update
|
||||
updateText : string
|
||||
updateText : string option
|
||||
}
|
||||
|
||||
/// An additional note
|
||||
|
@ -154,15 +139,15 @@ module Models =
|
|||
[<CLIMutable>]
|
||||
type Request = {
|
||||
/// The ID of the request
|
||||
id : string
|
||||
requestId : string
|
||||
/// The text of the request
|
||||
requestText : string
|
||||
requestText : string
|
||||
/// The additional status to record
|
||||
status : string option
|
||||
status : string option
|
||||
/// The recurrence type
|
||||
recurType : string
|
||||
recurType : string
|
||||
/// The recurrence count
|
||||
recurCount : int16 option
|
||||
recurCount : int16 option
|
||||
/// The recurrence interval
|
||||
recurInterval : string option
|
||||
}
|
||||
|
@ -175,6 +160,8 @@ module Models =
|
|||
}
|
||||
|
||||
|
||||
open MyPrayerJournal.Data.Extensions
|
||||
|
||||
/// Handlers for less-than-full-page HTML requests
|
||||
module Components =
|
||||
|
||||
|
@ -193,6 +180,34 @@ module Components =
|
|||
let! jrnl = Data.journalByUserId (userId ctx) (db 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
|
||||
|
@ -200,7 +215,7 @@ module Home =
|
|||
|
||||
// GET /
|
||||
let home : HttpHandler =
|
||||
withMenuRefresh >=> partialIfNotRefresh "Welcome!" Views.Home.home
|
||||
partialIfNotRefresh "Welcome!" Views.Home.home
|
||||
|
||||
// GET /user/log-on
|
||||
let logOn : HttpHandler =
|
||||
|
@ -213,7 +228,6 @@ module Journal =
|
|||
// GET /journal
|
||||
let journal : HttpHandler =
|
||||
authorize
|
||||
>=> withMenuRefresh
|
||||
>=> fun next ctx -> task {
|
||||
let usr = ctx.Request.Headers.["X-Given-Name"].[0]
|
||||
return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx
|
||||
|
@ -225,11 +239,11 @@ module Legal =
|
|||
|
||||
// GET /legal/privacy-policy
|
||||
let privacyPolicy : HttpHandler =
|
||||
withMenuRefresh >=> partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy
|
||||
partialIfNotRefresh "Privacy Policy" Views.Legal.privacyPolicy
|
||||
|
||||
// GET /legal/terms-of-service
|
||||
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)
|
||||
|
@ -239,72 +253,6 @@ module Ply = FSharp.Control.Tasks.Affine
|
|||
/// /api/request and /request(s) URLs
|
||||
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
|
||||
let addHistory requestId : HttpHandler =
|
||||
authorize
|
||||
|
@ -320,7 +268,7 @@ module Request =
|
|||
do! Data.addHistory reqId usrId
|
||||
{ asOf = now
|
||||
status = act
|
||||
text = match hist.updateText with null | "" -> None | x -> Some x
|
||||
text = match hist.updateText with None | Some "" -> None | x -> x
|
||||
} db
|
||||
match act with
|
||||
| Prayed ->
|
||||
|
@ -338,7 +286,6 @@ module Request =
|
|||
/// POST /api/request/[req-id]/note
|
||||
let addNote requestId : HttpHandler =
|
||||
authorize
|
||||
// >=> allowSyncIO
|
||||
>=> fun next ctx -> task {
|
||||
let db = db ctx
|
||||
let usrId = userId ctx
|
||||
|
@ -355,7 +302,6 @@ module Request =
|
|||
/// GET /requests/active
|
||||
let active : HttpHandler =
|
||||
authorize
|
||||
>=> withMenuRefresh
|
||||
>=> fun next ctx -> task {
|
||||
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
||||
return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx
|
||||
|
@ -364,7 +310,6 @@ module Request =
|
|||
/// GET /requests/answered
|
||||
let answered : HttpHandler =
|
||||
authorize
|
||||
>=> withMenuRefresh
|
||||
>=> fun next ctx -> task {
|
||||
let! reqs = Data.answeredRequests (userId ctx) (db ctx)
|
||||
return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx
|
||||
|
@ -382,7 +327,6 @@ module Request =
|
|||
/// GET /request/[req-id]/full
|
||||
let getFull requestId : HttpHandler =
|
||||
authorize
|
||||
>=> withMenuRefresh
|
||||
>=> fun next ctx -> task {
|
||||
match! Data.tryFullRequestById (toReqId requestId) (userId ctx) (db ctx) with
|
||||
| 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
|
@ -456,8 +463,10 @@ let routes =
|
|||
[ GET_HEAD [ route "/" Home.home ]
|
||||
subRoute "/components/" [
|
||||
GET_HEAD [
|
||||
route "journal-items" Components.journalItems
|
||||
route "nav-items" Components.navItems
|
||||
route "journal-items" Components.journalItems
|
||||
route "nav-items" Components.navItems
|
||||
routef "request/%s/edit" Components.requestEdit
|
||||
routef "request/%s/item" Components.requestItem
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/journal" Journal.journal ]
|
||||
|
@ -472,10 +481,12 @@ let routes =
|
|||
route "s/active" Request.active
|
||||
route "s/answered" Request.answered
|
||||
routef "/%s/full" Request.getFull
|
||||
routef "/%s/edit" Request.edit
|
||||
]
|
||||
PATCH [
|
||||
route "" Request.update
|
||||
]
|
||||
POST [
|
||||
route "/request" Request.save
|
||||
route "" Request.add
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/user/log-on" Home.logOn ]
|
||||
|
|
|
@ -63,81 +63,85 @@ module Legal =
|
|||
|
||||
/// View for the "Privacy Policy" page
|
||||
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" ] [
|
||||
h5 [ _class "card-header" ] [ str "Privacy Policy" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
|
||||
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 "the data we collect, access, and store on your behalf as you use this service."
|
||||
]
|
||||
hr []
|
||||
h3 [] [ str "Third Party Services" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
|
||||
str "yourself with the privacy policy for "
|
||||
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."
|
||||
div [ _class "list-group list-group-flush" ] [
|
||||
div [ _class "list-group-item"] [
|
||||
h3 [] [ str "Third Party Services" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
|
||||
str "yourself with the privacy policy for "
|
||||
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 ")."
|
||||
]
|
||||
]
|
||||
h4 [] [ str "User Provided Data" ]
|
||||
ul [] [
|
||||
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."
|
||||
div [ _class "list-group-item" ] [
|
||||
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" ]
|
||||
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 []
|
||||
h3 [] [ str "How Your Data Is Accessed / Secured" ]
|
||||
ul [] [
|
||||
li [] [
|
||||
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."
|
||||
]
|
||||
li [] [
|
||||
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"
|
||||
sup [] [ str "st" ]
|
||||
str " and 15"
|
||||
sup [] [ str "th" ]
|
||||
str " are preserved for 3 months. These backups are stored in a private cloud data repository."
|
||||
]
|
||||
li [] [
|
||||
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 "
|
||||
str "amount of information means that the data we have is not interesting to purchasers (or those who "
|
||||
str "may have more nefarious purposes)."
|
||||
]
|
||||
li [] [
|
||||
str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "How Your Data Is Accessed / Secured" ]
|
||||
ul [ _class "mb-0" ] [
|
||||
li [] [
|
||||
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."
|
||||
]
|
||||
li [] [
|
||||
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"
|
||||
sup [] [ str "st" ]
|
||||
str " and 15"
|
||||
sup [] [ str "th" ]
|
||||
str " are preserved for 3 months. These backups are stored in a private cloud data repository."
|
||||
]
|
||||
li [] [
|
||||
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 "
|
||||
str "amount of information means that the data we have is not interesting to purchasers (or those who "
|
||||
str "may have more nefarious purposes)."
|
||||
]
|
||||
li [] [
|
||||
str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
|
||||
]
|
||||
]
|
||||
]
|
||||
hr []
|
||||
h3 [] [ str "Removing Your Data" ]
|
||||
p [ _class "card-text" ] [
|
||||
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 "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."
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "Removing Your Data" ]
|
||||
p [ _class "card-text" ] [
|
||||
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 "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."
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
@ -145,55 +149,64 @@ module Legal =
|
|||
|
||||
/// View for the "Terms of Service" page
|
||||
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" ] [
|
||||
h5 [ _class "card-header" ] [ str "Terms of Service" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
|
||||
h3 [] [ str "1. Acceptance of Terms" ]
|
||||
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 "are responsible to ensure that your use of this site complies with all applicable laws. Your continued "
|
||||
str "use of this site implies your acceptance of these terms."
|
||||
div [ _class "list-group list-group-flush" ] [
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "1. Acceptance of Terms" ]
|
||||
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 "are responsible to ensure that your use of this site complies with all applicable laws. Your "
|
||||
str "continued use of this site implies your acceptance of these terms."
|
||||
]
|
||||
]
|
||||
h3 [] [ str "2. Description of Service and Registration" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It "
|
||||
str "requires no registration by itself, but access is granted based on a successful login with an external "
|
||||
str "identity provider. See "
|
||||
pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ]
|
||||
str " for details on how that information is accessed and stored."
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "2. Description of Service and Registration" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It "
|
||||
str "requires no registration by itself, but access is granted based on a successful login with an "
|
||||
str "external identity provider. See "
|
||||
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" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "This service utilizes a third-party service provider for identity management. Review the terms of "
|
||||
str "service for"
|
||||
a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
|
||||
str ", as well as those for the selected authorization provider ("
|
||||
a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
|
||||
str " or "
|
||||
a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
|
||||
str ")."
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "3. Third Party Services" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "This service utilizes a third-party service provider for identity management. Review the terms of "
|
||||
str "service for "
|
||||
a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
|
||||
str ", as well as those for the selected authorization provider ("
|
||||
a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
|
||||
str " or "
|
||||
a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
|
||||
str ")."
|
||||
]
|
||||
]
|
||||
h3 [] [ str "4. Liability" ]
|
||||
p [ _class "card-text" ] [
|
||||
rawText "This service is provided “as is”, and no warranty (express or implied) exists. The "
|
||||
str "service and its developers may not be held liable for any damages that may arise through the use of "
|
||||
str "this service."
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "4. Liability" ]
|
||||
p [ _class "card-text" ] [
|
||||
rawText "This service is provided “as is”, and no warranty (express or implied) exists. The "
|
||||
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" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "These terms and conditions may be updated at any time, and this service does not have the capability to "
|
||||
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 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."
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "5. Updates to Terms" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "These terms and conditions may be updated at any time, and this service does not have the capability "
|
||||
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."
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
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 isSnoozed = Ticks.toLong req.snoozedUntil > jsNow
|
||||
let isPending = (not isSnoozed) && Ticks.toLong req.showAfter > jsNow
|
||||
let btnClass = _class "btn btn-light"
|
||||
tr [] [
|
||||
td [ _class "action-cell" ] [
|
||||
div [ _class "btn-group btn-group-sm"; Accessibility._roleGroup ] [
|
||||
pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
|
||||
if not isAnswered then
|
||||
pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ]
|
||||
// TODO: these next two should use hx-patch, targeting replacement of this tr when complete
|
||||
if isSnoozed then
|
||||
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" ] [
|
||||
str req.text
|
||||
if isSnoozed || isPending || isAnswered then
|
||||
br []
|
||||
small [ _class "text-muted" ] [
|
||||
em [] [
|
||||
if isSnoozed then
|
||||
str "Snooze expires "
|
||||
relativeDate req.snoozedUntil
|
||||
if isPending then
|
||||
str "Request appears next "
|
||||
relativeDate req.showAfter
|
||||
if isAnswered then
|
||||
str "Answered "
|
||||
relativeDate req.asOf
|
||||
]
|
||||
let btnClass = _class "btn btn-light mx-2"
|
||||
div [
|
||||
_class "list-group-item px-0 d-flex flex-row align-items-start"
|
||||
_hxTarget "this"
|
||||
_hxSwap HxSwap.OuterHtml
|
||||
] [
|
||||
pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
|
||||
if not isAnswered then
|
||||
button [ btnClass; _hxGet $"/components/request/{reqId}/edit"; _title "Edit Request" ] [ icon "edit" ]
|
||||
// TODO: these next two should use hx-patch, targeting replacement of this tr when complete
|
||||
if isSnoozed then
|
||||
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" ]
|
||||
p [ _class "mpj-request-text mb-0" ] [
|
||||
str req.text
|
||||
if isSnoozed || isPending || isAnswered then
|
||||
br []
|
||||
small [ _class "text-muted" ] [
|
||||
em [] [
|
||||
if isSnoozed then
|
||||
str "Snooze expires "
|
||||
relativeDate req.snoozedUntil
|
||||
if isPending then
|
||||
str "Request appears next "
|
||||
relativeDate req.showAfter
|
||||
if isAnswered then
|
||||
str "Answered "
|
||||
relativeDate req.asOf
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Create a list of requests
|
||||
let reqList reqs =
|
||||
table [ _class "table table-hover table-sm align-top" ] [
|
||||
thead [] [
|
||||
tr [] [
|
||||
th [ _scope "col" ] [ str "Actions" ]
|
||||
th [ _scope "col" ] [ str "Request" ]
|
||||
]
|
||||
]
|
||||
reqs
|
||||
|> List.map reqListItem
|
||||
|> tbody []
|
||||
]
|
||||
reqs
|
||||
|> List.map reqListItem
|
||||
|> div [ _class "list-group" ]
|
||||
|
||||
/// View for Active Requests page
|
||||
let active reqs = article [ _class "container mt-3" ] [
|
||||
h2 [] [ str "Active Requests" ]
|
||||
h2 [ _class "pb-3" ] [ str "Active Requests" ]
|
||||
match reqs |> List.isEmpty with
|
||||
| true ->
|
||||
noResults "No Active Requests" "/journal" "Return to your journal"
|
||||
|
@ -334,7 +337,7 @@ module Request =
|
|||
|
||||
/// View for Answered Requests page
|
||||
let answered reqs = article [ _class "container mt-3" ] [
|
||||
h2 [] [ str "Answered Requests" ]
|
||||
h2 [ _class "pb-3" ] [ str "Answered Requests" ]
|
||||
match reqs |> List.isEmpty with
|
||||
| true ->
|
||||
noResults "No Active Requests" "/journal" "Return to your journal" [
|
||||
|
@ -346,7 +349,7 @@ module Request =
|
|||
|
||||
/// View for Snoozed Requests page
|
||||
let snoozed reqs = article [ _class "container mt-3" ] [
|
||||
h2 [] [ str "Snoozed Requests" ]
|
||||
h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
|
||||
reqList reqs
|
||||
]
|
||||
|
||||
|
@ -409,110 +412,124 @@ module Request =
|
|||
]
|
||||
]
|
||||
|
||||
let edit (req : JournalRequest) isNew = article [ _class "container mt-3" ] [
|
||||
form [ _hxPost "/request" ] [
|
||||
input [
|
||||
_type "hidden"
|
||||
_name "requestId"
|
||||
_value (match isNew with true -> "new" | false -> RequestId.toString req.requestId)
|
||||
]
|
||||
div [ _class "form-floating mb-3" ] [
|
||||
textarea [
|
||||
_id "requestText"
|
||||
_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" ]
|
||||
/// View for the edit request component
|
||||
let edit (req : JournalRequest) isNew =
|
||||
let cancelUrl = req.requestId |> (RequestId.toString >> sprintf "/components/request/%s/item")
|
||||
section [ _class "container list-group-item"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
|
||||
h5 [ _class "pb-3" ] [ (match isNew with true -> "Add" | false -> "Edit") |> strf "%s Prayer Request" ]
|
||||
form [ "/request" |> match isNew with true -> _hxPost | false -> _hxPatch ] [
|
||||
input [
|
||||
_type "hidden"
|
||||
_name "requestId"
|
||||
_value (match isNew with true -> "new" | false -> RequestId.toString req.requestId)
|
||||
]
|
||||
div [ _class "col-3 form-check"] [
|
||||
input [
|
||||
_type "radio"
|
||||
_class "form-check-input"
|
||||
_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"
|
||||
div [ _class "form-floating pb-3" ] [
|
||||
textarea [
|
||||
_id "requestText"
|
||||
_name "requestText"
|
||||
_class "form-control"
|
||||
_id "recurCount"
|
||||
_name "recurCount"
|
||||
_placeholder "0"
|
||||
_value (string req.recurCount)
|
||||
_required
|
||||
]
|
||||
label [ _for "recurCount" ] [ str "Count" ]
|
||||
_style "min-height: 8rem;"
|
||||
_placeholder "Enter the text of the request"
|
||||
_autofocus; _required
|
||||
] [ str req.text ]
|
||||
label [ _for "requestText" ] [ str "Prayer Request" ]
|
||||
]
|
||||
div [ _class "col-3 form-floating" ] [
|
||||
select [ _class "form-control"; _id "recurInterval"; _name "recurInterval"; _required ] [
|
||||
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" ]
|
||||
br []
|
||||
match isNew with
|
||||
| true -> ()
|
||||
| false ->
|
||||
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
|
||||
module Layout =
|
||||
|
||||
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
|
||||
/// The HTML `head` element
|
||||
let htmlHead pageTitle =
|
||||
head [] [
|
||||
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 =
|
||||
footer [ _class "container-fluid" ] [
|
||||
p [ _class "text-muted text-end" ] [
|
||||
|
@ -564,7 +588,8 @@ module Layout =
|
|||
htmlHead pageTitle
|
||||
body [ _hxHeaders "" ] [
|
||||
Navigation.navBar
|
||||
main [ _hxTrigger "setTitle from:body" ] [ content ]
|
||||
main [] [ content ]
|
||||
toaster
|
||||
htmlFoot
|
||||
]
|
||||
]
|
||||
|
|
|
@ -7,7 +7,7 @@ const mpj = {
|
|||
/** The Auth0 client */
|
||||
auth0: null,
|
||||
/** Configure the Auth0 client */
|
||||
configureClient: async () => {
|
||||
async configureClient () {
|
||||
const response = await fetch("/auth-config.json")
|
||||
const config = await response.json()
|
||||
mpj.auth.auth0 = await createAuth0Client({
|
||||
|
@ -21,16 +21,72 @@ const mpj = {
|
|||
isAuthenticated: false,
|
||||
/** Whether we should redirect to the journal the next time the menu items are refreshed */
|
||||
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()
|
||||
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()
|
||||
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 () => {
|
||||
|
@ -65,12 +121,17 @@ window.onload = async () => {
|
|||
}
|
||||
|
||||
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
||||
// 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")
|
||||
title.innerText = evt.detail.xhr.getResponseHeader("x-page-title")
|
||||
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) {
|
||||
// Redirect to the journal (once menu items load after log on)
|
||||
|
|
|
@ -34,6 +34,13 @@ form {
|
|||
.action-cell .material-icons {
|
||||
font-size: 1.1rem ;
|
||||
}
|
||||
.material-icons {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
#toastHost {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: solid 1px lightgray;
|
||||
|
|
Loading…
Reference in New Issue
Block a user