Remove F# app / GitHub pages docs
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.30114.105
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal", "MyPrayerJournal\MyPrayerJournal.fsproj", "{6BD5A3C8-F859-42A0-ACD7-A5819385E828}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{6BD5A3C8-F859-42A0-ACD7-A5819385E828}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6BD5A3C8-F859-42A0-ACD7-A5819385E828}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6BD5A3C8-F859-42A0-ACD7-A5819385E828}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6BD5A3C8-F859-42A0-ACD7-A5819385E828}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{72B57736-8721-4636-A309-49FA4222416E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{72B57736-8721-4636-A309-49FA4222416E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{72B57736-8721-4636-A309-49FA4222416E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{72B57736-8721-4636-A309-49FA4222416E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
2
src/MyPrayerJournal/.gitignore
vendored
2
src/MyPrayerJournal/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
## Development settings
|
||||
appsettings.Development.json
|
||||
@@ -1,202 +0,0 @@
|
||||
module MyPrayerJournal.Data
|
||||
|
||||
/// Table(!) used by myPrayerJournal
|
||||
module Table =
|
||||
|
||||
/// Requests
|
||||
[<Literal>]
|
||||
let Request = "mpj.request"
|
||||
|
||||
|
||||
/// JSON serialization customizations
|
||||
[<RequireQualifiedAccess>]
|
||||
module Json =
|
||||
|
||||
open System.Text.Json.Serialization
|
||||
|
||||
/// Convert a wrapped DU to/from its string representation
|
||||
type WrappedJsonConverter<'T>(wrap : string -> 'T, unwrap : 'T -> string) =
|
||||
inherit JsonConverter<'T>()
|
||||
override _.Read(reader, _, _) =
|
||||
wrap (reader.GetString())
|
||||
override _.Write(writer, value, _) =
|
||||
writer.WriteStringValue(unwrap value)
|
||||
|
||||
open System.Text.Json
|
||||
open NodaTime.Serialization.SystemTextJson
|
||||
|
||||
/// JSON serializer options to support the target domain
|
||||
let options =
|
||||
let opts = JsonSerializerOptions()
|
||||
[ WrappedJsonConverter(Recurrence.ofString, Recurrence.toString) :> JsonConverter
|
||||
WrappedJsonConverter(RequestAction.ofString, RequestAction.toString)
|
||||
WrappedJsonConverter(RequestId.ofString, RequestId.toString)
|
||||
WrappedJsonConverter(UserId, UserId.toString)
|
||||
JsonFSharpConverter() ]
|
||||
|> List.iter opts.Converters.Add
|
||||
let _ = opts.ConfigureForNodaTime NodaTime.DateTimeZoneProviders.Tzdb
|
||||
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
|
||||
opts.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull
|
||||
opts
|
||||
|
||||
|
||||
open BitBadger.Documents.Postgres
|
||||
|
||||
/// Connection
|
||||
[<RequireQualifiedAccess>]
|
||||
module Connection =
|
||||
|
||||
open BitBadger.Documents
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Npgsql
|
||||
open System.Text.Json
|
||||
|
||||
/// Ensure the database is ready to use
|
||||
let private ensureDb () = backgroundTask {
|
||||
do! Custom.nonQuery "CREATE SCHEMA IF NOT EXISTS mpj" []
|
||||
do! Definition.ensureTable Table.Request
|
||||
do! Definition.ensureDocumentIndex Table.Request Optimized
|
||||
}
|
||||
|
||||
/// Set up the data environment
|
||||
let setUp (cfg : IConfiguration) = backgroundTask {
|
||||
let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "mpj")
|
||||
let _ = builder.UseNodaTime()
|
||||
Configuration.useDataSource (builder.Build())
|
||||
Configuration.useIdField "id"
|
||||
Configuration.useSerializer
|
||||
{ new IDocumentSerializer with
|
||||
member _.Serialize<'T>(it : 'T) = JsonSerializer.Serialize(it, Json.options)
|
||||
member _.Deserialize<'T>(it : string) = JsonSerializer.Deserialize<'T>(it, Json.options)
|
||||
}
|
||||
do! ensureDb ()
|
||||
}
|
||||
|
||||
|
||||
/// Data access functions for requests
|
||||
[<RequireQualifiedAccess>]
|
||||
module Request =
|
||||
|
||||
open NodaTime
|
||||
|
||||
/// Add a request
|
||||
let add req =
|
||||
insert<Request> Table.Request req
|
||||
|
||||
/// Does a request exist for the given request ID and user ID?
|
||||
let existsById (reqId : RequestId) (userId : UserId) =
|
||||
Exists.byContains Table.Request {| Id = reqId; UserId = userId |}
|
||||
|
||||
/// Retrieve a request by its ID and user ID
|
||||
let tryById reqId userId = backgroundTask {
|
||||
match! Find.byId<string, Request> Table.Request (RequestId.toString reqId) with
|
||||
| Some req when req.UserId = userId -> return Some req
|
||||
| _ -> return None
|
||||
}
|
||||
|
||||
/// Update recurrence for a request
|
||||
let updateRecurrence reqId userId (recurType : Recurrence) = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! existsById reqId userId with
|
||||
| true -> do! Patch.byId Table.Request dbId {| Recurrence = recurType |}
|
||||
| false -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
/// Update the show-after time for a request
|
||||
let updateShowAfter reqId userId (showAfter : Instant option) = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! existsById reqId userId with
|
||||
| true -> do! Patch.byId Table.Request dbId {| ShowAfter = showAfter |}
|
||||
| false -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
/// Update the snoozed and show-after values for a request
|
||||
let updateSnoozed reqId userId (until : Instant option) = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! existsById reqId userId with
|
||||
| true -> do! Patch.byId Table.Request dbId {| SnoozedUntil = until; ShowAfter = until |}
|
||||
| false -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
|
||||
/// Specific manipulation of history entries
|
||||
[<RequireQualifiedAccess>]
|
||||
module History =
|
||||
|
||||
/// Add a history entry
|
||||
let add reqId userId hist = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! Request.tryById reqId userId with
|
||||
| Some req ->
|
||||
do! Patch.byId Table.Request dbId {| History = (hist :: req.History) |> List.sortByDescending (_.AsOf) |}
|
||||
| None -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
|
||||
/// Data access functions for journal-style requests
|
||||
[<RequireQualifiedAccess>]
|
||||
module Journal =
|
||||
|
||||
/// Retrieve a user's answered requests
|
||||
let answered (userId : UserId) = backgroundTask {
|
||||
let! reqs =
|
||||
Custom.list
|
||||
$"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}"""
|
||||
[ jsonParam "@criteria" {| UserId = userId |}
|
||||
"@stat", Sql.string """$.history[0].status ? (@ == "Answered")""" ]
|
||||
fromData<Request>
|
||||
return
|
||||
reqs
|
||||
|> Seq.ofList
|
||||
|> Seq.map JournalRequest.ofRequestLite
|
||||
|> Seq.filter (fun it -> it.LastStatus = Answered)
|
||||
|> Seq.sortByDescending (_.AsOf)
|
||||
|> List.ofSeq
|
||||
}
|
||||
|
||||
/// Retrieve a user's current prayer journal (includes snoozed and non-immediate recurrence)
|
||||
let forUser (userId : UserId) = backgroundTask {
|
||||
let! reqs =
|
||||
Custom.list
|
||||
$"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}"""
|
||||
[ jsonParam "@criteria" {| UserId = userId |}
|
||||
"@stat", Sql.string """$.history[0].status ? (@ <> "Answered")""" ]
|
||||
fromData<Request>
|
||||
return
|
||||
reqs
|
||||
|> Seq.ofList
|
||||
|> Seq.map JournalRequest.ofRequestLite
|
||||
|> Seq.filter (fun it -> it.LastStatus <> Answered)
|
||||
|> Seq.sortBy (_.AsOf)
|
||||
|> List.ofSeq
|
||||
}
|
||||
|
||||
/// Does the user's journal have any snoozed requests?
|
||||
let hasSnoozed userId now = backgroundTask {
|
||||
let! jrnl = forUser userId
|
||||
return jrnl |> List.exists (fun r -> defaultArg (r.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
||||
}
|
||||
|
||||
let tryById reqId userId = backgroundTask {
|
||||
let! req = Request.tryById reqId userId
|
||||
return req |> Option.map JournalRequest.ofRequestLite
|
||||
}
|
||||
|
||||
|
||||
/// Specific manipulation of note entries
|
||||
[<RequireQualifiedAccess>]
|
||||
module Note =
|
||||
|
||||
/// Add a note
|
||||
let add reqId userId note = backgroundTask {
|
||||
let dbId = RequestId.toString reqId
|
||||
match! Request.tryById reqId userId with
|
||||
| Some req ->
|
||||
do! Patch.byId Table.Request dbId {| Notes = (note :: req.Notes) |> List.sortByDescending (_.AsOf) |}
|
||||
| None -> invalidOp $"Request ID {dbId} not found"
|
||||
}
|
||||
|
||||
/// Retrieve notes for a request by the request ID
|
||||
let byRequestId reqId userId = backgroundTask {
|
||||
match! Request.tryById reqId userId with Some req -> return req.Notes | None -> return []
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/// Date formatting helpers
|
||||
// Many thanks to date-fns (https://date-fns.org) for this logic
|
||||
module MyPrayerJournal.Dates
|
||||
|
||||
open NodaTime
|
||||
|
||||
type internal FormatDistanceToken =
|
||||
| LessThanXMinutes
|
||||
| XMinutes
|
||||
| AboutXHours
|
||||
| XHours
|
||||
| XDays
|
||||
| AboutXWeeks
|
||||
| XWeeks
|
||||
| AboutXMonths
|
||||
| XMonths
|
||||
| AboutXYears
|
||||
| XYears
|
||||
| OverXYears
|
||||
| AlmostXYears
|
||||
|
||||
let internal locales =
|
||||
let format = PrintfFormat<int -> string, unit, string, string>
|
||||
Map.ofList [
|
||||
"en-US", Map.ofList [
|
||||
LessThanXMinutes, ("less than a minute", format "less than %i minutes")
|
||||
XMinutes, ("a minute", format "%i minutes")
|
||||
AboutXHours, ("about an hour", format "about %i hours")
|
||||
XHours, ("an hour", format "%i hours")
|
||||
XDays, ("a day", format "%i days")
|
||||
AboutXWeeks, ("about a week", format "about %i weeks")
|
||||
XWeeks, ("a week", format "%i weeks")
|
||||
AboutXMonths, ("about a month", format "about %i months")
|
||||
XMonths, ("a month", format "%i months")
|
||||
AboutXYears, ("about a year", format "about %i years")
|
||||
XYears, ("a year", format "%i years")
|
||||
OverXYears, ("over a year", format "over %i years")
|
||||
AlmostXYears, ("almost a year", format "almost %i years")
|
||||
]
|
||||
]
|
||||
|
||||
let aDay = 1_440.
|
||||
let almost2Days = 2_520.
|
||||
let aMonth = 43_200.
|
||||
let twoMonths = 86_400.
|
||||
|
||||
open System
|
||||
|
||||
/// Format the distance between two instants in approximate English terms
|
||||
let formatDistance (startOn : Instant) (endOn : Instant) =
|
||||
let format (token, number) locale =
|
||||
let labels = locales |> Map.find locale
|
||||
match number with 1 -> fst labels[token] | _ -> sprintf (snd labels[token]) number
|
||||
let round (it : float) = Math.Round it |> int
|
||||
|
||||
let diff = startOn - endOn
|
||||
let minutes = Math.Abs diff.TotalMinutes
|
||||
let formatToken =
|
||||
let months = minutes / aMonth |> round
|
||||
let years = months / 12
|
||||
match true with
|
||||
| _ when minutes < 1. -> LessThanXMinutes, 1
|
||||
| _ when minutes < 45. -> XMinutes, round minutes
|
||||
| _ when minutes < 90. -> AboutXHours, 1
|
||||
| _ when minutes < aDay -> AboutXHours, round (minutes / 60.)
|
||||
| _ when minutes < almost2Days -> XDays, 1
|
||||
| _ when minutes < aMonth -> XDays, round (minutes / aDay)
|
||||
| _ when minutes < twoMonths -> AboutXMonths, round (minutes / aMonth)
|
||||
| _ when months < 12 -> XMonths, round (minutes / aMonth)
|
||||
| _ when months % 12 < 3 -> AboutXYears, years
|
||||
| _ when months % 12 < 9 -> OverXYears, years
|
||||
| _ -> AlmostXYears, years + 1
|
||||
|
||||
format formatToken "en-US"
|
||||
|> match startOn > endOn with true -> sprintf "%s ago" | false -> sprintf "in %s"
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
/// The data model for myPrayerJournal
|
||||
[<AutoOpen>]
|
||||
module MyPrayerJournal.Domain
|
||||
|
||||
open System
|
||||
open Cuid
|
||||
open NodaTime
|
||||
|
||||
/// An identifier for a request
|
||||
type RequestId = RequestId of Cuid
|
||||
|
||||
/// Functions to manipulate request IDs
|
||||
module RequestId =
|
||||
|
||||
/// The string representation of the request ID
|
||||
let toString = function RequestId x -> Cuid.toString x
|
||||
|
||||
/// Create a request ID from a string representation
|
||||
let ofString = Cuid >> RequestId
|
||||
|
||||
|
||||
/// The identifier of a user (the "sub" part of the JWT)
|
||||
type UserId = UserId of string
|
||||
|
||||
/// Functions to manipulate user IDs
|
||||
module UserId =
|
||||
|
||||
/// The string representation of the user ID
|
||||
let toString = function UserId x -> x
|
||||
|
||||
|
||||
/// How frequently a request should reappear after it is marked "Prayed"
|
||||
type Recurrence =
|
||||
|
||||
/// A request should reappear immediately at the bottom of the list
|
||||
| Immediate
|
||||
|
||||
/// A request should reappear in the given number of hours
|
||||
| Hours of int16
|
||||
|
||||
/// A request should reappear in the given number of days
|
||||
| Days of int16
|
||||
|
||||
/// A request should reappear in the given number of weeks (7-day increments)
|
||||
| Weeks of int16
|
||||
|
||||
/// Functions to manipulate recurrences
|
||||
module Recurrence =
|
||||
|
||||
/// Create a string representation of a recurrence
|
||||
let toString =
|
||||
function
|
||||
| Immediate -> "Immediate"
|
||||
| Hours h -> $"{h} Hours"
|
||||
| Days d -> $"{d} Days"
|
||||
| Weeks w -> $"{w} Weeks"
|
||||
|
||||
/// Create a recurrence value from a string
|
||||
let ofString =
|
||||
function
|
||||
| "Immediate" -> Immediate
|
||||
| it when it.Contains " " ->
|
||||
let parts = it.Split " "
|
||||
let length = Convert.ToInt16 parts[0]
|
||||
match parts[1] with
|
||||
| "Hours" -> Hours length
|
||||
| "Days" -> Days length
|
||||
| "Weeks" -> Weeks length
|
||||
| _ -> invalidOp $"{parts[1]} is not a valid recurrence"
|
||||
| it -> invalidOp $"{it} is not a valid recurrence"
|
||||
|
||||
/// An hour's worth of seconds
|
||||
let private oneHour = 3_600L
|
||||
|
||||
/// The duration of the recurrence (in milliseconds)
|
||||
let duration =
|
||||
function
|
||||
| Immediate -> 0L
|
||||
| Hours h -> int64 h * oneHour
|
||||
| Days d -> int64 d * oneHour * 24L
|
||||
| Weeks w -> int64 w * oneHour * 24L * 7L
|
||||
|
||||
|
||||
/// The action taken on a request as part of a history entry
|
||||
type RequestAction =
|
||||
| Created
|
||||
| Prayed
|
||||
| Updated
|
||||
| Answered
|
||||
|
||||
/// Functions to manipulate request actions
|
||||
module RequestAction =
|
||||
|
||||
/// Create a string representation of an action
|
||||
let toString =
|
||||
function
|
||||
| Created -> "Created"
|
||||
| Prayed -> "Prayed"
|
||||
| Updated -> "Updated"
|
||||
| Answered -> "Answered"
|
||||
|
||||
/// Create a RequestAction from a string
|
||||
let ofString =
|
||||
function
|
||||
| "Created" -> Created
|
||||
| "Prayed" -> Prayed
|
||||
| "Updated" -> Updated
|
||||
| "Answered" -> Answered
|
||||
| it -> invalidOp $"Bad request action {it}"
|
||||
|
||||
|
||||
|
||||
/// History is a record of action taken on a prayer request, including updates to its text
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type History =
|
||||
{ /// The time when this history entry was made
|
||||
AsOf : Instant
|
||||
|
||||
/// The status for this history entry
|
||||
Status : RequestAction
|
||||
|
||||
/// The text of the update, if applicable
|
||||
Text : string option
|
||||
}
|
||||
|
||||
/// Functions to manipulate history entries
|
||||
module History =
|
||||
|
||||
/// Determine if a history's status is `Created`
|
||||
let isCreated hist = hist.Status = Created
|
||||
|
||||
/// Determine if a history's status is `Prayed`
|
||||
let isPrayed hist = hist.Status = Prayed
|
||||
|
||||
/// Determine if a history's status is `Answered`
|
||||
let isAnswered hist = hist.Status = Answered
|
||||
|
||||
|
||||
/// Note is a note regarding a prayer request that does not result in an update to its text
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Note =
|
||||
{ /// The time when this note was made
|
||||
AsOf : Instant
|
||||
|
||||
/// The text of the notes
|
||||
Notes : string
|
||||
}
|
||||
|
||||
|
||||
/// Request is the identifying record for a prayer request
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Request =
|
||||
{ /// The ID of the request
|
||||
Id : RequestId
|
||||
|
||||
/// The time this request was initially entered
|
||||
EnteredOn : Instant
|
||||
|
||||
/// The ID of the user to whom this request belongs ("sub" from the JWT)
|
||||
UserId : UserId
|
||||
|
||||
/// The time at which this request should reappear in the user's journal by manual user choice
|
||||
SnoozedUntil : Instant option
|
||||
|
||||
/// The time at which this request should reappear in the user's journal by recurrence
|
||||
ShowAfter : Instant option
|
||||
|
||||
/// The recurrence for this request
|
||||
Recurrence : Recurrence
|
||||
|
||||
/// The history entries for this request
|
||||
History : History list
|
||||
|
||||
/// The notes for this request
|
||||
Notes : Note list
|
||||
}
|
||||
|
||||
/// Functions to support requests
|
||||
module Request =
|
||||
|
||||
/// An empty request
|
||||
let empty =
|
||||
{ Id = Cuid.generate () |> RequestId
|
||||
EnteredOn = Instant.MinValue
|
||||
UserId = UserId ""
|
||||
SnoozedUntil = None
|
||||
ShowAfter = None
|
||||
Recurrence = Immediate
|
||||
History = []
|
||||
Notes = []
|
||||
}
|
||||
|
||||
|
||||
/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
|
||||
/// properties that may be filled for history and notes.
|
||||
[<NoComparison; NoEquality>]
|
||||
type JournalRequest =
|
||||
{ /// The ID of the request (just the CUID part)
|
||||
RequestId : RequestId
|
||||
|
||||
/// The ID of the user to whom the request belongs
|
||||
UserId : UserId
|
||||
|
||||
/// The current text of the request
|
||||
Text : string
|
||||
|
||||
/// The last time action was taken on the request
|
||||
AsOf : Instant
|
||||
|
||||
/// The last time a request was marked as prayed
|
||||
LastPrayed : Instant option
|
||||
|
||||
/// The last status for the request
|
||||
LastStatus : RequestAction
|
||||
|
||||
/// The time that this request should reappear in the user's journal
|
||||
SnoozedUntil : Instant option
|
||||
|
||||
/// The time after which this request should reappear in the user's journal by configured recurrence
|
||||
ShowAfter : Instant option
|
||||
|
||||
/// The recurrence for this request
|
||||
Recurrence : Recurrence
|
||||
|
||||
/// History entries for the request
|
||||
History : History list
|
||||
|
||||
/// Note entries for the request
|
||||
Notes : Note list
|
||||
}
|
||||
|
||||
/// Functions to manipulate journal requests
|
||||
module JournalRequest =
|
||||
|
||||
/// Convert a request to the form used for the journal (precomputed values, no notes or history)
|
||||
let ofRequestLite (req : Request) =
|
||||
let history = Seq.ofList req.History
|
||||
let lastHistory = Seq.tryHead history
|
||||
// Requests are sorted by the "as of" field in this record; for sorting to work properly, we will put the
|
||||
// largest of the last prayed date, the "snoozed until". or the "show after" date; if none of those are filled,
|
||||
// we will use the last activity date. This will mean that:
|
||||
// - Immediately shown requests will be at the top of the list, in order from least recently prayed to most.
|
||||
// - Non-immediate requests will enter the list as if they were marked as prayed at that time; this will put
|
||||
// them at the bottom of the list.
|
||||
// - Snoozed requests will reappear at the bottom of the list when they return.
|
||||
// - New requests will go to the bottom of the list, but will rise as others are marked as prayed.
|
||||
let lastActivity = lastHistory |> Option.map (_.AsOf) |> Option.defaultValue Instant.MinValue
|
||||
let showAfter = defaultArg req.ShowAfter Instant.MinValue
|
||||
let snoozedUntil = defaultArg req.SnoozedUntil Instant.MinValue
|
||||
let lastPrayed =
|
||||
history
|
||||
|> Seq.filter History.isPrayed
|
||||
|> Seq.tryHead
|
||||
|> Option.map (_.AsOf)
|
||||
|> Option.defaultValue Instant.MinValue
|
||||
let asOf = List.max [ lastPrayed; showAfter; snoozedUntil ]
|
||||
{ RequestId = req.Id
|
||||
UserId = req.UserId
|
||||
Text = history
|
||||
|> Seq.filter (fun it -> Option.isSome it.Text)
|
||||
|> Seq.tryHead
|
||||
|> Option.map (fun h -> Option.get h.Text)
|
||||
|> Option.defaultValue ""
|
||||
AsOf = if asOf > Instant.MinValue then asOf else lastActivity
|
||||
LastPrayed = if lastPrayed = Instant.MinValue then None else Some lastPrayed
|
||||
LastStatus = match lastHistory with Some h -> h.Status | None -> Created
|
||||
SnoozedUntil = req.SnoozedUntil
|
||||
ShowAfter = req.ShowAfter
|
||||
Recurrence = req.Recurrence
|
||||
History = []
|
||||
Notes = []
|
||||
}
|
||||
|
||||
/// Same as `ofRequestLite`, but with notes and history
|
||||
let ofRequestFull req =
|
||||
{ ofRequestLite req with
|
||||
History = req.History
|
||||
Notes = req.Notes
|
||||
}
|
||||
@@ -1,570 +0,0 @@
|
||||
/// HTTP handlers for the myPrayerJournal API
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyPrayerJournal.Handlers
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Htmx
|
||||
open System
|
||||
|
||||
/// Helper function to be able to split out log on
|
||||
[<AutoOpen>]
|
||||
module private LogOnHelpers =
|
||||
|
||||
open Microsoft.AspNetCore.Authentication
|
||||
|
||||
/// Log on, optionally specifying a redirected URL once authentication is complete
|
||||
let logOn url : HttpHandler = fun next ctx -> task {
|
||||
match url with
|
||||
| Some it ->
|
||||
do! ctx.ChallengeAsync("Auth0", AuthenticationProperties(RedirectUri = it))
|
||||
return! next ctx
|
||||
| None -> return! challenge "Auth0" next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for error conditions
|
||||
module Error =
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Handle errors
|
||||
let error (ex : Exception) (log : ILogger) =
|
||||
log.LogError (EventId (), ex, "An unhandled exception has occurred while executing the request.")
|
||||
clearResponse
|
||||
>=> setStatusCode 500
|
||||
>=> setHttpHeader "X-Toast" $"error|||{ex.GetType().Name}: {ex.Message}"
|
||||
>=> text ex.Message
|
||||
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
let notAuthorized : HttpHandler = fun next ctx ->
|
||||
(if ctx.Request.Method = "GET" then logOn None next else setStatusCode 401 earlyReturn) ctx
|
||||
|
||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||
let notFound : HttpHandler =
|
||||
setStatusCode 404 >=> text "Not found"
|
||||
|
||||
|
||||
open System.Security.Claims
|
||||
open Microsoft.AspNetCore.Http
|
||||
open NodaTime
|
||||
|
||||
/// Extensions on the HTTP context
|
||||
type HttpContext with
|
||||
|
||||
/// The "sub" for the current user (None if no user is authenticated)
|
||||
member this.CurrentUser =
|
||||
this.User
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier))
|
||||
|> Option.flatten
|
||||
|> Option.map (_.Value)
|
||||
|
||||
/// The current user's ID
|
||||
// NOTE: this may raise if you don't run the request through the requireUser handler first
|
||||
member this.UserId = UserId this.CurrentUser.Value
|
||||
|
||||
/// The system clock
|
||||
member this.Clock = this.GetService<IClock>()
|
||||
|
||||
/// Get the current instant from the system clock
|
||||
member this.Now = this.Clock.GetCurrentInstant
|
||||
|
||||
/// Get the time zone from the X-Time-Zone header (default UTC)
|
||||
member this.TimeZone =
|
||||
match this.TryGetRequestHeader "X-Time-Zone" with
|
||||
| Some tz ->
|
||||
match this.GetService<IDateTimeZoneProvider>().GetZoneOrNull tz with
|
||||
| null -> DateTimeZone.Utc
|
||||
| zone -> zone
|
||||
| None -> DateTimeZone.Utc
|
||||
|
||||
|
||||
open MyPrayerJournal.Data
|
||||
|
||||
/// Handler helpers
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
open Microsoft.Net.Http.Headers
|
||||
|
||||
/// Require a user to be logged on
|
||||
let requireUser : HttpHandler =
|
||||
requiresAuthentication Error.notAuthorized
|
||||
|
||||
/// Debug logger
|
||||
let debug (ctx : HttpContext) message =
|
||||
let fac = ctx.GetService<ILoggerFactory>()
|
||||
let log = fac.CreateLogger "Debug"
|
||||
log.LogInformation message
|
||||
|
||||
/// Return a 201 CREATED response
|
||||
let created =
|
||||
setStatusCode 201
|
||||
|
||||
/// Return a 201 CREATED response with the location header set for the created resource
|
||||
let createdAt url : HttpHandler = fun next ctx ->
|
||||
Successful.CREATED
|
||||
($"{ctx.Request.Scheme}://{ctx.Request.Host.Value}{url}" |> setHttpHeader HeaderNames.Location) next ctx
|
||||
|
||||
/// Return a 303 SEE OTHER response (forces a GET on the redirected URL)
|
||||
let seeOther (url : string) =
|
||||
noResponseCaching >=> setStatusCode 303 >=> setHttpHeader "Location" url
|
||||
|
||||
/// Render a component result
|
||||
let renderComponent nodes : HttpHandler =
|
||||
noResponseCaching
|
||||
>=> fun _ ctx -> backgroundTask {
|
||||
return! ctx.WriteHtmlStringAsync(ViewEngine.RenderView.AsString.htmlNodes nodes)
|
||||
}
|
||||
|
||||
open Views.Layout
|
||||
open System.Threading.Tasks
|
||||
|
||||
/// Create a page rendering context
|
||||
let pageContext (ctx : HttpContext) pageTitle content = backgroundTask {
|
||||
let! hasSnoozed =
|
||||
match ctx.CurrentUser with
|
||||
| Some _ -> Journal.hasSnoozed ctx.UserId (ctx.Now())
|
||||
| None -> Task.FromResult false
|
||||
return
|
||||
{ IsAuthenticated = Option.isSome ctx.CurrentUser
|
||||
HasSnoozed = hasSnoozed
|
||||
CurrentUrl = ctx.Request.Path.Value
|
||||
PageTitle = pageTitle
|
||||
Content = content
|
||||
}
|
||||
}
|
||||
|
||||
/// Composable handler to write a view to the output
|
||||
let writeView view : HttpHandler = fun _ ctx -> backgroundTask {
|
||||
return! ctx.WriteHtmlViewAsync view
|
||||
}
|
||||
|
||||
|
||||
/// Hold messages across redirects
|
||||
module Messages =
|
||||
|
||||
/// The messages being held
|
||||
let mutable private messages : Map<UserId, string * string> = Map.empty
|
||||
|
||||
/// Locked update to prevent updates by multiple threads
|
||||
let private upd8 = obj ()
|
||||
|
||||
/// Push a new message into the list
|
||||
let push (ctx : HttpContext) message url = lock upd8 (fun () ->
|
||||
messages <- messages.Add(ctx.UserId, (message, url)))
|
||||
|
||||
/// Add a success message header to the response
|
||||
let pushSuccess ctx message url =
|
||||
push ctx $"success|||%s{message}" url
|
||||
|
||||
/// Pop the messages for the given user
|
||||
let pop userId = lock upd8 (fun () ->
|
||||
let msg = messages.TryFind userId
|
||||
msg |> Option.iter (fun _ -> messages <- messages.Remove userId)
|
||||
msg)
|
||||
|
||||
/// Send a partial result if this is not a full page load (does not append no-cache headers)
|
||||
let partialStatic (pageTitle : string) content : HttpHandler = fun next ctx -> task {
|
||||
let isPartial = ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
|
||||
let! pageCtx = pageContext ctx pageTitle content
|
||||
let view = (match isPartial with true -> partial | false -> view) pageCtx
|
||||
return!
|
||||
(next, ctx)
|
||||
||> match ctx.CurrentUser with
|
||||
| Some _ ->
|
||||
match Messages.pop ctx.UserId with
|
||||
| Some (msg, url) -> setHttpHeader "X-Toast" msg >=> withHxPushUrl url >=> writeView view
|
||||
| None -> writeView view
|
||||
| None -> writeView view
|
||||
}
|
||||
|
||||
/// Send an explicitly non-cached result, rendering as a partial if this is not a full page load
|
||||
let partial pageTitle content =
|
||||
noResponseCaching >=> partialStatic pageTitle content
|
||||
|
||||
/// Add a success message header to the response
|
||||
let withSuccessMessage : string -> HttpHandler =
|
||||
sprintf "success|||%s" >> setHttpHeader "X-Toast"
|
||||
|
||||
/// Hide a modal window when the response is sent
|
||||
let hideModal (name : string) : HttpHandler =
|
||||
setHttpHeader "X-Hide-Modal" name
|
||||
|
||||
|
||||
/// Strongly-typed models for post requests
|
||||
module Models =
|
||||
|
||||
/// An additional note
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type NoteEntry =
|
||||
{ /// The notes being added
|
||||
notes : string
|
||||
}
|
||||
|
||||
/// A prayer request
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Request =
|
||||
{ /// The ID of the request
|
||||
requestId : string
|
||||
|
||||
/// Where to redirect after saving
|
||||
returnTo : string
|
||||
|
||||
/// The text of the request
|
||||
requestText : string
|
||||
|
||||
/// The additional status to record
|
||||
status : string option
|
||||
|
||||
/// The recurrence type
|
||||
recurType : string
|
||||
|
||||
/// The recurrence count
|
||||
recurCount : int16 option
|
||||
|
||||
/// The recurrence interval
|
||||
recurInterval : string option
|
||||
}
|
||||
|
||||
/// The date until which a request should not appear in the journal
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type SnoozeUntil =
|
||||
{ /// The date (YYYY-MM-DD) at which the request should reappear
|
||||
until : string
|
||||
}
|
||||
|
||||
|
||||
open NodaTime.Text
|
||||
|
||||
/// Handlers for less-than-full-page HTML requests
|
||||
module Components =
|
||||
|
||||
// GET /components/journal-items
|
||||
let journalItems : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let now = ctx.Now ()
|
||||
let shouldBeShown (req : JournalRequest) =
|
||||
match req.SnoozedUntil, req.ShowAfter with
|
||||
| None, None -> true
|
||||
| Some snooze, Some hide when snooze < now && hide < now -> true
|
||||
| Some snooze, _ when snooze < now -> true
|
||||
| _, Some hide when hide < now -> true
|
||||
| _, _ -> false
|
||||
let! journal = Journal.forUser ctx.UserId
|
||||
let shown = journal |> List.filter shouldBeShown
|
||||
return! renderComponent [ Views.Journal.journalItems now ctx.TimeZone shown ] next ctx
|
||||
}
|
||||
|
||||
// GET /components/request-item/[req-id]
|
||||
let requestItem reqId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Journal.tryById (RequestId.ofString reqId) ctx.UserId with
|
||||
| Some req -> return! renderComponent [ Views.Request.reqListItem (ctx.Now()) ctx.TimeZone req ] next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /components/request/[req-id]/add-notes
|
||||
let addNotes requestId : HttpHandler =
|
||||
requireUser >=> renderComponent (Views.Journal.notesEdit (RequestId.ofString requestId))
|
||||
|
||||
// GET /components/request/[req-id]/notes
|
||||
let notes requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! notes = Note.byRequestId (RequestId.ofString requestId) ctx.UserId
|
||||
return! renderComponent (Views.Request.notes (ctx.Now()) ctx.TimeZone notes) next ctx
|
||||
}
|
||||
|
||||
// GET /components/request/[req-id]/snooze
|
||||
let snooze requestId : HttpHandler =
|
||||
requireUser >=> renderComponent [ RequestId.ofString requestId |> Views.Journal.snooze ]
|
||||
|
||||
|
||||
/// / URL and documentation
|
||||
module Home =
|
||||
|
||||
// GET /
|
||||
let home : HttpHandler =
|
||||
partialStatic "Welcome!" Views.Layout.home
|
||||
|
||||
// GET /docs
|
||||
let docs : HttpHandler =
|
||||
partialStatic "Documentation" Views.Docs.index
|
||||
|
||||
/// /journal URL
|
||||
module Journal =
|
||||
|
||||
// GET /journal
|
||||
let journal : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let usr =
|
||||
ctx.User.Claims
|
||||
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName)
|
||||
|> Option.map (_.Value)
|
||||
|> Option.defaultValue "Your"
|
||||
let title = usr |> match usr with "Your" -> sprintf "%s" | _ -> sprintf "%s’s"
|
||||
return! partial $"{title} Prayer Journal" (Views.Journal.journal usr) next ctx
|
||||
}
|
||||
|
||||
|
||||
/// /legal URLs
|
||||
module Legal =
|
||||
|
||||
// GET /legal/privacy-policy
|
||||
let privacyPolicy : HttpHandler =
|
||||
partialStatic "Privacy Policy" Views.Legal.privacyPolicy
|
||||
|
||||
// GET /legal/terms-of-service
|
||||
let termsOfService : HttpHandler =
|
||||
partialStatic "Terms of Service" Views.Legal.termsOfService
|
||||
|
||||
|
||||
/// /api/request and /request(s) URLs
|
||||
module Request =
|
||||
|
||||
open Cuid
|
||||
|
||||
// GET /request/[req-id]/edit
|
||||
let edit requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let returnTo =
|
||||
match ctx.Request.Headers.Referer[0] with
|
||||
| it when it.EndsWith "/active" -> "active"
|
||||
| it when it.EndsWith "/snoozed" -> "snoozed"
|
||||
| _ -> "journal"
|
||||
match requestId with
|
||||
| "new" ->
|
||||
return! partial "Add Prayer Request"
|
||||
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) returnTo true) next ctx
|
||||
| _ ->
|
||||
match! Journal.tryById (RequestId.ofString requestId) ctx.UserId with
|
||||
| Some req ->
|
||||
debug ctx "Found - sending view"
|
||||
return! partial "Edit Prayer Request" (Views.Request.edit req returnTo false) next ctx
|
||||
| None ->
|
||||
debug ctx "Not found - uh oh..."
|
||||
return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH /request/[req-id]/prayed
|
||||
let prayed requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Journal.tryById reqId userId with
|
||||
| Some req ->
|
||||
let now = ctx.Now ()
|
||||
do! History.add reqId userId { AsOf = now; Status = Prayed; Text = None }
|
||||
let nextShow =
|
||||
match Recurrence.duration req.Recurrence with
|
||||
| 0L -> None
|
||||
| duration -> Some <| now.Plus (Duration.FromSeconds duration)
|
||||
do! Request.updateShowAfter reqId userId nextShow
|
||||
return! (withSuccessMessage "Request marked as prayed" >=> Components.journalItems) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /request/[req-id]/note
|
||||
let addNote requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
let! notes = ctx.BindFormAsync<Models.NoteEntry>()
|
||||
do! Note.add reqId userId { AsOf = ctx.Now(); Notes = notes.notes }
|
||||
return! (withSuccessMessage "Added Notes" >=> hideModal "notes" >=> created) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET /requests/active
|
||||
let active : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! reqs = Journal.forUser ctx.UserId
|
||||
return! partial "Active Requests" (Views.Request.active (ctx.Now()) ctx.TimeZone reqs) next ctx
|
||||
}
|
||||
|
||||
// GET /requests/snoozed
|
||||
let snoozed : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! reqs = Journal.forUser ctx.UserId
|
||||
let now = ctx.Now()
|
||||
let snoozed = reqs
|
||||
|> List.filter (fun it -> defaultArg (it.SnoozedUntil |> Option.map (fun it -> it > now)) false)
|
||||
return! partial "Snoozed Requests" (Views.Request.snoozed now ctx.TimeZone snoozed) next ctx
|
||||
}
|
||||
|
||||
// GET /requests/answered
|
||||
let answered : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! reqs = Journal.answered ctx.UserId
|
||||
return! partial "Answered Requests" (Views.Request.answered (ctx.Now()) ctx.TimeZone reqs) next ctx
|
||||
}
|
||||
|
||||
// GET /request/[req-id]/full
|
||||
let getFull requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
match! Request.tryById (RequestId.ofString requestId) ctx.UserId with
|
||||
| Some req -> return! partial "Prayer Request" (Views.Request.full ctx.Clock ctx.TimeZone req) next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH /request/[req-id]/show
|
||||
let show requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
do! Request.updateShowAfter reqId userId None
|
||||
return! (withSuccessMessage "Request now shown" >=> Components.requestItem requestId) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH /request/[req-id]/snooze
|
||||
let snooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
let! until = ctx.BindFormAsync<Models.SnoozeUntil>()
|
||||
let date =
|
||||
LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd").Parse(until.until).Value
|
||||
.AtStartOfDayInZone(DateTimeZone.Utc)
|
||||
.ToInstant()
|
||||
do! Request.updateSnoozed reqId userId (Some date)
|
||||
return!
|
||||
(withSuccessMessage $"Request snoozed until {until.until}"
|
||||
>=> hideModal "snooze"
|
||||
>=> Components.journalItems) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// PATCH /request/[req-id]/cancel-snooze
|
||||
let cancelSnooze requestId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let userId = ctx.UserId
|
||||
let reqId = RequestId.ofString requestId
|
||||
match! Request.existsById reqId userId with
|
||||
| true ->
|
||||
do! Request.updateSnoozed reqId userId None
|
||||
return! (withSuccessMessage "Request unsnoozed" >=> Components.requestItem requestId) next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
/// Derive a recurrence from its representation in the form
|
||||
let private parseRecurrence (form : Models.Request) =
|
||||
match form.recurInterval with Some x -> $"{defaultArg form.recurCount 0s} {x}" | None -> "Immediate"
|
||||
|> Recurrence.ofString
|
||||
|
||||
// POST /request
|
||||
let add : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! form = ctx.BindModelAsync<Models.Request>()
|
||||
let userId = ctx.UserId
|
||||
let now = ctx.Now()
|
||||
let req =
|
||||
{ Request.empty with
|
||||
Id = Cuid.generate () |> RequestId
|
||||
UserId = userId
|
||||
EnteredOn = now
|
||||
ShowAfter = None
|
||||
Recurrence = parseRecurrence form
|
||||
History = [
|
||||
{ AsOf = now
|
||||
Status = Created
|
||||
Text = Some form.requestText
|
||||
}
|
||||
]
|
||||
}
|
||||
do! Request.add req
|
||||
Messages.pushSuccess ctx "Added prayer request" "/journal"
|
||||
return! seeOther "/journal" next ctx
|
||||
}
|
||||
|
||||
// PATCH /request
|
||||
let update : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! form = ctx.BindModelAsync<Models.Request>()
|
||||
let userId = ctx.UserId
|
||||
// TODO: update the instance and save rather than all these little updates
|
||||
match! Journal.tryById (RequestId.ofString form.requestId) userId with
|
||||
| Some req ->
|
||||
// update recurrence if changed
|
||||
let recur = parseRecurrence form
|
||||
match recur = req.Recurrence with
|
||||
| true -> ()
|
||||
| false ->
|
||||
do! Request.updateRecurrence req.RequestId userId recur
|
||||
match recur with
|
||||
| Immediate -> do! Request.updateShowAfter req.RequestId userId None
|
||||
| _ -> ()
|
||||
// append history
|
||||
let upd8Text = form.requestText.Trim()
|
||||
let text = if upd8Text = req.Text then None else Some upd8Text
|
||||
do! History.add req.RequestId userId
|
||||
{ AsOf = ctx.Now(); Status = (Option.get >> RequestAction.ofString) form.status; Text = text }
|
||||
let nextUrl =
|
||||
match form.returnTo with
|
||||
| "active" -> "/requests/active"
|
||||
| "snoozed" -> "/requests/snoozed"
|
||||
| _ (* "journal" *) -> "/journal"
|
||||
Messages.pushSuccess ctx "Prayer request updated successfully" nextUrl
|
||||
return! seeOther nextUrl next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers for /user URLs
|
||||
module User =
|
||||
|
||||
open Microsoft.AspNetCore.Authentication
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
|
||||
// GET /user/log-on
|
||||
let logOn : HttpHandler =
|
||||
logOn (Some "/journal")
|
||||
|
||||
// GET /user/log-off
|
||||
let logOff : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
do! ctx.SignOutAsync("Auth0", AuthenticationProperties (RedirectUri = "/"))
|
||||
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||
return! next ctx
|
||||
}
|
||||
|
||||
|
||||
open Giraffe.EndpointRouting
|
||||
|
||||
/// The routes for myPrayerJournal
|
||||
let routes = [
|
||||
GET_HEAD [ route "/" Home.home ]
|
||||
subRoute "/components/" [
|
||||
GET_HEAD [
|
||||
route "journal-items" Components.journalItems // done
|
||||
routef "request/%s/add-notes" Components.addNotes // done
|
||||
routef "request/%s/item" Components.requestItem
|
||||
routef "request/%s/notes" Components.notes // done
|
||||
routef "request/%s/snooze" Components.snooze // done
|
||||
]
|
||||
]
|
||||
GET_HEAD [ route "/docs" Home.docs ] // done
|
||||
GET_HEAD [ route "/journal" Journal.journal ] // done
|
||||
subRoute "/legal/" [
|
||||
GET_HEAD [
|
||||
route "privacy-policy" Legal.privacyPolicy // done
|
||||
route "terms-of-service" Legal.termsOfService // done
|
||||
]
|
||||
]
|
||||
subRoute "/request" [
|
||||
GET_HEAD [
|
||||
routef "/%s/edit" Request.edit // done
|
||||
routef "/%s/full" Request.getFull // done
|
||||
route "s/active" Request.active // done
|
||||
route "s/answered" Request.answered // done
|
||||
route "s/snoozed" Request.snoozed // done
|
||||
]
|
||||
PATCH [
|
||||
route "" Request.update // done
|
||||
routef "/%s/cancel-snooze" Request.cancelSnooze // done
|
||||
routef "/%s/prayed" Request.prayed // done
|
||||
routef "/%s/show" Request.show // done
|
||||
routef "/%s/snooze" Request.snooze // done
|
||||
]
|
||||
POST [
|
||||
route "" Request.add // done
|
||||
routef "/%s/note" Request.addNote // done
|
||||
]
|
||||
]
|
||||
subRoute "/user/" [
|
||||
GET_HEAD [
|
||||
route "log-off" User.logOff // done
|
||||
route "log-on" User.logOn // done
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Version>3.4</Version>
|
||||
<DebugType>embedded</DebugType>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<SelfContained>false</SelfContained>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Dates.fs" />
|
||||
<Compile Include="Views/Helpers.fs" />
|
||||
<Compile Include="Views/Journal.fs" />
|
||||
<Compile Include="Views/Layout.fs" />
|
||||
<Compile Include="Views/Legal.fs" />
|
||||
<Compile Include="Views/Request.fs" />
|
||||
<Compile Include="Views\Docs.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BitBadger.Documents.Postgres" Version="3.1.0" />
|
||||
<PackageReference Include="FSharp.SystemTextJson" Version="1.3.13" />
|
||||
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
||||
<PackageReference Include="Giraffe" Version="6.4.0" />
|
||||
<PackageReference Include="Giraffe.Htmx" Version="1.9.12" />
|
||||
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.9.12" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.6" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="8.0.3" />
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,111 +0,0 @@
|
||||
module MyPrayerJournal.Api
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
let sameSite (opts : CookieOptions) =
|
||||
match opts.SameSite, opts.Secure with
|
||||
| SameSiteMode.None, false -> opts.SameSite <- SameSiteMode.Unspecified
|
||||
| _, _ -> ()
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Authentication.OpenIdConnect
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.HttpOverrides
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Hosting
|
||||
open Microsoft.Extensions.Logging
|
||||
open Microsoft.IdentityModel.Protocols.OpenIdConnect
|
||||
open MyPrayerJournal.Data
|
||||
open NodaTime
|
||||
open System
|
||||
open System.Text.Json
|
||||
open System.Threading.Tasks
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
//use host = Configure.webHost [| "wwwroot" |] (Directory.GetCurrentDirectory ())
|
||||
//host.Run ()
|
||||
let builder = WebApplication.CreateBuilder args
|
||||
let _ = builder.Configuration.AddEnvironmentVariables "MPJ_"
|
||||
let svc = builder.Services
|
||||
let cfg = svc.BuildServiceProvider().GetRequiredService<IConfiguration>()
|
||||
|
||||
let _ = svc.AddRouting()
|
||||
let _ = svc.AddGiraffe()
|
||||
let _ = svc.AddSingleton<IClock> SystemClock.Instance
|
||||
let _ = svc.AddSingleton<IDateTimeZoneProvider> DateTimeZoneProviders.Tzdb
|
||||
let _ = svc.Configure<ForwardedHeadersOptions>(fun (opts : ForwardedHeadersOptions) ->
|
||||
opts.ForwardedHeaders <- ForwardedHeaders.XForwardedFor ||| ForwardedHeaders.XForwardedProto)
|
||||
|
||||
let _ =
|
||||
svc.Configure<CookiePolicyOptions>(fun (opts : CookiePolicyOptions) ->
|
||||
opts.MinimumSameSitePolicy <- SameSiteMode.Unspecified
|
||||
opts.OnAppendCookie <- fun ctx -> sameSite ctx.CookieOptions
|
||||
opts.OnDeleteCookie <- fun ctx -> sameSite ctx.CookieOptions)
|
||||
let _ =
|
||||
svc.AddAuthentication(fun opts ->
|
||||
opts.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||
opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||
opts.DefaultChallengeScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie()
|
||||
.AddOpenIdConnect("Auth0", fun opts ->
|
||||
// Configure OIDC with Auth0 options from configuration
|
||||
let auth0 = cfg.GetSection "Auth0"
|
||||
opts.Authority <- $"""https://{auth0["Domain"]}/"""
|
||||
opts.ClientId <- auth0["Id"]
|
||||
opts.ClientSecret <- auth0["Secret"]
|
||||
opts.ResponseType <- OpenIdConnectResponseType.Code
|
||||
|
||||
opts.Scope.Clear()
|
||||
opts.Scope.Add "openid"
|
||||
opts.Scope.Add "profile"
|
||||
|
||||
opts.CallbackPath <- PathString "/user/log-on/success"
|
||||
opts.ClaimsIssuer <- "Auth0"
|
||||
opts.SaveTokens <- true
|
||||
|
||||
opts.Events <- OpenIdConnectEvents()
|
||||
opts.Events.OnRedirectToIdentityProviderForSignOut <- fun ctx ->
|
||||
let returnTo =
|
||||
match ctx.Properties.RedirectUri with
|
||||
| it when isNull it || it = "" -> ""
|
||||
| redirUri ->
|
||||
let finalRedirUri =
|
||||
match redirUri.StartsWith "/" with
|
||||
| true ->
|
||||
// transform to absolute
|
||||
let request = ctx.Request
|
||||
$"{request.Scheme}://{request.Host.Value}{request.PathBase.Value}{redirUri}"
|
||||
| false -> redirUri
|
||||
Uri.EscapeDataString $"&returnTo={finalRedirUri}"
|
||||
ctx.Response.Redirect $"""https://{auth0["Domain"]}/v2/logout?client_id={auth0["Id"]}{returnTo}"""
|
||||
ctx.HandleResponse()
|
||||
Task.CompletedTask
|
||||
opts.Events.OnRedirectToIdentityProvider <- fun ctx ->
|
||||
let uri = UriBuilder ctx.ProtocolMessage.RedirectUri
|
||||
uri.Scheme <- auth0["Scheme"]
|
||||
uri.Port <- int auth0["Port"]
|
||||
ctx.ProtocolMessage.RedirectUri <- string uri
|
||||
Task.CompletedTask)
|
||||
|
||||
let _ = svc.AddSingleton<JsonSerializerOptions> Json.options
|
||||
let _ = svc.AddSingleton<Json.ISerializer>(SystemTextJson.Serializer Json.options)
|
||||
let _ = Connection.setUp cfg |> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
||||
if builder.Environment.IsDevelopment() then builder.Logging.AddFilter(fun l -> l > LogLevel.Information) |> ignore
|
||||
let _ = builder.Logging.AddConsole().AddDebug() |> ignore
|
||||
|
||||
use app = builder.Build()
|
||||
let _ = app.UseStaticFiles()
|
||||
let _ = app.UseCookiePolicy()
|
||||
let _ = app.UseRouting()
|
||||
let _ = app.UseAuthentication()
|
||||
let _ = app.UseGiraffeErrorHandler Handlers.Error.error
|
||||
let _ = app.UseEndpoints(fun e -> e.MapGiraffeEndpoints Handlers.routes)
|
||||
|
||||
app.Run()
|
||||
|
||||
0
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:61905",
|
||||
"sslPort": 0
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"MyPrayerJournal.Api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
module MyPrayerJournal.Views.Docs
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
|
||||
/// The "About myPrayerJournal" section
|
||||
let private about = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "About myPrayerJournal" ]
|
||||
p [] [
|
||||
rawText "Journaling has a long history; it helps people remember what happened, and the act of writing helps "
|
||||
rawText "people think about what happened and process it. A prayer journal is not a new concept; it helps you "
|
||||
rawText "keep track of the requests for which you've prayed, you can use it to pray over things repeatedly, "
|
||||
rawText "and you can write the result when the answer comes "; em [] [ rawText "(or it was “no”)" ]
|
||||
rawText "."
|
||||
]
|
||||
p [] [
|
||||
rawText "myPrayerJournal was borne of out of a personal desire "
|
||||
a [ _href "https://daniel.summershome.org"; _target "_blank"; _rel "noopener" ] [ rawText "Daniel" ]
|
||||
rawText " had to have something that would help him with his prayer life. When it’s time to pray, "
|
||||
rawText "it’s not really time to use an app, so the design goal here is to keep it simple and "
|
||||
rawText "unobtrusive. It will also help eliminate some of the downsides to a paper prayer journal, like not "
|
||||
rawText "remembering whether you’ve prayed for a request, or running out of room to write another update "
|
||||
rawText "on one."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Signing Up" section
|
||||
let private signUp = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Signing Up" ]
|
||||
p [] [
|
||||
rawText "myPrayerJournal uses login services using Google or Microsoft accounts. The only information the "
|
||||
rawText "application stores in its database is your user Id token it receives from these services, so there "
|
||||
rawText "are no permissions you should have to accept from these provider other than establishing that you can "
|
||||
rawText "log on with that account. Because of this, you’ll want to pick the same one each time; the "
|
||||
rawText "tokens between the two accounts are different, even if you use the same e-mail address to log on to "
|
||||
rawText "both."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Your Prayer Journal" section
|
||||
let private yourJournal = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Your Prayer Journal" ]
|
||||
p [] [
|
||||
rawText "Your current requests will be presented in columns (usually three, but it could be more or less, "
|
||||
rawText "depending on the size of your screen or device). Each request is in its own card, and the buttons at "
|
||||
rawText "the top of each card apply to that request. The last line of each request also tells you how long it "
|
||||
rawText "has been since anything has been done on that request. Any time you see something like “a few "
|
||||
rawText "minutes ago,” you can hover over that to see the actual date/time the action was taken."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Adding a Request" section
|
||||
let private addRequest = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Adding a Request" ]
|
||||
p [] [
|
||||
rawText "To add a request, click the “Add a New Request” button at the top of your journal. Then, "
|
||||
rawText "enter the text of the request as you see fit; there is no right or wrong way, and you are the only "
|
||||
rawText "person who will see the text you enter. When you save the request, it will go to the bottom of the "
|
||||
rawText "list of requests."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Setting Request Recurrence" section
|
||||
let private setRecurrence = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Setting Request Recurrence" ]
|
||||
p [] [
|
||||
rawText "When you add or update a request, you can choose whether requests go to the bottom of the journal "
|
||||
rawText "once they have been marked “Prayed” or whether they will reappear after a delay. You can "
|
||||
rawText "set recurrence in terms of hours, days, or weeks, but it cannot be longer than 365 days. If you "
|
||||
rawText "decide you want a request to reappear sooner, you can skip the current delay; click the "
|
||||
rawText "“Active” menu link, find the request in the list (likely near the bottom), and click the "
|
||||
rawText "“Show Now” button."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Praying for Requests" section
|
||||
let private praying = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Praying for Requests" ]
|
||||
p [] [
|
||||
rawText "The first button for each request has a checkmark icon; clicking this button will mark the request as "
|
||||
rawText "“Prayed” and move it to the bottom of the list (or off, if you’ve set a recurrence "
|
||||
rawText "period for the request). This allows you, if you’re praying through your requests, to start at "
|
||||
rawText "the top left (with the request that it’s been the longest since you’ve prayed) and click "
|
||||
rawText "the button as you pray; when the request move below or away, the next-least-recently-prayed request "
|
||||
rawText "will take the top spot."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Editing Requests" section
|
||||
let private editing = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Editing Requests" ]
|
||||
p [] [
|
||||
rawText "The second button for each request has a pencil icon. This allows you to edit the text of the "
|
||||
rawText "request, pretty much the same way you entered it; it starts with the current text, and you can add to "
|
||||
rawText "it, modify it, or completely replace it. By default, updates will go in with an “Updated” "
|
||||
rawText "status; you have the option to also mark this update as “Prayed” or "
|
||||
rawText "“Answered”. Answered requests will drop off the journal list."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Adding Notes" section
|
||||
let private addNotes = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Adding Notes" ]
|
||||
p [] [
|
||||
rawText "The third button for each request has an icon that looks like a speech bubble with lines on it; this "
|
||||
rawText "lets you record notes about the request. If there is something you want to record that doesn’t "
|
||||
rawText "change the text of the request, this is the place to do it. For example, you may be praying for a "
|
||||
rawText "long-term health issue, and that person tells you that their status is the same; or, you may want to "
|
||||
rawText "record something God said to you while you were praying for that request."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Snoozing Requests" section
|
||||
let private snoozing = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Snoozing Requests" ]
|
||||
p [] [
|
||||
rawText "There may be a time where a request does not need to appear. The fourth button, with the clock icon, "
|
||||
rawText "allows you to snooze requests until the day you specify. Additionally, if you have any snoozed "
|
||||
rawText "requests, a “Snoozed” menu item will appear next to the “Journal” one; this "
|
||||
rawText "page allows you to see what requests are snoozed, and return them to your journal by canceling the "
|
||||
rawText "snooze."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Viewing a Request and Its History" section
|
||||
let private viewing = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Viewing a Request and Its History" ]
|
||||
p [] [
|
||||
rawText "myPrayerJournal tracks all of the actions related to a request; from the “Active” and "
|
||||
rawText "“Answered” menu links (and “Snoozed”, if it’s showing), there is a "
|
||||
rawText "“View Full Request” button. That page will show the current text of the request; how many "
|
||||
rawText "times it has been marked as prayed; how long it has been an active request; and a log of all updates, "
|
||||
rawText "prayers, and notes you have recorded. That log is listed from most recent to least recent; if you "
|
||||
rawText "want to read it chronologically, press the “End” key on your keyboard and read it from "
|
||||
rawText "the bottom up."
|
||||
]
|
||||
p [] [
|
||||
rawText "The “Active” link will show all requests that have not yet been marked answered, "
|
||||
rawText "including snoozed and recurring requests. If requests are snoozed, or in a recurrence period off the "
|
||||
rawText "journal, there will be a button where you can return the request to the list (either “Cancel "
|
||||
rawText "Snooze” or “Show Now”). The “Answered” link shows all requests that "
|
||||
rawText "have been marked answered. The “Snoozed” link only shows snoozed requests."
|
||||
]
|
||||
]
|
||||
|
||||
/// The "Final Notes" section
|
||||
let private finalNotes = [
|
||||
h3 [ _class "mb-3 mt-4" ] [ rawText "Final Notes" ]
|
||||
ul [] [
|
||||
li [] [
|
||||
rawText "If you encounter errors, please "
|
||||
a [ _href "https://git.bitbadger.solutions/bit-badger/myPrayerJournal/issues"; _target "_blank" ] [
|
||||
rawText "file an issue"
|
||||
]; rawText " (or "
|
||||
a [ _href "mailto:daniel@bitbadger.solutions?subject=myPrayerJournal+Issue" ] [ rawText "e-mail Daniel" ]
|
||||
rawText " if you do not have an account on that server) with as much detail as possible. You can also "
|
||||
rawText "provide suggestions, or browse the list of currently open issues."
|
||||
]
|
||||
li [] [
|
||||
rawText "Prayer requests and their history are securely backed up nightly along with other Bit Badger "
|
||||
rawText "Solutions data."
|
||||
]
|
||||
li [] [
|
||||
rawText "Prayer changes things - most of all, the one doing the praying. I pray that this tool enables you "
|
||||
rawText "to deepen and strengthen your prayer life."
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The documentation page
|
||||
let index =
|
||||
article [ _class "container mt-3" ] [
|
||||
h2 [ _class "mb-3" ] [ rawText "Documentation" ]
|
||||
yield! about
|
||||
yield! signUp
|
||||
yield! yourJournal
|
||||
yield! addRequest
|
||||
yield! setRecurrence
|
||||
yield! praying
|
||||
yield! editing
|
||||
yield! addNotes
|
||||
yield! snoozing
|
||||
yield! viewing
|
||||
yield! finalNotes
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
/// Internal partial views
|
||||
[<AutoOpen>]
|
||||
module private MyPrayerJournal.Views.Helpers
|
||||
|
||||
open Giraffe.Htmx
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyPrayerJournal
|
||||
open NodaTime
|
||||
|
||||
/// Create a link that targets the `#top` element and pushes a URL to history
|
||||
let pageLink href attrs =
|
||||
attrs
|
||||
|> List.append [ _href href; _hxBoost; _hxTarget "#top"; _hxSwap HxSwap.InnerHtml; _hxPushUrl "true" ]
|
||||
|> a
|
||||
|
||||
/// Create a Material icon
|
||||
let icon name = span [ _class "material-icons" ] [ str name ]
|
||||
|
||||
/// Create a card when there are no results found
|
||||
let noResults heading link buttonText text =
|
||||
div [ _class "card" ] [
|
||||
h5 [ _class "card-header"] [ str heading ]
|
||||
div [ _class "card-body text-center" ] [
|
||||
p [ _class "card-text" ] text
|
||||
pageLink link [ _class "btn btn-primary" ] [ str buttonText ]
|
||||
]
|
||||
]
|
||||
|
||||
/// Create a date with a span tag, displaying the relative date with the full date/time in the tooltip
|
||||
let relativeDate (date : Instant) now (tz : DateTimeZone) =
|
||||
span [ _title (date.InZone(tz).ToDateTimeOffset().ToString("f", null)) ] [ Dates.formatDistance now date |> str ]
|
||||
|
||||
/// The version of myPrayerJournal
|
||||
let version =
|
||||
let v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version
|
||||
seq {
|
||||
string v.Major
|
||||
if v.Minor > 0 then
|
||||
$".{v.Minor}"
|
||||
if v.Revision > 0 then $".{v.Revision}"
|
||||
} |> Seq.reduce (+)
|
||||
@@ -1,180 +0,0 @@
|
||||
/// Views for journal pages and components
|
||||
module MyPrayerJournal.Views.Journal
|
||||
|
||||
open Giraffe.Htmx
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyPrayerJournal
|
||||
|
||||
/// Display a card for this prayer request
|
||||
let journalCard now tz req =
|
||||
let reqId = RequestId.toString req.RequestId
|
||||
let spacer = span [] [ rawText " " ]
|
||||
div [ _class "col" ] [
|
||||
div [ _class "card h-100" ] [
|
||||
div [ _class "card-header p-0 d-flex"; _roleToolBar ] [
|
||||
pageLink $"/request/{reqId}/edit" [ _class "btn btn-secondary"; _title "Edit Request" ] [ icon "edit" ]
|
||||
spacer
|
||||
button [ _type "button"
|
||||
_class "btn btn-secondary"
|
||||
_title "Add Notes"
|
||||
_data "bs-toggle" "modal"
|
||||
_data "bs-target" "#notesModal"
|
||||
_hxGet $"/components/request/{reqId}/add-notes"
|
||||
_hxTarget "#notesBody"
|
||||
_hxSwap HxSwap.InnerHtml ] [
|
||||
icon "comment"
|
||||
]
|
||||
spacer
|
||||
button [ _type "button"
|
||||
_class "btn btn-secondary"
|
||||
_title "Snooze Request"
|
||||
_data "bs-toggle" "modal"
|
||||
_data "bs-target" "#snoozeModal"
|
||||
_hxGet $"/components/request/{reqId}/snooze"
|
||||
_hxTarget "#snoozeBody"
|
||||
_hxSwap HxSwap.InnerHtml ] [
|
||||
icon "schedule"
|
||||
]
|
||||
div [ _class "flex-grow-1" ] []
|
||||
button [ _type "button"
|
||||
_class "btn btn-success w-25"
|
||||
_hxPatch $"/request/{reqId}/prayed"
|
||||
_title "Mark as Prayed" ] [
|
||||
icon "done"
|
||||
]
|
||||
]
|
||||
div [ _class "card-body" ] [
|
||||
p [ _class "request-text" ] [ str req.Text ]
|
||||
]
|
||||
div [ _class "card-footer text-end text-muted px-1 py-0" ] [
|
||||
em [] [
|
||||
match req.LastPrayed with
|
||||
| Some dt -> str "last prayed "; relativeDate dt now tz
|
||||
| None -> str "last activity "; relativeDate req.AsOf now tz
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The journal loading page
|
||||
let journal user =
|
||||
article [ _class "container-fluid mt-3" ] [
|
||||
h2 [ _class "pb-3" ] [
|
||||
str user
|
||||
match user with "Your" -> () | _ -> rawText "’s"
|
||||
str " Prayer Journal"
|
||||
]
|
||||
p [ _class "pb-3 text-center" ] [
|
||||
pageLink "/request/new/edit" [ _class "btn btn-primary "] [ icon "add_box"; str " Add a Prayer Request" ]
|
||||
]
|
||||
p [ _hxGet "/components/journal-items"; _hxSwap HxSwap.OuterHtml; _hxTrigger HxTrigger.Load ] [
|
||||
rawText "Loading your prayer journal…"
|
||||
]
|
||||
div [ _id "notesModal"
|
||||
_class "modal fade"
|
||||
_tabindex "-1"
|
||||
_ariaLabelledBy "nodesModalLabel"
|
||||
_ariaHidden "true" ] [
|
||||
div [ _class "modal-dialog modal-dialog-scrollable" ] [
|
||||
div [ _class "modal-content" ] [
|
||||
div [ _class "modal-header" ] [
|
||||
h5 [ _class "modal-title"; _id "nodesModalLabel" ] [ str "Add Notes to Prayer Request" ]
|
||||
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
|
||||
]
|
||||
div [ _class "modal-body"; _id "notesBody" ] [ ]
|
||||
div [ _class "modal-footer" ] [
|
||||
button [ _type "button"
|
||||
_id "notesDismiss"
|
||||
_class "btn btn-secondary"
|
||||
_data "bs-dismiss" "modal" ] [
|
||||
str "Close"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _id "snoozeModal"
|
||||
_class "modal fade"
|
||||
_tabindex "-1"
|
||||
_ariaLabelledBy "snoozeModalLabel"
|
||||
_ariaHidden "true" ] [
|
||||
div [ _class "modal-dialog modal-sm" ] [
|
||||
div [ _class "modal-content" ] [
|
||||
div [ _class "modal-header" ] [
|
||||
h5 [ _class "modal-title"; _id "snoozeModalLabel" ] [ str "Snooze Prayer Request" ]
|
||||
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
|
||||
]
|
||||
div [ _class "modal-body"; _id "snoozeBody" ] [ ]
|
||||
div [ _class "modal-footer" ] [
|
||||
button [ _type "button"
|
||||
_id "snoozeDismiss"
|
||||
_class "btn btn-secondary"
|
||||
_data "bs-dismiss" "modal" ] [
|
||||
str "Close"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The journal items
|
||||
let journalItems now tz items =
|
||||
match items |> List.isEmpty with
|
||||
| true ->
|
||||
noResults "No Active Requests" "/request/new/edit" "Add a Request" [
|
||||
rawText "You have no requests to be shown; see the “Active” link above for snoozed or deferred "
|
||||
rawText "requests, and the “Answered” link for answered requests"
|
||||
]
|
||||
| false ->
|
||||
items
|
||||
|> List.map (journalCard now tz)
|
||||
|> section [ _id "journalItems"
|
||||
_class "row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3"
|
||||
_hxTarget "this"
|
||||
_hxSwap HxSwap.OuterHtml
|
||||
_ariaLabel "Prayer Requests" ]
|
||||
|
||||
/// The notes edit modal body
|
||||
let notesEdit requestId =
|
||||
let reqId = RequestId.toString requestId
|
||||
[ form [ _hxPost $"/request/{reqId}/note" ] [
|
||||
div [ _class "form-floating pb-3" ] [
|
||||
textarea [ _id "notes"
|
||||
_name "notes"
|
||||
_class "form-control"
|
||||
_style "min-height: 8rem;"
|
||||
_placeholder "Notes"
|
||||
_autofocus; _required ] [ ]
|
||||
label [ _for "notes" ] [ str "Notes" ]
|
||||
]
|
||||
p [ _class "text-end" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Add Notes" ] ]
|
||||
]
|
||||
hr [ _style "margin: .5rem -1rem" ]
|
||||
div [ _id "priorNotes" ] [
|
||||
p [ _class "text-center pt-3" ] [
|
||||
button [ _type "button"
|
||||
_class "btn btn-secondary"
|
||||
_hxGet $"/components/request/{reqId}/notes"
|
||||
_hxSwap HxSwap.OuterHtml
|
||||
_hxTarget "#priorNotes" ] [
|
||||
str "Load Prior Notes"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// The snooze edit form
|
||||
let snooze requestId =
|
||||
let today = System.DateTime.Today.ToString "yyyy-MM-dd"
|
||||
form [ _hxPatch $"/request/{RequestId.toString requestId}/snooze"
|
||||
_hxTarget "#journalItems"
|
||||
_hxSwap HxSwap.OuterHtml ] [
|
||||
div [ _class "form-floating pb-3" ] [
|
||||
input [ _type "date"; _id "until"; _name "until"; _class "form-control"; _min today; _required ]
|
||||
label [ _for "until" ] [ str "Until" ]
|
||||
]
|
||||
p [ _class "text-end mb-0" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Snooze" ] ]
|
||||
]
|
||||
@@ -1,125 +0,0 @@
|
||||
/// Layout / home views
|
||||
module MyPrayerJournal.Views.Layout
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
|
||||
/// The data needed to render a page-level view
|
||||
type PageRenderContext =
|
||||
{ /// Whether the user is authenticated
|
||||
IsAuthenticated: bool
|
||||
|
||||
/// Whether the user has snoozed requests
|
||||
HasSnoozed: bool
|
||||
|
||||
/// The current URL
|
||||
CurrentUrl: string
|
||||
|
||||
/// The title for the page to be rendered
|
||||
PageTitle: string
|
||||
|
||||
/// The content of the page
|
||||
Content: XmlNode }
|
||||
|
||||
/// The home page
|
||||
let home =
|
||||
article [ _class "container mt-3" ]
|
||||
[ p [] [ rawText " " ]
|
||||
p []
|
||||
[ str "myPrayerJournal is a place where individuals can record their prayer requests, record that they "
|
||||
str "prayed for them, update them as God moves in the situation, and record a final answer received on "
|
||||
str "that request. It also allows individuals to review their answered prayers." ]
|
||||
p []
|
||||
[ str "This site is open and available to the general public. To get started, simply click the "
|
||||
rawText "“Log On” link above, and log on with either a Microsoft or Google account. You can "
|
||||
rawText "also learn more about the site at the “Docs” link, also above." ] ]
|
||||
|
||||
/// The default navigation bar, which will load the items on page load, and whenever a refresh event occurs
|
||||
let private navBar ctx =
|
||||
nav [ _class "navbar navbar-dark"; _roleNavigation ]
|
||||
[ div [ _class "container-fluid" ]
|
||||
[ pageLink
|
||||
"/" [ _class "navbar-brand" ]
|
||||
[ span [ _class "m" ] [ str "my" ]
|
||||
span [ _class "p" ] [ str "Prayer" ]
|
||||
span [ _class "j" ] [ str "Journal" ] ]
|
||||
seq {
|
||||
let navLink (matchUrl : string) =
|
||||
match ctx.CurrentUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
|
||||
|> pageLink matchUrl
|
||||
if ctx.IsAuthenticated then
|
||||
li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ]
|
||||
li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ]
|
||||
if ctx.HasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ]
|
||||
li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
|
||||
li [ _class "nav-item" ] [ a [ _href "/user/log-off" ] [ str "Log Off" ] ]
|
||||
else li [ _class "nav-item"] [ a [ _href "/user/log-on" ] [ str "Log On" ] ]
|
||||
li [ _class "nav-item" ] [ navLink "/docs" [ str "Docs" ] ]
|
||||
}
|
||||
|> List.ofSeq
|
||||
|> ul [ _class "navbar-nav me-auto d-flex flex-row" ] ] ]
|
||||
|
||||
/// The title tag with the application name appended
|
||||
let titleTag ctx =
|
||||
title [] [ rawText ctx.PageTitle; rawText " « myPrayerJournal" ]
|
||||
|
||||
/// The HTML `head` element
|
||||
let htmlHead ctx =
|
||||
head [ _lang "en" ]
|
||||
[ meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||
meta [ _name "description"; _content "Online prayer journal - free w/Google or Microsoft account" ]
|
||||
titleTag ctx
|
||||
link [ _href "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
_rel "stylesheet"
|
||||
_integrity "sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
|
||||
_crossorigin "anonymous" ]
|
||||
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
||||
link [ _href "/style/style.css"; _rel "stylesheet" ] ]
|
||||
|
||||
/// 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" ]
|
||||
[ str $"myPrayerJournal {version}"
|
||||
br []
|
||||
em []
|
||||
[ small []
|
||||
[ pageLink "/legal/privacy-policy" [] [ str "Privacy Policy" ]
|
||||
rawText " • "
|
||||
pageLink "/legal/terms-of-service" [] [ str "Terms of Service" ]
|
||||
rawText " • "
|
||||
a [ _href "https://git.bitbadger.solutions/bit-badger/myPrayerJournal"
|
||||
_target "_blank"
|
||||
_rel "noopener" ] [ str "Developed" ]
|
||||
str " and hosted by "
|
||||
a [ _href "https://bitbadger.solutions"; _target "_blank"; _rel "noopener" ]
|
||||
[ str "Bit Badger Solutions" ] ] ] ]
|
||||
Htmx.Script.minified
|
||||
script [] [ rawText "if (!htmx) document.write('<script src=\"/script/htmx.min.js\"><\/script>')" ]
|
||||
script [ _async
|
||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
|
||||
_integrity "sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
|
||||
_crossorigin "anonymous" ] []
|
||||
script []
|
||||
[ rawText "setTimeout(function () { "
|
||||
rawText "if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>') "
|
||||
rawText "}, 2000)" ]
|
||||
script [ _src "/script/mpj.js" ] [] ]
|
||||
|
||||
/// Create the full view of the page
|
||||
let view ctx =
|
||||
html [ _lang "en" ]
|
||||
[ htmlHead ctx
|
||||
body []
|
||||
[ section [ _id "top"; _ariaLabel "Top navigation" ] [ navBar ctx; main [ _roleMain ] [ ctx.Content ] ]
|
||||
toaster
|
||||
htmlFoot ] ]
|
||||
|
||||
/// Create a partial view
|
||||
let partial ctx =
|
||||
html [ _lang "en" ] [ head [] [ titleTag ctx ]; body [] [ navBar ctx; main [ _roleMain ] [ ctx.Content ] ] ]
|
||||
@@ -1,162 +0,0 @@
|
||||
/// Views for legal pages
|
||||
module MyPrayerJournal.Views.Legal
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
|
||||
/// 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 "
|
||||
str "the data we collect, access, and store on your behalf as you use this service."
|
||||
]
|
||||
div [ _class "card" ] [
|
||||
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 "
|
||||
str "familiarize 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 ")."
|
||||
]
|
||||
]
|
||||
div [ _class "list-group-item" ] [
|
||||
h3 [] [ str "What We Collect" ]
|
||||
h4 [] [ str "Identifying Data" ]
|
||||
ul [] [
|
||||
li [] [
|
||||
str "The only identifying data myPrayerJournal stores is the subscriber "
|
||||
rawText "(“sub”) field from the token we receive from Auth0, once you have "
|
||||
str "signed in through their hosted service. All information is associated with you via "
|
||||
str "this field."
|
||||
]
|
||||
li [] [
|
||||
str "While you are signed in, within your browser, the service has access to your first "
|
||||
str "and last names, along with a URL to the profile picture (provided by your selected "
|
||||
str "identity provider). This information is not transmitted to the server, and is removed "
|
||||
rawText "when “Log Off” is clicked."
|
||||
]
|
||||
]
|
||||
h4 [] [ str "User Provided Data" ]
|
||||
ul [ _class "mb-0" ] [
|
||||
li [] [
|
||||
str "myPrayerJournal stores the information you provide, including the text of prayer "
|
||||
str "requests, updates, and notes; and the date/time when certain actions are taken."
|
||||
]
|
||||
]
|
||||
]
|
||||
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 "
|
||||
str "answered 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 "
|
||||
str "rolling manner; 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 "
|
||||
str "repository."
|
||||
]
|
||||
li [] [
|
||||
str "The data collected and stored is the absolute minimum necessary for the functionality "
|
||||
rawText "of the service. There are no plans to “monetize” this service, and "
|
||||
str "storing the minimum amount of information means that the data we have is not "
|
||||
str "interesting to purchasers (or those who may have more nefarious purposes)."
|
||||
]
|
||||
li [] [
|
||||
str "Access to servers and backups is strictly controlled and monitored for unauthorized "
|
||||
str "access attempts."
|
||||
]
|
||||
]
|
||||
]
|
||||
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 "
|
||||
str "provide ways to revoke access from this application. However, if you want your data "
|
||||
str "removed from the database, please contact daniel at bitbadger.solutions (via e-mail, "
|
||||
str "replacing at with @) prior to doing so, to ensure we can determine which subscriber ID "
|
||||
str "belongs to you."
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// 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" ] [
|
||||
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, "
|
||||
str "and that you are responsible to ensure that your use of this site complies with all "
|
||||
str "applicable laws. Your continued use of this site implies your acceptance of these terms."
|
||||
]
|
||||
]
|
||||
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 "
|
||||
str "requests. It requires no registration by itself, but access is granted based on a "
|
||||
str "successful login with an external 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 "3. Third Party Services" ]
|
||||
p [ _class "card-text" ] [
|
||||
str "This service utilizes a third-party service provider for identity management. Review the "
|
||||
str "terms of 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 "4. Liability" ]
|
||||
p [ _class "card-text" ] [
|
||||
rawText "This service is provided “as is”, and no warranty (express or implied) "
|
||||
str "exists. The service and its developers may not be held liable for any damages that may "
|
||||
str "arise through the use of this service."
|
||||
]
|
||||
]
|
||||
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 "
|
||||
str "capability to notify users when these change. The date at the top of the page will be "
|
||||
str "updated when any of the 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."
|
||||
]
|
||||
]
|
||||
@@ -1,273 +0,0 @@
|
||||
/// Views for request pages and components
|
||||
module MyPrayerJournal.Views.Request
|
||||
|
||||
open Giraffe.Htmx
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyPrayerJournal
|
||||
open NodaTime
|
||||
|
||||
/// Create a request within the list
|
||||
let reqListItem now tz req =
|
||||
let isFuture instant = defaultArg (instant |> Option.map (fun it -> it > now)) false
|
||||
let reqId = RequestId.toString req.RequestId
|
||||
let isAnswered = req.LastStatus = Answered
|
||||
let isSnoozed = isFuture req.SnoozedUntil
|
||||
let isPending = (not isSnoozed) && isFuture req.ShowAfter
|
||||
let btnClass = _class "btn btn-light mx-2"
|
||||
let restoreBtn (link : string) title =
|
||||
button [ btnClass; _hxPatch $"/request/{reqId}/{link}"; _title title ] [ icon "restore" ]
|
||||
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 pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ]
|
||||
if isSnoozed then restoreBtn "cancel-snooze" "Cancel Snooze"
|
||||
elif isPending then restoreBtn "show" "Show Now"
|
||||
p [ _class "request-text mb-0" ] [
|
||||
str req.Text
|
||||
if isSnoozed || isPending || isAnswered then
|
||||
br []
|
||||
small [ _class "text-muted" ] [
|
||||
if isSnoozed then [ str "Snooze expires "; relativeDate req.SnoozedUntil.Value now tz ]
|
||||
elif isPending then [ str "Request appears next "; relativeDate req.ShowAfter.Value now tz ]
|
||||
else (* isAnswered *) [ str "Answered "; relativeDate req.AsOf now tz ]
|
||||
|> em []
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Create a list of requests
|
||||
let reqList now tz reqs =
|
||||
reqs
|
||||
|> List.map (reqListItem now tz)
|
||||
|> div [ _class "list-group" ]
|
||||
|
||||
/// View for Active Requests page
|
||||
let active now tz reqs =
|
||||
article [ _class "container mt-3" ] [
|
||||
h2 [ _class "pb-3" ] [ str "Active Requests" ]
|
||||
if List.isEmpty reqs then
|
||||
noResults "No Active Requests" "/journal" "Return to your journal"
|
||||
[ str "Your prayer journal has no active requests" ]
|
||||
else reqList now tz reqs
|
||||
]
|
||||
|
||||
/// View for Answered Requests page
|
||||
let answered now tz reqs =
|
||||
article [ _class "container mt-3" ] [
|
||||
h2 [ _class "pb-3" ] [ str "Answered Requests" ]
|
||||
if List.isEmpty reqs then
|
||||
noResults "No Answered Requests" "/journal" "Return to your journal" [
|
||||
str "Your prayer journal has no answered requests; once you have marked one as "
|
||||
rawText "“Answered”, it will appear here"
|
||||
]
|
||||
else reqList now tz reqs
|
||||
]
|
||||
|
||||
/// View for Snoozed Requests page
|
||||
let snoozed now tz reqs =
|
||||
article [ _class "container mt-3" ] [
|
||||
h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
|
||||
reqList now tz reqs
|
||||
]
|
||||
|
||||
/// View for Full Request page
|
||||
let full (clock : IClock) tz (req : Request) =
|
||||
let now = clock.GetCurrentInstant()
|
||||
let answered =
|
||||
req.History
|
||||
|> Seq.ofList
|
||||
|> Seq.filter History.isAnswered
|
||||
|> Seq.tryHead
|
||||
|> Option.map (_.AsOf)
|
||||
let prayed = (req.History |> List.filter History.isPrayed |> List.length).ToString "N0"
|
||||
let daysOpen =
|
||||
let asOf = defaultArg answered now
|
||||
((asOf - (req.History |> List.filter History.isCreated |> List.head).AsOf).TotalDays |> int).ToString "N0"
|
||||
let lastText =
|
||||
req.History
|
||||
|> Seq.ofList
|
||||
|> Seq.filter (fun h -> Option.isSome h.Text)
|
||||
|> Seq.sortByDescending (_.AsOf)
|
||||
|> Seq.map (fun h -> Option.get h.Text)
|
||||
|> Seq.head
|
||||
// The history log including notes (and excluding the final entry for answered requests)
|
||||
let log =
|
||||
let toDisp (h : History) = {| asOf = h.AsOf; text = h.Text; status = RequestAction.toString h.Status |}
|
||||
let all =
|
||||
req.Notes
|
||||
|> Seq.ofList
|
||||
|> Seq.map (fun n -> {| asOf = n.AsOf; text = Some n.Notes; status = "Notes" |})
|
||||
|> Seq.append (req.History |> List.map toDisp)
|
||||
|> Seq.sortByDescending (_.asOf)
|
||||
|> List.ofSeq
|
||||
// Skip the first entry for answered requests; that info is already displayed
|
||||
match answered with Some _ -> all.Tail | None -> all
|
||||
article [ _class "container mt-3" ] [
|
||||
div [_class "card" ] [
|
||||
h5 [ _class "card-header" ] [ str "Full Prayer Request" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted mb-2"] [
|
||||
match answered with
|
||||
| Some date ->
|
||||
str "Answered "
|
||||
date.ToDateTimeOffset().ToString("D", null) |> str
|
||||
str " ("
|
||||
relativeDate date now tz
|
||||
rawText ") • "
|
||||
| None -> ()
|
||||
rawText $"Prayed %s{prayed} times • Open %s{daysOpen} days"
|
||||
]
|
||||
p [ _class "card-text" ] [ str lastText ]
|
||||
]
|
||||
log
|
||||
|> List.map (fun it ->
|
||||
li [ _class "list-group-item" ] [
|
||||
p [ _class "m-0" ] [
|
||||
str it.status
|
||||
rawText " "
|
||||
small [] [ em [] [ it.asOf.ToDateTimeOffset().ToString("D", null) |> str ] ]
|
||||
]
|
||||
match it.text with
|
||||
| Some txt -> p [ _class "mt-2 mb-0" ] [ str txt ]
|
||||
| None -> ()
|
||||
])
|
||||
|> ul [ _class "list-group list-group-flush" ]
|
||||
]
|
||||
]
|
||||
|
||||
/// View for the edit request component
|
||||
let edit (req : JournalRequest) returnTo isNew =
|
||||
let cancelLink =
|
||||
match returnTo with
|
||||
| "active" -> "/requests/active"
|
||||
| "snoozed" -> "/requests/snoozed"
|
||||
| _ (* "journal" *) -> "/journal"
|
||||
let recurCount =
|
||||
match req.Recurrence with
|
||||
| Immediate -> None
|
||||
| Hours h -> Some h
|
||||
| Days d -> Some d
|
||||
| Weeks w -> Some w
|
||||
|> Option.map string
|
||||
|> Option.defaultValue ""
|
||||
article [ _class "container" ] [
|
||||
h2 [ _class "pb-3" ] [ (match isNew with true -> "Add" | false -> "Edit") |> strf "%s Prayer Request" ]
|
||||
form [ _hxBoost
|
||||
_hxTarget "#top"
|
||||
_hxPushUrl "true"
|
||||
"/request" |> match isNew with true -> _hxPost | false -> _hxPatch ] [
|
||||
input [ _type "hidden"
|
||||
_name "requestId"
|
||||
_value (match isNew with true -> "new" | false -> RequestId.toString req.RequestId) ]
|
||||
input [ _type "hidden"; _name "returnTo"; _value returnTo ]
|
||||
div [ _class "form-floating pb-3" ] [
|
||||
textarea [ _id "requestText"
|
||||
_name "requestText"
|
||||
_class "form-control"
|
||||
_style "min-height: 8rem;"
|
||||
_placeholder "Enter the text of the request"
|
||||
_autofocus; _required ] [ str req.Text ]
|
||||
label [ _for "requestText" ] [ str "Prayer Request" ]
|
||||
]
|
||||
br []
|
||||
if not isNew then
|
||||
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.Recurrence 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.Recurrence 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 recurCount
|
||||
_style "width:6rem;"
|
||||
_required
|
||||
match req.Recurrence 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.Recurrence with Immediate -> _disabled | _ -> () ] [
|
||||
option [ _value "Hours"; match req.Recurrence with Hours _ -> _selected | _ -> () ] [
|
||||
str "hours"
|
||||
]
|
||||
option [ _value "Days"; match req.Recurrence with Days _ -> _selected | _ -> () ] [
|
||||
str "days"
|
||||
]
|
||||
option [ _value "Weeks"; match req.Recurrence with Weeks _ -> _selected | _ -> () ] [
|
||||
str "weeks"
|
||||
]
|
||||
]
|
||||
label [ _form "recurInterval" ] [ str "Interval" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "text-end pt-3" ] [
|
||||
button [ _class "btn btn-primary me-2"; _type "submit" ] [ icon "save"; str " Save" ]
|
||||
pageLink cancelLink [ _class "btn btn-secondary ms-2" ] [ icon "arrow_back"; str " Cancel" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Display a list of notes for a request
|
||||
let notes now tz notes =
|
||||
let toItem (note : Note) =
|
||||
p [] [ small [ _class "text-muted" ] [ relativeDate note.AsOf now tz ]; br []; str note.Notes ]
|
||||
[ p [ _class "text-center" ] [ strong [] [ str "Prior Notes for This Request" ] ]
|
||||
match notes with
|
||||
| [] -> p [ _class "text-center text-muted" ] [ str "There are no prior notes for this request" ]
|
||||
| _ -> yield! notes |> List.map toItem
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,104 +0,0 @@
|
||||
"use strict"
|
||||
|
||||
/** myPrayerJournal script */
|
||||
this.mpj = {
|
||||
/**
|
||||
* 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 === "error" ? "danger" : 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()
|
||||
},
|
||||
/**
|
||||
* Load local version of Bootstrap CSS if the CDN load failed
|
||||
*/
|
||||
ensureCss () {
|
||||
let loaded = false
|
||||
for (let i = 0; !loaded && i < document.styleSheets.length; i++) {
|
||||
loaded = document.styleSheets[i].href.endsWith("bootstrap.min.css")
|
||||
}
|
||||
if (!loaded) {
|
||||
const css = document.createElement("link")
|
||||
css.rel = "stylesheet"
|
||||
css.href = "/style/bootstrap.min.css"
|
||||
document.getElementsByTagName("head")[0].appendChild(css)
|
||||
}
|
||||
},
|
||||
/** 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)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The time zone of the current browser
|
||||
* @type {string}
|
||||
**/
|
||||
timeZone: undefined,
|
||||
/**
|
||||
* Derive the time zone from the current browser
|
||||
*/
|
||||
deriveTimeZone () {
|
||||
try {
|
||||
this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone
|
||||
} catch (_) { }
|
||||
}
|
||||
}
|
||||
|
||||
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
||||
// Show a message if there was one in the response
|
||||
if (hdrs.indexOf("x-toast") >= 0) {
|
||||
mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
|
||||
}
|
||||
// Hide a modal window if requested
|
||||
if (hdrs.indexOf("x-hide-modal") >= 0) {
|
||||
document.getElementById(evt.detail.xhr.getResponseHeader("x-hide-modal") + "Dismiss").click()
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on("htmx:configRequest", function (evt) {
|
||||
// Send the user's current time zone so that we can display local time
|
||||
if (mpj.timeZone) {
|
||||
evt.detail.headers["X-Time-Zone"] = mpj.timeZone
|
||||
}
|
||||
})
|
||||
|
||||
mpj.deriveTimeZone()
|
||||
File diff suppressed because one or more lines are too long
@@ -1,57 +0,0 @@
|
||||
|
||||
nav {
|
||||
background-color: green;
|
||||
}
|
||||
nav .m {
|
||||
font-weight: 100;
|
||||
}
|
||||
nav .p {
|
||||
font-weight: 400;
|
||||
}
|
||||
nav .j {
|
||||
font-weight: 700;
|
||||
}
|
||||
.nav-item a:link,
|
||||
.nav-item a:visited {
|
||||
padding: .5rem 1rem;
|
||||
margin: 0 .5rem;
|
||||
border-radius: .5rem;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-item a:hover {
|
||||
cursor: pointer;
|
||||
background-color: rgba(255, 255, 255, .2);
|
||||
}
|
||||
.nav-item a.is-active-route {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: solid 4px rgba(255, 255, 255, .3);
|
||||
}
|
||||
|
||||
form {
|
||||
max-width: 60rem;
|
||||
margin: auto;
|
||||
}
|
||||
.action-cell .material-icons {
|
||||
font-size: 1.1rem ;
|
||||
}
|
||||
.material-icons {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
#toastHost {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
.request-text {
|
||||
white-space: pre-line
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: solid 1px lightgray;
|
||||
margin: 1rem -1rem 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
Reference in New Issue
Block a user