Version 3 #67

Merged
danieljsummers merged 53 commits from version-3 into master 2021-10-26 23:39:59 +00:00
4 changed files with 491 additions and 387 deletions
Showing only changes of commit 765636ee88 - Show all commits

View File

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

View File

@ -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 (&ldquo;sub&rdquo;) 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 &ldquo;Log Off&rdquo; 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 (&ldquo;sub&rdquo;) 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 &ldquo;Log Off&rdquo; 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 &ldquo;monetize&rdquo; 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 &ldquo;monetize&rdquo; 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 &ldquo;as is&rdquo;, 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 &ldquo;as is&rdquo;, 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 &nbsp; &nbsp; "
em [ _class "text-muted" ] [ rawText "After prayer, request reappears&hellip;" ]
]
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&hellip;" ]
]
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 &nbsp; " ]
em [ _class "text-muted" ] [ rawText "After prayer, request reappears&hellip;" ]
]
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&hellip;" ]
]
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 " &#xab; 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
]
]

View File

@ -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 += " &#xab; 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)

View File

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