From b3f62c25861dcbe50b1d91466a797fa5fbc2ed19 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 6 Jun 2024 22:49:57 -0400 Subject: [PATCH 1/4] WIP on update to .NET 8 (#75) --- src/MyPrayerJournal.sln | 6 --- src/MyPrayerJournal/Data.fs | 47 +++++++++++----------- src/MyPrayerJournal/Domain.fs | 4 +- src/MyPrayerJournal/Handlers.fs | 46 ++++++++++----------- src/MyPrayerJournal/MyPrayerJournal.fsproj | 15 ++++--- src/MyPrayerJournal/Program.fs | 30 +++++++------- src/MyPrayerJournal/Views/Helpers.fs | 2 +- src/MyPrayerJournal/Views/Request.fs | 12 +++--- 8 files changed, 77 insertions(+), 85 deletions(-) diff --git a/src/MyPrayerJournal.sln b/src/MyPrayerJournal.sln index bac65a6..f535d6b 100644 --- a/src/MyPrayerJournal.sln +++ b/src/MyPrayerJournal.sln @@ -5,8 +5,6 @@ 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 -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal.ToPostgres", "MyPrayerJournal.ToPostgres\MyPrayerJournal.ToPostgres.fsproj", "{3114B8F4-E388-4804-94D3-A2F4D42797C6}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -24,9 +22,5 @@ Global {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 - {3114B8F4-E388-4804-94D3-A2F4D42797C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3114B8F4-E388-4804-94D3-A2F4D42797C6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3114B8F4-E388-4804-94D3-A2F4D42797C6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3114B8F4-E388-4804-94D3-A2F4D42797C6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/MyPrayerJournal/Data.fs b/src/MyPrayerJournal/Data.fs index f007339..92ccd87 100644 --- a/src/MyPrayerJournal/Data.fs +++ b/src/MyPrayerJournal/Data.fs @@ -15,24 +15,24 @@ 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> () + type WrappedJsonConverter<'T>(wrap : string -> 'T, unwrap : 'T -> string) = + inherit JsonConverter<'T>() override _.Read(reader, _, _) = - wrap (reader.GetString ()) + wrap (reader.GetString()) override _.Write(writer, value, _) = - writer.WriteStringValue (unwrap 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 () + 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 @@ -62,12 +62,12 @@ module Connection = /// Set up the data environment let setUp (cfg : IConfiguration) = backgroundTask { let builder = NpgsqlDataSourceBuilder (cfg.GetConnectionString "mpj") - let _ = builder.UseNodaTime () - Configuration.useDataSource (builder.Build ()) + let _ = builder.UseNodaTime() + Configuration.useDataSource (builder.Build()) 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) + member _.Serialize<'T>(it : 'T) = JsonSerializer.Serialize(it, Json.options) + member _.Deserialize<'T>(it : string) = JsonSerializer.Deserialize<'T>(it, Json.options) } do! ensureDb () } @@ -80,9 +80,8 @@ module Request = open NodaTime /// Add a request - let add req = backgroundTask { - do! insert Table.Request (RequestId.toString req.Id) req - } + let add req = + insert Table.Request req /// Does a request exist for the given request ID and user ID? let existsById (reqId : RequestId) (userId : UserId) = @@ -100,7 +99,7 @@ module Request = let dbId = RequestId.toString reqId match! existsById reqId userId with | true -> do! Update.partialById Table.Request dbId {| Recurrence = recurType |} - | false -> invalidOp "Request ID {dbId} not found" + | false -> invalidOp $"Request ID {dbId} not found" } /// Update the show-after time for a request @@ -108,7 +107,7 @@ module Request = let dbId = RequestId.toString reqId match! existsById reqId userId with | true -> do! Update.partialById Table.Request dbId {| ShowAfter = showAfter |} - | false -> invalidOp "Request ID {dbId} not found" + | false -> invalidOp $"Request ID {dbId} not found" } /// Update the snoozed and show-after values for a request @@ -116,7 +115,7 @@ module Request = let dbId = RequestId.toString reqId match! existsById reqId userId with | true -> do! Update.partialById Table.Request dbId {| SnoozedUntil = until; ShowAfter = until |} - | false -> invalidOp "Request ID {dbId} not found" + | false -> invalidOp $"Request ID {dbId} not found" } @@ -130,7 +129,7 @@ module History = match! Request.tryById reqId userId with | Some req -> do! Update.partialById Table.Request dbId - {| History = (hist :: req.History) |> List.sortByDescending (fun it -> it.AsOf) |} + {| History = (hist :: req.History) |> List.sortByDescending (_.AsOf) |} | None -> invalidOp $"Request ID {dbId} not found" } @@ -152,7 +151,7 @@ module Journal = |> Seq.ofList |> Seq.map JournalRequest.ofRequestLite |> Seq.filter (fun it -> it.LastStatus = Answered) - |> Seq.sortByDescending (fun it -> it.AsOf) + |> Seq.sortByDescending (_.AsOf) |> List.ofSeq } @@ -169,7 +168,7 @@ module Journal = |> Seq.ofList |> Seq.map JournalRequest.ofRequestLite |> Seq.filter (fun it -> it.LastStatus <> Answered) - |> Seq.sortBy (fun it -> it.AsOf) + |> Seq.sortBy (_.AsOf) |> List.ofSeq } @@ -195,7 +194,7 @@ module Note = match! Request.tryById reqId userId with | Some req -> do! Update.partialById Table.Request dbId - {| Notes = (note :: req.Notes) |> List.sortByDescending (fun it -> it.AsOf) |} + {| Notes = (note :: req.Notes) |> List.sortByDescending (_.AsOf) |} | None -> invalidOp $"Request ID {dbId} not found" } diff --git a/src/MyPrayerJournal/Domain.fs b/src/MyPrayerJournal/Domain.fs index de688ed..445aeb0 100644 --- a/src/MyPrayerJournal/Domain.fs +++ b/src/MyPrayerJournal/Domain.fs @@ -244,14 +244,14 @@ module JournalRequest = // 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 (fun it -> it.AsOf) |> Option.defaultValue Instant.MinValue + 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 (fun it -> it.AsOf) + |> Option.map (_.AsOf) |> Option.defaultValue Instant.MinValue let asOf = List.max [ lastPrayed; showAfter; snoozedUntil ] { RequestId = req.Id diff --git a/src/MyPrayerJournal/Handlers.fs b/src/MyPrayerJournal/Handlers.fs index 367b042..4816900 100644 --- a/src/MyPrayerJournal/Handlers.fs +++ b/src/MyPrayerJournal/Handlers.fs @@ -16,7 +16,7 @@ module private LogOnHelpers = let logOn url : HttpHandler = fun next ctx -> task { match url with | Some it -> - do! ctx.ChallengeAsync ("Auth0", AuthenticationProperties (RedirectUri = it)) + do! ctx.ChallengeAsync("Auth0", AuthenticationProperties(RedirectUri = it)) return! next ctx | None -> return! challenge "Auth0" next ctx } @@ -57,14 +57,14 @@ type HttpContext with |> Option.ofObj |> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier)) |> Option.flatten - |> Option.map (fun claim -> claim.Value) + |> 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 () + member this.Clock = this.GetService() /// Get the current instant from the system clock member this.Now = this.Clock.GetCurrentInstant @@ -94,7 +94,7 @@ module private Helpers = /// Debug logger let debug (ctx : HttpContext) message = - let fac = ctx.GetService () + let fac = ctx.GetService() let log = fac.CreateLogger "Debug" log.LogInformation message @@ -115,7 +115,7 @@ module private Helpers = let renderComponent nodes : HttpHandler = noResponseCaching >=> fun _ ctx -> backgroundTask { - return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes) + return! ctx.WriteHtmlStringAsync(ViewEngine.RenderView.AsString.htmlNodes nodes) } open Views.Layout @@ -125,7 +125,7 @@ module private Helpers = let pageContext (ctx : HttpContext) pageTitle content = backgroundTask { let! hasSnoozed = match ctx.CurrentUser with - | Some _ -> Journal.hasSnoozed ctx.UserId (ctx.Now ()) + | Some _ -> Journal.hasSnoozed ctx.UserId (ctx.Now()) | None -> Task.FromResult false return { IsAuthenticated = Option.isSome ctx.CurrentUser @@ -153,7 +153,7 @@ module private Helpers = /// Push a new message into the list let push (ctx : HttpContext) message url = lock upd8 (fun () -> - messages <- messages.Add (ctx.UserId, (message, url))) + messages <- messages.Add(ctx.UserId, (message, url))) /// Add a success message header to the response let pushSuccess ctx message url = @@ -259,7 +259,7 @@ module Components = // 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 + | Some req -> return! renderComponent [ Views.Request.reqListItem (ctx.Now()) ctx.TimeZone req ] next ctx | None -> return! Error.notFound next ctx } @@ -270,7 +270,7 @@ module Components = // 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 + return! renderComponent (Views.Request.notes (ctx.Now()) ctx.TimeZone notes) next ctx } // GET /components/request/[req-id]/snooze @@ -294,7 +294,7 @@ module Journal = let usr = ctx.User.Claims |> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName) - |> Option.map (fun c -> c.Value) + |> 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 @@ -362,8 +362,8 @@ module Request = let reqId = RequestId.ofString requestId match! Request.existsById reqId userId with | true -> - let! notes = ctx.BindFormAsync () - do! Note.add reqId userId { AsOf = ctx.Now (); Notes = notes.notes } + let! notes = ctx.BindFormAsync() + 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 } @@ -371,13 +371,13 @@ module Request = // 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 + 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 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 @@ -386,7 +386,7 @@ module Request = // 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 + return! partial "Answered Requests" (Views.Request.answered (ctx.Now()) ctx.TimeZone reqs) next ctx } // GET /request/[req-id]/full @@ -413,11 +413,11 @@ module Request = let reqId = RequestId.ofString requestId match! Request.existsById reqId userId with | true -> - let! until = ctx.BindFormAsync () + let! until = ctx.BindFormAsync() let date = LocalDatePattern.CreateWithInvariantCulture("yyyy-MM-dd").Parse(until.until).Value .AtStartOfDayInZone(DateTimeZone.Utc) - .ToInstant () + .ToInstant() do! Request.updateSnoozed reqId userId (Some date) return! (withSuccessMessage $"Request snoozed until {until.until}" @@ -444,9 +444,9 @@ module Request = // POST /request let add : HttpHandler = requireUser >=> fun next ctx -> task { - let! form = ctx.BindModelAsync () + let! form = ctx.BindModelAsync() let userId = ctx.UserId - let now = ctx.Now () + let now = ctx.Now() let req = { Request.empty with Id = Cuid.generate () |> RequestId @@ -468,7 +468,7 @@ module Request = // PATCH /request let update : HttpHandler = requireUser >=> fun next ctx -> task { - let! form = ctx.BindModelAsync () + let! form = ctx.BindModelAsync() 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 @@ -483,10 +483,10 @@ module Request = | Immediate -> do! Request.updateShowAfter req.RequestId userId None | _ -> () // append history - let upd8Text = form.requestText.Trim () + 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 } + { AsOf = ctx.Now(); Status = (Option.get >> RequestAction.ofString) form.status; Text = text } let nextUrl = match form.returnTo with | "active" -> "/requests/active" @@ -510,7 +510,7 @@ module User = // GET /user/log-off let logOff : HttpHandler = requireUser >=> fun next ctx -> task { - do! ctx.SignOutAsync ("Auth0", AuthenticationProperties (RedirectUri = "/")) + do! ctx.SignOutAsync("Auth0", AuthenticationProperties (RedirectUri = "/")) do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme return! next ctx } diff --git a/src/MyPrayerJournal/MyPrayerJournal.fsproj b/src/MyPrayerJournal/MyPrayerJournal.fsproj index 4ab242d..6f2dbd1 100644 --- a/src/MyPrayerJournal/MyPrayerJournal.fsproj +++ b/src/MyPrayerJournal/MyPrayerJournal.fsproj @@ -1,7 +1,7 @@ - net7.0 - 3.3 + net8.0 + 3.4 embedded false false @@ -20,16 +20,15 @@ - + - - - + + + - - + diff --git a/src/MyPrayerJournal/Program.fs b/src/MyPrayerJournal/Program.fs index 38e46e0..612e35e 100644 --- a/src/MyPrayerJournal/Program.fs +++ b/src/MyPrayerJournal/Program.fs @@ -31,10 +31,10 @@ let main args = let builder = WebApplication.CreateBuilder args let _ = builder.Configuration.AddEnvironmentVariables "MPJ_" let svc = builder.Services - let cfg = svc.BuildServiceProvider().GetRequiredService () + let cfg = svc.BuildServiceProvider().GetRequiredService() - let _ = svc.AddRouting () - let _ = svc.AddGiraffe () + let _ = svc.AddRouting() + let _ = svc.AddGiraffe() let _ = svc.AddSingleton SystemClock.Instance let _ = svc.AddSingleton DateTimeZoneProviders.Tzdb let _ = svc.Configure(fun (opts : ForwardedHeadersOptions) -> @@ -59,7 +59,7 @@ let main args = opts.ClientSecret <- auth0["Secret"] opts.ResponseType <- OpenIdConnectResponseType.Code - opts.Scope.Clear () + opts.Scope.Clear() opts.Scope.Add "openid" opts.Scope.Add "profile" @@ -67,7 +67,7 @@ let main args = opts.ClaimsIssuer <- "Auth0" opts.SaveTokens <- true - opts.Events <- OpenIdConnectEvents () + opts.Events <- OpenIdConnectEvents() opts.Events.OnRedirectToIdentityProviderForSignOut <- fun ctx -> let returnTo = match ctx.Properties.RedirectUri with @@ -82,7 +82,7 @@ let main args = | false -> redirUri Uri.EscapeDataString $"&returnTo={finalRedirUri}" ctx.Response.Redirect $"""https://{auth0["Domain"]}/v2/logout?client_id={auth0["Id"]}{returnTo}""" - ctx.HandleResponse () + ctx.HandleResponse() Task.CompletedTask opts.Events.OnRedirectToIdentityProvider <- fun ctx -> let uri = UriBuilder ctx.ProtocolMessage.RedirectUri @@ -92,20 +92,20 @@ let main args = Task.CompletedTask) let _ = svc.AddSingleton Json.options - let _ = svc.AddSingleton (SystemTextJson.Serializer Json.options) + let _ = svc.AddSingleton(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 + 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 () + 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) + let _ = app.UseEndpoints(fun e -> e.MapGiraffeEndpoints Handlers.routes) - app.Run () + app.Run() 0 diff --git a/src/MyPrayerJournal/Views/Helpers.fs b/src/MyPrayerJournal/Views/Helpers.fs index b950342..63ee010 100644 --- a/src/MyPrayerJournal/Views/Helpers.fs +++ b/src/MyPrayerJournal/Views/Helpers.fs @@ -29,7 +29,7 @@ let noResults heading link buttonText text = /// 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 ] + span [ _title (date.InZone(tz).ToDateTimeOffset().ToString("f", null)) ] [ Dates.formatDistance now date |> str ] /// The version of myPrayerJournal let version = diff --git a/src/MyPrayerJournal/Views/Request.fs b/src/MyPrayerJournal/Views/Request.fs index e465f28..c78a6e5 100644 --- a/src/MyPrayerJournal/Views/Request.fs +++ b/src/MyPrayerJournal/Views/Request.fs @@ -74,13 +74,13 @@ let snoozed now tz reqs = /// View for Full Request page let full (clock : IClock) tz (req : Request) = - let now = clock.GetCurrentInstant () + let now = clock.GetCurrentInstant() let answered = req.History |> Seq.ofList |> Seq.filter History.isAnswered |> Seq.tryHead - |> Option.map (fun x -> x.AsOf) + |> Option.map (_.AsOf) let prayed = (req.History |> List.filter History.isPrayed |> List.length).ToString "N0" let daysOpen = let asOf = defaultArg answered now @@ -89,7 +89,7 @@ let full (clock : IClock) tz (req : Request) = req.History |> Seq.ofList |> Seq.filter (fun h -> Option.isSome h.Text) - |> Seq.sortByDescending (fun h -> h.AsOf) + |> 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) @@ -100,7 +100,7 @@ let full (clock : IClock) tz (req : Request) = |> Seq.ofList |> Seq.map (fun n -> {| asOf = n.AsOf; text = Some n.Notes; status = "Notes" |}) |> Seq.append (req.History |> List.map toDisp) - |> Seq.sortByDescending (fun it -> it.asOf) + |> 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 @@ -112,7 +112,7 @@ let full (clock : IClock) tz (req : Request) = match answered with | Some date -> str "Answered " - date.ToDateTimeOffset().ToString ("D", null) |> str + date.ToDateTimeOffset().ToString("D", null) |> str str " (" relativeDate date now tz rawText ") • " @@ -127,7 +127,7 @@ let full (clock : IClock) tz (req : Request) = p [ _class "m-0" ] [ str it.status rawText "  " - small [] [ em [] [ it.asOf.ToDateTimeOffset().ToString ("D", null) |> str ] ] + small [] [ em [] [ it.asOf.ToDateTimeOffset().ToString("D", null) |> str ] ] ] match it.text with | Some txt -> p [ _class "mt-2 mb-0" ] [ str txt ] -- 2.45.1 From b07532ab508f0c550713129a1c07100b8bd84954 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 6 Jun 2024 23:07:57 -0400 Subject: [PATCH 2/4] Update Dockerfile to .NET 8 (#75) - Update deps - Implement newer doc library --- src/Dockerfile | 8 ++-- src/MyPrayerJournal/Data.fs | 45 ++++++++++------------ src/MyPrayerJournal/MyPrayerJournal.fsproj | 17 ++++---- 3 files changed, 34 insertions(+), 36 deletions(-) diff --git a/src/Dockerfile b/src/Dockerfile index 1a5a49a..0dc5c2b 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,17 +1,17 @@ -FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build WORKDIR /mpj COPY ./MyPrayerJournal/MyPrayerJournal.fsproj ./ RUN dotnet restore COPY ./MyPrayerJournal ./ RUN dotnet publish -c Release -r linux-x64 -RUN rm bin/Release/net7.0/linux-x64/publish/appsettings.*.json +RUN rm bin/Release/net8.0/linux-x64/publish/appsettings.*.json -FROM mcr.microsoft.com/dotnet/aspnet:7.0-alpine as final +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine as final WORKDIR /app RUN apk add --no-cache icu-libs ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false -COPY --from=build /mpj/bin/Release/net7.0/linux-x64/publish/ ./ +COPY --from=build /mpj/bin/Release/net8.0/linux-x64/publish/ ./ EXPOSE 80 CMD [ "dotnet", "/app/MyPrayerJournal.dll" ] \ No newline at end of file diff --git a/src/MyPrayerJournal/Data.fs b/src/MyPrayerJournal/Data.fs index 92ccd87..0c072cf 100644 --- a/src/MyPrayerJournal/Data.fs +++ b/src/MyPrayerJournal/Data.fs @@ -28,12 +28,11 @@ module Json = /// 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() - ] + [ 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 @@ -41,13 +40,13 @@ module Json = opts -open BitBadger.Npgsql.FSharp.Documents +open BitBadger.Documents.Postgres /// Connection [] module Connection = - open BitBadger.Npgsql.Documents + open BitBadger.Documents open Microsoft.Extensions.Configuration open Npgsql open System.Text.Json @@ -55,8 +54,8 @@ module Connection = /// 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.ensureIndex Table.Request Optimized + do! Definition.ensureTable Table.Request + do! Definition.ensureDocumentIndex Table.Request Optimized } /// Set up the data environment @@ -89,7 +88,7 @@ module Request = /// Retrieve a request by its ID and user ID let tryById reqId userId = backgroundTask { - match! Find.byId Table.Request (RequestId.toString reqId) with + match! Find.byId Table.Request (RequestId.toString reqId) with | Some req when req.UserId = userId -> return Some req | _ -> return None } @@ -98,7 +97,7 @@ module Request = let updateRecurrence reqId userId (recurType : Recurrence) = backgroundTask { let dbId = RequestId.toString reqId match! existsById reqId userId with - | true -> do! Update.partialById Table.Request dbId {| Recurrence = recurType |} + | true -> do! Patch.byId Table.Request dbId {| Recurrence = recurType |} | false -> invalidOp $"Request ID {dbId} not found" } @@ -106,7 +105,7 @@ module Request = let updateShowAfter reqId userId (showAfter : Instant option) = backgroundTask { let dbId = RequestId.toString reqId match! existsById reqId userId with - | true -> do! Update.partialById Table.Request dbId {| ShowAfter = showAfter |} + | true -> do! Patch.byId Table.Request dbId {| ShowAfter = showAfter |} | false -> invalidOp $"Request ID {dbId} not found" } @@ -114,7 +113,7 @@ module Request = let updateSnoozed reqId userId (until : Instant option) = backgroundTask { let dbId = RequestId.toString reqId match! existsById reqId userId with - | true -> do! Update.partialById Table.Request dbId {| SnoozedUntil = until; ShowAfter = until |} + | true -> do! Patch.byId Table.Request dbId {| SnoozedUntil = until; ShowAfter = until |} | false -> invalidOp $"Request ID {dbId} not found" } @@ -128,8 +127,7 @@ module History = let dbId = RequestId.toString reqId match! Request.tryById reqId userId with | Some req -> - do! Update.partialById Table.Request dbId - {| History = (hist :: req.History) |> List.sortByDescending (_.AsOf) |} + do! Patch.byId Table.Request dbId {| History = (hist :: req.History) |> List.sortByDescending (_.AsOf) |} | None -> invalidOp $"Request ID {dbId} not found" } @@ -143,9 +141,9 @@ module Journal = let! reqs = Custom.list $"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}""" - [ "@criteria", Query.jsonbDocParam {| UserId = userId |} - "@stat", Sql.string """$.history[0].status ? (@ == "Answered")""" - ] fromData + [ jsonParam "@criteria" {| UserId = userId |} + "@stat", Sql.string """$.history[0].status ? (@ == "Answered")""" ] + fromData return reqs |> Seq.ofList @@ -160,9 +158,9 @@ module Journal = let! reqs = Custom.list $"""{Query.Find.byContains Table.Request} AND {Query.whereJsonPathMatches "@stat"}""" - [ "@criteria", Query.jsonbDocParam {| UserId = userId |} - "@stat", Sql.string """$.history[0].status ? (@ <> "Answered")""" - ] fromData + [ jsonParam "@criteria" {| UserId = userId |} + "@stat", Sql.string """$.history[0].status ? (@ <> "Answered")""" ] + fromData return reqs |> Seq.ofList @@ -193,8 +191,7 @@ module Note = let dbId = RequestId.toString reqId match! Request.tryById reqId userId with | Some req -> - do! Update.partialById Table.Request dbId - {| Notes = (note :: req.Notes) |> List.sortByDescending (_.AsOf) |} + do! Patch.byId Table.Request dbId {| Notes = (note :: req.Notes) |> List.sortByDescending (_.AsOf) |} | None -> invalidOp $"Request ID {dbId} not found" } diff --git a/src/MyPrayerJournal/MyPrayerJournal.fsproj b/src/MyPrayerJournal/MyPrayerJournal.fsproj index 6f2dbd1..d8d415c 100644 --- a/src/MyPrayerJournal/MyPrayerJournal.fsproj +++ b/src/MyPrayerJournal/MyPrayerJournal.fsproj @@ -20,15 +20,16 @@ - - + + - - - - - - + + + + + + + -- 2.45.1 From 8ee3c6b48340a2525ab09a5f8b08c1b6123df80d Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 7 Jun 2024 12:04:00 -0400 Subject: [PATCH 3/4] Add documentation and handler (#77) --- src/Dockerfile | 2 +- src/MyPrayerJournal/Handlers.fs | 8 +- src/MyPrayerJournal/MyPrayerJournal.fsproj | 1 + src/MyPrayerJournal/Views/Docs.fs | 184 ++++++++++++++++++++ src/MyPrayerJournal/Views/Layout.fs | 189 +++++++++------------ 5 files changed, 275 insertions(+), 109 deletions(-) create mode 100644 src/MyPrayerJournal/Views/Docs.fs diff --git a/src/Dockerfile b/src/Dockerfile index 0dc5c2b..99c874e 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -5,7 +5,7 @@ RUN dotnet restore COPY ./MyPrayerJournal ./ RUN dotnet publish -c Release -r linux-x64 -RUN rm bin/Release/net8.0/linux-x64/publish/appsettings.*.json +RUN rm bin/Release/net8.0/linux-x64/publish/appsettings.*.json || true FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine as final WORKDIR /app diff --git a/src/MyPrayerJournal/Handlers.fs b/src/MyPrayerJournal/Handlers.fs index 4816900..7672e25 100644 --- a/src/MyPrayerJournal/Handlers.fs +++ b/src/MyPrayerJournal/Handlers.fs @@ -278,13 +278,16 @@ module Components = requireUser >=> renderComponent [ RequestId.ofString requestId |> Views.Journal.snooze ] -/// / URL +/// / 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 = @@ -296,7 +299,7 @@ module Journal = |> 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" + let title = usr |> match usr with "Your" -> sprintf "%s" | _ -> sprintf "%s’s" return! partial $"{title} Prayer Journal" (Views.Journal.journal usr) next ctx } @@ -530,6 +533,7 @@ let routes = [ routef "request/%s/snooze" Components.snooze ] ] + GET_HEAD [ route "/docs" Home.docs ] GET_HEAD [ route "/journal" Journal.journal ] subRoute "/legal/" [ GET_HEAD [ diff --git a/src/MyPrayerJournal/MyPrayerJournal.fsproj b/src/MyPrayerJournal/MyPrayerJournal.fsproj index d8d415c..970d550 100644 --- a/src/MyPrayerJournal/MyPrayerJournal.fsproj +++ b/src/MyPrayerJournal/MyPrayerJournal.fsproj @@ -16,6 +16,7 @@ + diff --git a/src/MyPrayerJournal/Views/Docs.fs b/src/MyPrayerJournal/Views/Docs.fs new file mode 100644 index 0000000..aa24327 --- /dev/null +++ b/src/MyPrayerJournal/Views/Docs.fs @@ -0,0 +1,184 @@ +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 + ] diff --git a/src/MyPrayerJournal/Views/Layout.fs b/src/MyPrayerJournal/Views/Layout.fs index cdd0022..fe8d4f3 100644 --- a/src/MyPrayerJournal/Views/Layout.fs +++ b/src/MyPrayerJournal/Views/Layout.fs @@ -6,48 +6,44 @@ 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 - } + { /// 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." - ] - ] + 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 { + 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 @@ -58,91 +54,72 @@ let private navBar ctx = 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" ] [ - a [ _href "https://docs.prayerjournal.me"; _target "_blank"; _rel "noopener" ] [ str "Docs" ] - ] - } - |> List.ofSeq - |> ul [ _class "navbar-nav me-auto d-flex flex-row" ] - ] - ] + 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 [] [ str ctx.PageTitle; rawText " « myPrayerJournal" ] + 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" ] - ] + 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" ] [] - ] + 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://github.com/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('