Version 3 #67
3
src/MyPrayerJournal/Server/.gitignore
vendored
3
src/MyPrayerJournal/Server/.gitignore
vendored
@ -1,6 +1,9 @@
|
||||
## LiteDB database file
|
||||
*.db
|
||||
|
||||
## Auth0 settings
|
||||
wwwroot/auth-config.json
|
||||
|
||||
## Web application compile output
|
||||
wwwroot/favicon.ico
|
||||
wwwroot/index.html
|
||||
|
@ -9,7 +9,7 @@ open Giraffe.Htmx
|
||||
open MyPrayerJournal.Data.Extensions
|
||||
|
||||
/// Send a partial result if this is not a full page load
|
||||
let partialIfNotRefresh content layout : HttpHandler =
|
||||
let partialIfNotRefresh content : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
let hdrs = Headers.fromRequest ctx
|
||||
let isHtmx =
|
||||
@ -24,7 +24,7 @@ let partialIfNotRefresh content layout : HttpHandler =
|
||||
|> function Some (HistoryRestoreRequest hist) -> hist | _ -> false
|
||||
match isHtmx && not isRefresh with
|
||||
| true -> return! ctx.WriteHtmlViewAsync content
|
||||
| false -> return! layout content |> ctx.WriteHtmlViewAsync
|
||||
| false -> return! Views.Layout.view content |> ctx.WriteHtmlViewAsync
|
||||
}
|
||||
|
||||
/// Handler to return Vue files
|
||||
@ -33,7 +33,7 @@ module Vue =
|
||||
/// The application index page
|
||||
let app : HttpHandler =
|
||||
Headers.toResponse (Trigger "menu-refresh")
|
||||
>=> partialIfNotRefresh (ViewEngine.HtmlElements.str "It works") Views.Layout.wide
|
||||
>=> partialIfNotRefresh (ViewEngine.HtmlElements.str "It works")
|
||||
|
||||
|
||||
open System
|
||||
@ -60,6 +60,8 @@ module Error =
|
||||
|
||||
open Cuid
|
||||
open LiteDB
|
||||
open System.Security.Claims
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Handler helpers
|
||||
[<AutoOpen>]
|
||||
@ -67,7 +69,6 @@ module private Helpers =
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open System.Threading.Tasks
|
||||
open System.Security.Claims
|
||||
|
||||
/// Get the LiteDB database
|
||||
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
|
||||
@ -107,6 +108,18 @@ module private Helpers =
|
||||
/// Flip JSON result so we can pipe into it
|
||||
let asJson<'T> next ctx (o : 'T) =
|
||||
json o next ctx
|
||||
|
||||
/// Trigger a menu item refresh
|
||||
let withMenuRefresh : HttpHandler =
|
||||
// let trigger = //string ctx.Request.Path |> sprintf "{ \"menu-refresh\": \"%s\" }" :> obj |> TriggerAfterSwap
|
||||
Headers.toResponse (TriggerAfterSettle "menu-refresh")
|
||||
|
||||
/// Render a component result
|
||||
let renderComponent nodes : HttpHandler =
|
||||
fun next ctx -> task {
|
||||
return! ctx.WriteHtmlStringAsync (ViewEngine.RenderView.AsString.htmlNodes nodes)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Strongly-typed models for post requests
|
||||
@ -166,13 +179,29 @@ module Components =
|
||||
Headers.fromRequest ctx
|
||||
|> List.tryFind HtmxReqHeader.isCurrentUrl
|
||||
|> function Some (CurrentUrl u) -> Some u | _ -> None
|
||||
let view = Views.Navigation.currentNav false false url |> ViewEngine.RenderView.AsString.htmlNodes
|
||||
return! ctx.WriteHtmlStringAsync view
|
||||
let isAuthorized = ctx |> (user >> Option.isSome)
|
||||
return! renderComponent (Views.Navigation.currentNav isAuthorized false url) next ctx
|
||||
}
|
||||
|
||||
// GET /components/journal-items
|
||||
let journalItems : HttpHandler =
|
||||
authorize
|
||||
>=> fun next ctx -> task {
|
||||
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
|
||||
do! System.Threading.Tasks.Task.Delay (TimeSpan.FromSeconds 5.)
|
||||
return! renderComponent [ Views.Journal.journalItems jrnl ] next ctx
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// /api/journal URLs
|
||||
/// / URL
|
||||
module Home =
|
||||
|
||||
// GET /
|
||||
let home : HttpHandler =
|
||||
withMenuRefresh >=> partialIfNotRefresh Views.Home.home
|
||||
|
||||
|
||||
/// /api/journal and /journal URLs
|
||||
module Journal =
|
||||
|
||||
/// GET /api/journal
|
||||
@ -182,6 +211,15 @@ module Journal =
|
||||
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
|
||||
return! json jrnl next ctx
|
||||
}
|
||||
|
||||
// GET /journal
|
||||
let journalPage : HttpHandler =
|
||||
authorize
|
||||
>=> withMenuRefresh
|
||||
>=> fun next ctx -> task {
|
||||
let usr = ctx.Request.Headers.["X-Given-Name"].[0]
|
||||
return! partialIfNotRefresh (Views.Journal.journal usr) next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Legalese
|
||||
@ -189,10 +227,10 @@ module Legal =
|
||||
|
||||
// GET /legal/privacy-policy
|
||||
let privacyPolicy : HttpHandler =
|
||||
partialIfNotRefresh Views.Legal.privacyPolicy Views.Layout.standard
|
||||
withMenuRefresh >=> partialIfNotRefresh Views.Legal.privacyPolicy
|
||||
|
||||
let termsOfService : HttpHandler =
|
||||
partialIfNotRefresh Views.Legal.termsOfService Views.Layout.standard
|
||||
withMenuRefresh >=> partialIfNotRefresh Views.Legal.termsOfService
|
||||
|
||||
|
||||
/// /api/request URLs
|
||||
@ -362,10 +400,12 @@ open Giraffe.EndpointRouting
|
||||
|
||||
/// The routes for myPrayerJournal
|
||||
let routes =
|
||||
[ route "/" Vue.app
|
||||
[ route "/" Home.home
|
||||
subRoute "/components/" [
|
||||
route "nav-items" Components.navItems
|
||||
route "journal-items" Components.journalItems
|
||||
route "nav-items" Components.navItems
|
||||
]
|
||||
route "/journal" Journal.journalPage
|
||||
subRoute "/legal/" [
|
||||
route "privacy-policy" Legal.privacyPolicy
|
||||
route "terms-of-service" Legal.termsOfService
|
||||
|
@ -76,7 +76,7 @@ module Configure =
|
||||
fun opts ->
|
||||
let jwtCfg = bldr.Configuration.GetSection "Auth0"
|
||||
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
|
||||
opts.Audience <- jwtCfg.["Id"])
|
||||
opts.Audience <- jwtCfg.["Audience"])
|
||||
|> ignore
|
||||
let jsonOptions = JsonSerializerOptions ()
|
||||
jsonOptions.Converters.Add (JsonFSharpConverter ())
|
||||
|
@ -4,13 +4,33 @@ open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open System
|
||||
|
||||
/// Target the `main` tag with boosted links
|
||||
let toMain = _hxTarget "main"
|
||||
|
||||
/// View for home page
|
||||
module Home =
|
||||
|
||||
/// 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 prayed for "
|
||||
str "them, update them as God moves in the situation, and record a final answer received on that request. It "
|
||||
str "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 also "
|
||||
rawText "learn more about the site at the “Docs” link, also above."
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Views for legal pages
|
||||
module Legal =
|
||||
|
||||
/// View for the "Privacy Policy" page
|
||||
let privacyPolicy = article [] [
|
||||
let privacyPolicy = article [ _class "container mt-3" ] [
|
||||
div [ _class "card" ] [
|
||||
h5 [ _class "card-header" ] [ str "Privacy Policy" ]
|
||||
div [ _class "card-body" ] [
|
||||
@ -92,7 +112,7 @@ module Legal =
|
||||
]
|
||||
|
||||
/// View for the "Terms of Service" page
|
||||
let termsOfService = article [ _class "container" ] [
|
||||
let termsOfService = article [ _class "container mt-3" ] [
|
||||
div [ _class "card" ] [
|
||||
h5 [ _class "card-header" ] [ str "Terms of Service" ]
|
||||
div [ _class "card-body" ] [
|
||||
@ -144,14 +164,15 @@ module Legal =
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Views for navigation support
|
||||
module Navigation =
|
||||
|
||||
/// The default navigation bar, which will load the items on page load, and whenever a refresh event occurs
|
||||
let navBar =
|
||||
nav [ _class "navbar navbar-dark"; _hxBoost; toMain ] [
|
||||
nav [ _class "navbar navbar-dark" ] [
|
||||
div [ _class "container-fluid" ] [
|
||||
a [ _href "/"; _class "navbar-brand" ] [
|
||||
a [ _href "/"; _class "navbar-brand"; _hxBoost; toMain ] [
|
||||
span [ _class "m" ] [ str "my" ]
|
||||
span [ _class "p" ] [ str "Prayer" ]
|
||||
span [ _class "j" ] [ str "Journal" ]
|
||||
@ -171,23 +192,58 @@ module Navigation =
|
||||
match isAuthenticated with
|
||||
| true ->
|
||||
let currUrl = match url with Some u -> (u.PathAndQuery.Split '?').[0] | None -> ""
|
||||
let deriveClass (matchUrl : string) css =
|
||||
match currUrl.StartsWith matchUrl with
|
||||
| true -> sprintf "%s is-active-route" css
|
||||
| false -> css
|
||||
|> _class
|
||||
li [ deriveClass "/journal" "nav-item" ] [ a [ _href "/journal" ] [ str "Journal" ] ]
|
||||
li [ deriveClass "/requests/active" "nav-item" ] [ a [ _href "/requests/active" ] [ str "Active" ] ]
|
||||
if hasSnoozed then
|
||||
li [ deriveClass "/requests/snoozed" "nav-item" ] [ a [ _href "/requests/snoozed" ] [ str "Snoozed" ] ]
|
||||
li [ deriveClass "/requests/answered" "nav-item" ] [ a [ _href "/requests/answered" ] [ str "Answered" ] ]
|
||||
li [ _class "nav-item" ] [ a [ _href "/user/log-off"; _onclick "logOff()" ] [ str "Log Off" ] ]
|
||||
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on"; _onclick "logOn()"] [ str "Log On" ] ]
|
||||
let attrs (matchUrl : string) =
|
||||
[ _href matchUrl
|
||||
match currUrl.StartsWith matchUrl with
|
||||
| true -> _class "is-active-route"
|
||||
| false -> ()
|
||||
_hxBoost; toMain
|
||||
]
|
||||
li [ _class "nav-item" ] [ a (attrs "/journal") [ str "Journal" ] ]
|
||||
li [ _class "nav-item" ] [ a (attrs "/requests/active") [ str "Active" ] ]
|
||||
if hasSnoozed then li [ _class "nav-item" ] [ a (attrs "/requests/snoozed") [ str "Snoozed" ] ]
|
||||
li [ _class "nav-item" ] [ a (attrs "/requests/answered") [ str "Answered" ] ]
|
||||
li [ _class "nav-item" ] [ a [ _href "/user/log-off"; _onclick "mpj.logOff(event)" ] [ str "Log Off" ] ]
|
||||
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on"; _onclick "mpj.logOn(event)"] [ str "Log On" ] ]
|
||||
li [ _class "nav-item" ] [ a [ _href "https://docs.prayerjournal.me"; _target "_blank" ] [ str "Docs" ] ]
|
||||
}
|
||||
|> List.ofSeq
|
||||
|
||||
|
||||
/// Views for journal pages and components
|
||||
module Journal =
|
||||
|
||||
/// The journal loading page
|
||||
let journal user = article [ _class "container-fluid mt-3" ] [
|
||||
h2 [ _class "pb-3" ] [ str user; rawText "’s Prayer Journal" ]
|
||||
p [
|
||||
_hxGet "/components/journal-items"
|
||||
_hxSwap HxSwap.OuterHtml
|
||||
_hxTrigger HxTrigger.Load
|
||||
] [ rawText "Loading your prayer journal…" ]
|
||||
]
|
||||
|
||||
/// The journal items
|
||||
let journalItems items =
|
||||
match items |> List.isEmpty with
|
||||
| true ->
|
||||
div [ _class "card no-requests" ] [
|
||||
h5 [ _class "card-header"] [ str "No Active Requests" ]
|
||||
div [ _class "card-body text-center" ] [
|
||||
p [ _class "card-text" ] [
|
||||
rawText "You have no requests to be shown; see the “Active” link above for snoozed or "
|
||||
rawText "deferred requests, and the “Answered” link for answered requests"
|
||||
]
|
||||
a [
|
||||
_class "btn btn-primary"
|
||||
_href "/request/new/edit"
|
||||
_hxBoost; toMain
|
||||
] [ str "Add a Request" ]
|
||||
]
|
||||
]
|
||||
| false -> p [] [ str "There are requests" ]
|
||||
|
||||
|
||||
/// Layout views
|
||||
module Layout =
|
||||
|
||||
@ -199,7 +255,7 @@ module Layout =
|
||||
_integrity "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
|
||||
_crossorigin "anonymous"
|
||||
]
|
||||
link [ _href "/css/style.css"; _rel "stylesheet" ]
|
||||
link [ _href "/style/style.css"; _rel "stylesheet" ]
|
||||
script [
|
||||
_src "https://unpkg.com/htmx.org@1.5.0"
|
||||
_integrity "sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
|
||||
@ -225,25 +281,23 @@ module Layout =
|
||||
]
|
||||
]
|
||||
script [
|
||||
_async
|
||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
_integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
_crossorigin "anonymous"
|
||||
] []
|
||||
script [ _src "https://cdn.auth0.com/js/auth0-spa-js/1.13/auth0-spa-js.production.js" ] []
|
||||
script [ _src "/script/mpj.js" ] []
|
||||
]
|
||||
|
||||
let full content =
|
||||
/// Create the full view of the page
|
||||
let view content =
|
||||
html [ _lang "en" ] [
|
||||
htmlHead
|
||||
body [] [
|
||||
body [ _hxHeaders "" ] [
|
||||
Navigation.navBar
|
||||
content
|
||||
main [] [ content ]
|
||||
htmlFoot
|
||||
]
|
||||
]
|
||||
|
||||
let standard content =
|
||||
main [ _class "container" ] [ content ] |> full
|
||||
|
||||
let wide content =
|
||||
main [ _class "container-fluid" ] [ content ] |> full
|
||||
|
||||
|
59
src/MyPrayerJournal/Server/wwwroot/script/mpj.js
Normal file
59
src/MyPrayerJournal/Server/wwwroot/script/mpj.js
Normal file
@ -0,0 +1,59 @@
|
||||
"use strict"
|
||||
|
||||
/** myPrayerJournal script */
|
||||
const mpj = {
|
||||
/** Auth0 configuration */
|
||||
auth: {
|
||||
/** The Auth0 client */
|
||||
auth0: null,
|
||||
/** Configure the Auth0 client */
|
||||
configureClient: async () => {
|
||||
const response = await fetch("/auth-config.json")
|
||||
const config = await response.json()
|
||||
mpj.auth.auth0 = await createAuth0Client({
|
||||
domain: config.domain,
|
||||
client_id: config.clientId,
|
||||
audience: config.audience
|
||||
})
|
||||
}
|
||||
},
|
||||
/** Whether the user is currently authenticated */
|
||||
isAuthenticated: false,
|
||||
/** Process a log on request */
|
||||
logOn: async (e) => {
|
||||
e.preventDefault()
|
||||
await mpj.auth.auth0.loginWithRedirect({ redirect_uri: window.location.origin })
|
||||
},
|
||||
/** Log the user off */
|
||||
logOff: (e) => {
|
||||
e.preventDefault()
|
||||
mpj.auth.auth0.logout({ returnTo: window.location.origin })
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = async () => {
|
||||
/** If the user is authenticated, set the JWT on the `body` tag */
|
||||
const establishAuth = async () => {
|
||||
mpj.isAuthenticated = await mpj.auth.auth0.isAuthenticated()
|
||||
if (mpj.isAuthenticated) {
|
||||
const token = await mpj.auth.auth0.getTokenSilently()
|
||||
const user = await mpj.auth.auth0.getUser()
|
||||
document.querySelector("body")
|
||||
.setAttribute("hx-headers", `{ "Authorization": "Bearer ${token}", "X-Given-Name": "${user.given_name}" }`)
|
||||
htmx.trigger(htmx.find(".navbar-nav"), "menu-refresh")
|
||||
}
|
||||
}
|
||||
|
||||
// Set up Auth0
|
||||
await mpj.auth.configureClient()
|
||||
await establishAuth()
|
||||
if (mpj.isAuthenticated) return
|
||||
|
||||
// Handle log on code, if present
|
||||
const query = window.location.search
|
||||
if (query.includes("code=") && query.includes("state=")) {
|
||||
await mpj.auth.auth0.handleRedirectCallback()
|
||||
await establishAuth()
|
||||
window.history.replaceState({}, document.title, "/")
|
||||
}
|
||||
}
|
37
src/MyPrayerJournal/Server/wwwroot/style/style.css
Normal file
37
src/MyPrayerJournal/Server/wwwroot/style/style.css
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
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);
|
||||
}
|
||||
.navbar-nav .is-active-route {
|
||||
background-color: rgba(255, 255, 255, .2);
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: solid 1px lightgray;
|
||||
margin: 1rem -1rem 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
Loading…
Reference in New Issue
Block a user