htmx experiment in progress...

This commit is contained in:
Daniel J. Summers 2021-09-29 21:13:04 -04:00
parent 371f5d7385
commit 0f9b128c79
15 changed files with 683 additions and 44 deletions

View File

@ -10,7 +10,7 @@
"lint": "vue-cli-service lint", "lint": "vue-cli-service lint",
"apistart": "cd ../Server && dotnet run", "apistart": "cd ../Server && dotnet run",
"publish": "vue-cli-service build --modern && cd ../Server && dotnet publish -c Release -r linux-x64 -p:PublishSingleFile=true --self-contained false", "publish": "vue-cli-service build --modern && cd ../Server && dotnet publish -c Release -r linux-x64 -p:PublishSingleFile=true --self-contained false",
"vue": "vue-cli-service build --development && cd ../Server && dotnet run" "vue": "vue-cli-service build --mode development && cd ../Server && dotnet run"
}, },
"dependencies": { "dependencies": {
"@vueuse/core": "^6.4.1", "@vueuse/core": "^6.4.1",

View File

@ -70,7 +70,10 @@ export default {
* Set the bearer token for all future requests * Set the bearer token for all future requests
* @param token The token to use to identify the user to the server * @param token The token to use to identify the user to the server
*/ */
setBearer: (token: string) => { bearer = `Bearer ${token}` }, setBearer: (token: string) => {
console.info(`Setting bearer token to ${token}`)
bearer = `Bearer ${token}`
},
/** /**
* Remove the bearer token * Remove the bearer token

View File

@ -1,7 +1,10 @@
<template lang="pug"> <template lang="pug">
nav.navbar nav.navbar.navbar-dark
.container-fluid .container-fluid
router-link.navbar-brand(to="/") myPrayerJournal router-link.navbar-brand(to="/")
span.m my
span.p Prayer
span.j Journal
ul.navbar-nav.me-auto.d-flex.flex-row ul.navbar-nav.me-auto.d-flex.flex-row
template(v-if="isAuthenticated") template(v-if="isAuthenticated")
li.nav-item: router-link(to="/journal") Journal li.nav-item: router-link(to="/journal") Journal
@ -43,3 +46,27 @@ const logOff = () => {
router.push("/") router.push("/")
} }
</script> </script>
<style lang="sass" scoped>
nav
background-color: green
.m
font-weight: 100
.p
font-weight: 400
.j
font-weight: 700
.nav-item
a:link,
a:visited
padding: .5rem 1rem
margin: 0 .5rem
border-radius: .5rem
color: white
text-decoration: none
a:hover
cursor: pointer
background-color: rgba(255, 255, 255, .2)
.navbar-nav .router-link-exact-active
background-color: rgba(255, 255, 255, .2)
</style>

View File

@ -1,7 +1,7 @@
import { createApp } from "vue" import { createApp } from "vue"
import App from "./App.vue" import App from "./App.vue"
import auth from "./plugins/auth" import auth, { key as authKey } from "./plugins/auth"
import router from "./router" import router from "./router"
import store from "./store" import store, { key as storeKey } from "./store"
createApp(App).use(store).use(router).use(auth).mount('#app') createApp(App).use(store, storeKey).use(router).use(auth, authKey).mount('#app')

View File

@ -1,14 +1,17 @@
import { App, inject, InjectionKey } from "vue" import { App, InjectionKey } from "vue"
import authService, { AuthService } from "@/auth" import authService, { AuthService } from "@/auth"
/** The symbol to use for dependency injection */ /** The symbol to use for dependency injection */
const AuthSymbol : InjectionKey<AuthService> = Symbol("Auth service") export const key : InjectionKey<AuthService> = Symbol("Auth service")
/** The auth service instance */
const service = new AuthService()
export default { export default {
install (app : App) { install (app : App) {
Object.defineProperty(app, "authService", { get: () => authService }) Object.defineProperty(app, "authService", { get: () => service })
app.provide(AuthSymbol, authService) app.provide(key, service)
app.mixin({ app.mixin({
created () { created () {
@ -27,5 +30,5 @@ export default {
/** Use the auth service */ /** Use the auth service */
export function useAuth () : AuthService { export function useAuth () : AuthService {
return inject(AuthSymbol)! return service
} }

View File

@ -17,12 +17,12 @@ const routes: Array<RouteRecordRaw> = [
component: Home, component: Home,
meta: { title: "Welcome!" } meta: { title: "Welcome!" }
}, },
// { {
// path: "/journal", path: "/journal",
// name: "Journal", name: "Journal",
// component: () => import(/* webpackChunkName: "journal" */ "@/views/Journal.vue"), component: () => import(/* webpackChunkName: "journal" */ "@/views/Journal.vue"),
// meta: { auth: true, title: "Loading Prayer Journal..." } meta: { auth: true, title: "Loading Prayer Journal..." }
// }, },
{ {
path: "/legal/privacy-policy", path: "/legal/privacy-policy",
name: "PrivacyPolicy", name: "PrivacyPolicy",
@ -35,12 +35,12 @@ const routes: Array<RouteRecordRaw> = [
component: () => import(/* webpackChunkName: "legal" */ "@/views/legal/TermsOfService.vue"), component: () => import(/* webpackChunkName: "legal" */ "@/views/legal/TermsOfService.vue"),
meta: { title: "Terms of Service" } meta: { title: "Terms of Service" }
}, },
// { {
// path: "/request/:id/edit", path: "/request/:id/edit",
// name: "EditRequest", name: "EditRequest",
// component: () => import(/* webpackChunkName: "edit" */ "@/views/request/EditRequest.vue"), component: () => import(/* webpackChunkName: "edit" */ "@/views/request/EditRequest.vue"),
// meta: { auth: true, title: "Edit Prayer Request" } meta: { auth: true, title: "Edit Prayer Request" }
// }, },
// { // {
// path: "/request/:id/full", // path: "/request/:id/full",
// name: "FullRequest", // name: "FullRequest",

View File

@ -29,6 +29,9 @@ export function useStore () : Store<State> {
return baseUseStore(key) return baseUseStore(key)
} }
/** The authentication service */
const auth = useAuth()
/** /**
* Set the "Bearer" authorization header with the current access token * Set the "Bearer" authorization header with the current access token
*/ */
@ -59,9 +62,6 @@ const sortValue = (it : JournalRequest) => it.showAfter === 0 ? it.asOf : it.sho
*/ */
const journalSort = (a : JournalRequest, b : JournalRequest) => sortValue(a) - sortValue(b) const journalSort = (a : JournalRequest, b : JournalRequest) => sortValue(a) - sortValue(b)
/** The authentication service */
const auth = useAuth()
export default createStore({ export default createStore({
state: () : State => ({ state: () : State => ({
pageTitle: appName, pageTitle: appName,
@ -123,23 +123,27 @@ export default createStore({
} catch (_) { } catch (_) {
commit(Mutations.SetAuthentication, false) commit(Mutations.SetAuthentication, false)
} }
},
async [Actions.LoadJournal] ({ commit } /*, progress : ProgressProps */) {
commit(Mutations.LoadedJournal, [])
// progress.events.$emit("show", "query")
commit(Mutations.LoadingJournal, true)
await setBearer()
try {
const jrnl = await api.request.journal()
if (typeof jrnl === "string") {
throw new Error(`Unable to retrieve journal - ${jrnl}`)
}
if (typeof jrnl === "undefined") {
throw new Error(`HTTP 404 when retrieving journal`)
}
commit(Mutations.LoadedJournal, jrnl)
} finally {
// progress.events.$emit("done")
commit(Mutations.LoadingJournal, false)
}
} }
// , // ,
// async [Actions.LoadJournal] ({ commit }, progress : ProgressProps) {
// commit(Mutations.LoadedJournal, [])
// progress.events.$emit("show", "query")
// commit(Mutations.LoadingJournal, true)
// await setBearer()
// try {
// const jrnl = await api.journal()
// commit(Mutations.LoadedJournal, jrnl.data)
// } catch (err) {
// logError(err)
// } finally {
// progress.events.$emit("done")
// commit(Mutations.LoadingJournal, false)
// }
// },
// async [Actions.UpdateRequest] ({ commit, state }, p : UpdateRequestAction) { // async [Actions.UpdateRequest] ({ commit, state }, p : UpdateRequestAction) {
// const { progress, requestId, status, updateText, recurType, recurCount } = p // const { progress, requestId, status, updateText, recurType, recurCount } = p
// progress.events.$emit("show", "indeterminate") // progress.events.$emit("show", "indeterminate")

View File

@ -0,0 +1,41 @@
<template lang="pug">
main
p(v-if="isLoadingJournal") Loading your prayer journal&hellip;
template(v-else)
.card(v-if="journal.length === 0").no-requests.mt-3
.card-body
h5.card-title No Active Requests
p.card-text.
You have no requests to be shown; see the &ldquo;Active&rdquo; link above for snoozed or deferred requests,
and the &ldquo;Answered&rdquo; link for answered requests
router-link(:to="{ name: 'EditRequest', params: { id: 'new' } }").btn.btn-primary Add a Request
p(v-else) There are requests
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue"
import { Actions, Mutations, useStore } from "@/store"
const store = useStore()
/** The user's prayer journal */
const journal = computed(() => store.state.journal)
/** Whether the journal is loading */
const isLoadingJournal = computed(() => store.state.isLoadingJournal)
onMounted(async () => {
try {
await store.dispatch(Actions.LoadJournal)
} finally {
store.commit(Mutations.SetTitle, `${store.state.user.given_name}&rsquo;s Prayer Journal`)
}
})
</script>
<style lang="sass">
.no-requests
max-width: 40rem
margin: auto
</style>

View File

@ -0,0 +1,11 @@
<template lang="pug">
main
p It works
</template>
<script setup lang="ts">
const props = defineProps({
id: String
})
</script>

View File

@ -5,13 +5,36 @@ module MyPrayerJournal.Handlers
// fsharplint:disable RecordFieldNames // fsharplint:disable RecordFieldNames
open Giraffe open Giraffe
open Giraffe.Htmx
open MyPrayerJournal.Data.Extensions open MyPrayerJournal.Data.Extensions
/// Send a partial result if this is not a full page load
let partialIfNotRefresh content layout : HttpHandler =
fun next ctx -> task {
let hdrs = Headers.fromRequest ctx
let isHtmx =
hdrs
|> List.filter HtmxReqHeader.isRequest
|> List.tryHead
|> Option.isSome
let isRefresh =
hdrs
|> List.filter HtmxReqHeader.isHistoryRestoreRequest
|> List.tryHead
|> function Some (HistoryRestoreRequest hist) -> hist | _ -> false
match isHtmx && not isRefresh with
| true -> return! ctx.WriteHtmlViewAsync content
| false -> return! layout content |> ctx.WriteHtmlViewAsync
}
/// Handler to return Vue files /// Handler to return Vue files
module Vue = module Vue =
/// The application index page /// The application index page
let app : HttpHandler = htmlFile "wwwroot/index.html" let app : HttpHandler =
Headers.toResponse (Trigger "menu-refresh")
>=> partialIfNotRefresh (ViewEngine.HtmlElements.str "It works") Views.Layout.wide
open System open System
@ -132,6 +155,23 @@ module Models =
until : int64 until : int64
} }
/// Handlers for less-than-full-page HTML requests
module Components =
// GET /components/nav-items
let navItems : HttpHandler =
fun next ctx -> task {
let url =
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
}
/// /api/journal URLs /// /api/journal URLs
module Journal = module Journal =
@ -144,6 +184,17 @@ module Journal =
} }
/// Legalese
module Legal =
// GET /legal/privacy-policy
let privacyPolicy : HttpHandler =
partialIfNotRefresh Views.Legal.privacyPolicy Views.Layout.standard
let termsOfService : HttpHandler =
partialIfNotRefresh Views.Legal.termsOfService Views.Layout.standard
/// /api/request URLs /// /api/request URLs
module Request = module Request =
@ -312,6 +363,13 @@ open Giraffe.EndpointRouting
/// The routes for myPrayerJournal /// The routes for myPrayerJournal
let routes = let routes =
[ route "/" Vue.app [ route "/" Vue.app
subRoute "/components/" [
route "nav-items" Components.navItems
]
subRoute "/legal/" [
route "privacy-policy" Legal.privacyPolicy
route "terms-of-service" Legal.termsOfService
]
subRoute "/api/" [ subRoute "/api/" [
GET [ GET [
route "journal" Journal.journal route "journal" Journal.journal

View File

@ -0,0 +1,102 @@
module Giraffe.Htmx
open System
/// HTMX request header values
type HtmxReqHeader =
/// Indicates that the request is via an element using `hx-boost`
| Boosted of string
/// The current URL of the browser
| CurrentUrl of Uri
/// `true` if the request is for history restoration after a miss in the local history cache
| HistoryRestoreRequest of bool
/// The user response to an hx-prompt
| Prompt of string
/// Always `true`
| Request of bool
/// The `id` of the target element if it exists
| Target of string
/// The `id` of the triggered element if it exists
| Trigger of string
/// The `name` of the triggered element if it exists
| TriggerName of string
/// Functions for manipulating htmx request headers
module HtmxReqHeader =
/// True if this is an `HX-Boosted` header, false if not
let isBoosted = function Boosted _ -> true | _ -> false
/// True if this is an `HX-Current-URL` header, false if not
let isCurrentUrl = function CurrentUrl _ -> true | _ -> false
/// True if this is an `HX-History-Restore-Request` header, false if not
let isHistoryRestoreRequest = function HistoryRestoreRequest _ -> true | _ -> false
/// True if this is an `HX-Prompt` header, false if not
let isPrompt = function Prompt _ -> true | _ -> false
/// True if this is an `HX-Request` header, false if not
let isRequest = function Request _ -> true | _ -> false
/// True if this is an `HX-Target` header, false if not
let isTarget = function Target _ -> true | _ -> false
/// True if this is an `HX-Trigger` header, false if not
let isTrigger = function Trigger _ -> true | _ -> false
/// True if this is an `HX-Trigger-Name` header, false if not
let isTriggerName = function TriggerName _ -> true | _ -> false
/// HTMX response header values
type HtmxResHeader =
/// Pushes a new url into the history stack
| Push of bool
/// Can be used to do a client-side redirect to a new location
| Redirect of string
/// If set to `true` the client side will do a a full refresh of the page
| Refresh of bool
/// Allows you to trigger client side events
| Trigger of obj
/// Allows you to trigger client side events after changes have settled
| TriggerAfterSettle of obj
/// Allows you to trigger client side events after DOM swapping occurs
| TriggerAfterSwap of obj
module Headers =
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Primitives
/// Get the HTMX headers from the request context
let fromRequest (ctx : HttpContext) =
ctx.Request.Headers.Keys
|> Seq.filter (fun key -> key.StartsWith "HX-")
|> Seq.map (fun key ->
let v = ctx.Request.Headers.[key].[0]
match key with
| "HX-Boosted" -> v |> (Boosted >> Some)
| "HX-Current-URL" -> v |> (Uri >> CurrentUrl >> Some)
| "HX-History-Restore-Request" -> v |> (bool.Parse >> HistoryRestoreRequest >> Some)
| "HX-Prompt" -> v |> (Prompt >> Some)
| "HX-Request" -> v |> (bool.Parse >> Request >> Some)
| "HX-Target" -> v |> (Target >> Some)
| "HX-Trigger" -> v |> (HtmxReqHeader.Trigger >> Some)
| "HX-Trigger-Name" -> v |> (TriggerName >> Some)
| _ -> None
)
|> Seq.filter Option.isSome
|> Seq.map Option.get
|> List.ofSeq
/// Add an htmx header to the response
let toResponse (hdr : HtmxResHeader) : HttpHandler =
let toJson (it : obj) =
match it with
| :? string as x -> x
| _ -> "" // TODO: serialize object
fun next ctx -> task {
match hdr with
| Push push -> "HX-Push", string push
| Redirect url -> "HX-Redirect", url
| Refresh refresh -> "HX-Refresh", string refresh
| Trigger trig -> "HX-Trigger", toJson trig
| TriggerAfterSettle trig -> "HX-Trigger-After-Settle", toJson trig
| TriggerAfterSwap trig -> "HX-Trigger-After-Swap", toJson trig
|> function (k, v) -> ctx.Response.Headers.Add (k, StringValues v)
return! next ctx
}

View File

@ -4,8 +4,11 @@
<Version>3.0.0.0</Version> <Version>3.0.0.0</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="ViewEngine.Htmx.fs" />
<Compile Include="Htmx.fs" />
<Compile Include="Domain.fs" /> <Compile Include="Domain.fs" />
<Compile Include="Data.fs" /> <Compile Include="Data.fs" />
<Compile Include="Views.fs" />
<Compile Include="Handlers.fs" /> <Compile Include="Handlers.fs" />
<Compile Include="Program.fs" /> <Compile Include="Program.fs" />
</ItemGroup> </ItemGroup>

View File

@ -100,7 +100,9 @@ module Configure =
app.UseAuthentication() app.UseAuthentication()
.UseStaticFiles() .UseStaticFiles()
.UseRouting() .UseRouting()
.UseEndpoints (fun e -> e.MapGiraffeEndpoints Handlers.routes) .UseEndpoints (fun e ->
e.MapGiraffeEndpoints Handlers.routes
e.MapFallbackToFile "index.html" |> ignore)
|> ignore |> ignore
app app

View File

@ -0,0 +1,136 @@
module Giraffe.ViewEngine.Htmx
/// Valid values for the `hx-encoding` attribute
[<RequireQualifiedAccess>]
module HxEncoding =
/// A standard HTTP form
let Form = "application/x-www-form-urlencoded"
/// A multipart form (used for file uploads)
let MultipartForm = "multipart/form-data"
// TODO: hx-header helper
/// Values / helpers for the `hx-params` attribute
[<RequireQualifiedAccess>]
module HxParams =
/// Include all parameters
let All = "*"
/// Include no parameters
let None = "none"
/// Include the specified parameters
let With fields = fields |> List.reduce (fun acc it -> $"{acc},{it}")
/// Exclude the specified parameters
let Except fields = With fields |> sprintf "not %s"
// TODO: hx-request helper
/// Valid values for the `hx-swap` attribute (may be combined with swap/settle/scroll/show config)
[<RequireQualifiedAccess>]
module HxSwap =
/// The default, replace the inner html of the target element
let InnerHtml = "innerHTML"
/// Replace the entire target element with the response
let OuterHtml = "outerHTML"
/// Insert the response before the target element
let BeforeBegin = "beforebegin"
/// Insert the response before the first child of the target element
let AfterBegin = "afterbegin"
/// Insert the response after the last child of the target element
let BeforeEnd = "beforeend"
/// Insert the response after the target element
let AfterEnd = "afterend"
/// Does not append content from response (out of band items will still be processed).
let None = "none"
/// Helpers for the `hx-trigger` attribute
[<RequireQualifiedAccess>]
module HxTrigger =
/// Append a filter to a trigger
let private appendFilter filter (trigger : string) =
match trigger.Contains "[" with
| true ->
let parts = trigger.Split ('[', ']')
sprintf "%s[%s&&%s]" parts.[0] parts.[1] filter
| false -> sprintf "%s[%s]" trigger filter
/// Trigger the event on a click
let Click = "click"
/// Trigger the event on page load
let Load = "load"
/// Helpers for defining filters
module Filter =
/// Only trigger the event if the `ALT` key is pressed
let Alt = appendFilter "altKey"
/// Only trigger the event if the `CTRL` key is pressed
let Ctrl = appendFilter "ctrlKey"
/// Only trigger the event if the `SHIFT` key is pressed
let Shift = appendFilter "shiftKey"
/// Only trigger the event if `CTRL+ALT` are pressed
let CtrlAlt = Ctrl >> Alt
/// Only trigger the event if `CTRL+SHIFT` are pressed
let CtrlShift = Ctrl >> Shift
/// Only trigger the event if `CTRL+ALT+SHIFT` are pressed
let CtrlAltShift = CtrlAlt >> Shift
/// Only trigger the event if `ALT+SHIFT` are pressed
let AltShift = Alt >> Shift
// TODO: more stuff for the hx-trigger helper
// TODO: hx-vals helper
[<AutoOpen>]
module HtmxAttrs =
/// Progressively enhances anchors and forms to use AJAX requests
let _hxBoost = attr "hx-boost" "true"
/// Shows a confim() dialog before issuing a request
let _hxConfirm = attr "hx-confirm"
/// Issues a DELETE to the specified URL
let _hxDelete = attr "hx-delete"
/// Disables htmx processing for the given node and any children nodes
let _hxDisable = flag "hx-disable"
/// Changes the request encoding type
let _hxEncoding = attr "hx-encoding"
/// Extensions to use for this element
let _hxExt = attr "hx-ext"
/// Issues a GET to the specified URL
let _hxGet = attr "hx-get"
/// Adds to the headers that will be submitted with the request
let _hxHeaders = attr "hx-headers"
/// The element to snapshot and restore during history navigation
let _hxHistoryElt = flag "hx-history-elt"
/// Includes additional data in AJAX requests
let _hxInclude = attr "hx-include"
/// The element to put the htmx-request class on during the AJAX request
let _hxIndicator = attr "hx-indicator"
/// Filters the parameters that will be submitted with a request
let _hxParams = attr "hx-params"
/// Issues a PATCH to the specified URL
let _hxPatch = attr "hx-patch"
/// Issues a POST to the specified URL
let _hxPost = attr "hx-post"
/// Preserves an element between requests
let _hxPreserve = attr "hx-preserve" "true"
/// Shows a prompt before submitting a request
let _hxPrompt = attr "hx-prompt"
/// Pushes the URL into the location bar, creating a new history entry
let _hxPushUrl = attr "hx-push-url"
/// Issues a PUT to the specified URL
let _hxPut = attr "hx-put"
/// Configures various aspects of the request
let _hxRequest = attr "hx-request"
/// Selects a subset of the server response to process
let _hxSelect = attr "hx-select"
/// Establishes and listens to Server Sent Event (SSE) sources for events
let _hxSse = attr "hx-sse"
/// Marks content in a response as being "Out of Band", i.e. swapped somewhere other than the target
let _hxSwapOob = attr "hx-swap-oob"
/// Controls how the response content is swapped into the DOM (e.g. 'outerHTML' or 'beforeEnd')
let _hxSwap = attr "hx-swap"
/// Specifies the target element to be swapped
let _hxTarget = attr "hx-target"
/// Specifies the event that triggers the request
let _hxTrigger = attr "hx-trigger"
/// Adds to the parameters that will be submitted with the request
let _hxVals = attr "hx-vals"
/// Establishes a WebSocket or sends information to one
let _hxWs = attr "hx-ws"

View File

@ -0,0 +1,249 @@
module MyPrayerJournal.Views
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Htmx
open System
let toMain = _hxTarget "main"
/// Views for legal pages
module Legal =
/// View for the "Privacy Policy" page
let privacyPolicy = article [] [
div [ _class "card" ] [
h5 [ _class "card-header" ] [ str "Privacy Policy" ]
div [ _class "card-body" ] [
h6 [ _class "card-subtitle text-muted" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
p [ _class "card-text" ] [
str "The nature of the service is one where privacy is a must. The items below will help you understand "
str "the data we collect, access, and store on your behalf as you use this service."
]
hr []
h3 [] [ str "Third Party Services" ]
p [ _class "card-text" ] [
str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
str "yourself with the privacy policy for "
a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ]
str ", as well as your chosen provider ("
a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [ str "Microsoft"]
str " or "
a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ]
str ")."
]
hr []
h3 [] [ str "What We Collect" ]
h4 [] [ str "Identifying Data" ]
ul [] [
li [] [
rawText "The only identifying data myPrayerJournal stores is the subscriber (&ldquo;sub&rdquo;) field "
str "from the token we receive from Auth0, once you have signed in through their hosted service. "
str "All information is associated with you via this field."
]
li [] [
str "While you are signed in, within your browser, the service has access to your first and last names, "
str "along with a URL to the profile picture (provided by your selected identity provider). This "
rawText "information is not transmitted to the server, and is removed when &ldquo;Log Off&rdquo; is "
str "clicked."
]
]
h4 [] [ str "User Provided Data" ]
ul [] [
li [] [
str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, "
str "and notes; and the date/time when certain actions are taken."
]
]
hr []
h3 [] [ str "How Your Data Is Accessed / Secured" ]
ul [] [
li [] [
str "Your provided data is returned to you, as required, to display your journal or your answered "
str "requests. On the server, it is stored in a controlled-access database."
]
li [] [
str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; "
str "backups are preserved for the prior 7 days, and backups from the 1"
sup [] [ str "st" ]
str " and 15"
sup [] [ str "th" ]
str " are preserved for 3 months. These backups are stored in a private cloud data repository."
]
li [] [
str "The data collected and stored is the absolute minimum necessary for the functionality of the "
rawText "service. There are no plans to &ldquo;monetize&rdquo; this service, and storing the minimum "
str "amount of information means that the data we have is not interesting to purchasers (or those who "
str "may have more nefarious purposes)."
]
li [] [
str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
]
]
hr []
h3 [] [ str "Removing Your Data" ]
p [ _class "card-text" ] [
str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways "
str "to revoke access from this application. However, if you want your data removed from the database, "
str "please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to "
str "ensure we can determine which subscriber ID belongs to you."
]
]
]
]
/// View for the "Terms of Service" page
let termsOfService = article [ _class "container" ] [
div [ _class "card" ] [
h5 [ _class "card-header" ] [ str "Terms of Service" ]
div [ _class "card-body" ] [
h6 [ _class "card-subtitle text-muted"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
h3 [] [ str "1. Acceptance of Terms" ]
p [ _class "card-text" ] [
str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you "
str "are responsible to ensure that your use of this site complies with all applicable laws. Your continued "
str "use of this site implies your acceptance of these terms."
]
h3 [] [ str "2. Description of Service and Registration" ]
p [ _class "card-text" ] [
str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It "
str "requires no registration by itself, but access is granted based on a successful login with an external "
str "identity provider. See "
a [ _href "/legal/privacy-policy"; _hxBoost; toMain ] [ str "our privacy policy" ]
str " for details on how that information is accessed and stored."
]
h3 [] [ str "3. Third Party Services" ]
p [ _class "card-text" ] [
str "This service utilizes a third-party service provider for identity management. Review the terms of "
str "service for"
a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
str ", as well as those for the selected authorization provider ("
a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
str " or "
a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
str ")."
]
h3 [] [ str "4. Liability" ]
p [ _class "card-text" ] [
rawText "This service is provided &ldquo;as is&rdquo;, and no warranty (express or implied) exists. The "
str "service and its developers may not be held liable for any damages that may arise through the use of "
str "this service."
]
h3 [] [ str "5. Updates to Terms" ]
p [ _class "card-text" ] [
str "These terms and conditions may be updated at any time, and this service does not have the capability to "
str "notify users when these change. The date at the top of the page will be updated when any of the text of "
str "these terms is updated."
]
hr []
p [ _class "card-text" ] [
str "You may also wish to review our "
a [ _href "/legal/privacy-policy"; _hxBoost; toMain ] [ str "privacy policy" ]
str " to learn how we handle your data."
]
]
]
]
/// 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 ] [
div [ _class "container-fluid" ] [
a [ _href "/"; _class "navbar-brand" ] [
span [ _class "m" ] [ str "my" ]
span [ _class "p" ] [ str "Prayer" ]
span [ _class "j" ] [ str "Journal" ]
]
ul [
_class "navbar-nav me-auto d-flex flex-row"
_hxGet "/components/nav-items"
_hxTarget ".navbar-nav"
_hxTrigger (sprintf "%s, menu-refresh from:body" HxTrigger.Load)
] [ ]
]
]
/// Generate the navigation items based on the current state
let currentNav isAuthenticated hasSnoozed (url : Uri option) =
seq {
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" ] ]
li [ _class "nav-item" ] [ a [ _href "https://docs.prayerjournal.me"; _target "_blank" ] [ str "Docs" ] ]
}
|> List.ofSeq
/// Layout views
module Layout =
let htmlHead =
head [] [
link [
_href "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
_rel "stylesheet"
_integrity "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
_crossorigin "anonymous"
]
link [ _href "/css/style.css"; _rel "stylesheet" ]
script [
_src "https://unpkg.com/htmx.org@1.5.0"
_integrity "sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
_crossorigin "anonymous"
] []
]
let htmlFoot =
footer [ _class "container-fluid"; _hxBoost; toMain ] [
p [ _class "text-muted text-end" ] [
str "myPrayerJournal v3"
br []
em [] [
small [] [
a [ _href "/legal/privacy-policy" ] [ str "Privacy Policy" ]
rawText " &bull; "
a [ _href "/legal/terms-of-service" ] [ str "Terms of Service" ]
rawText " &bull; "
a [ _href "https://github.com/bit-badger/myprayerjournal"; _target "_blank" ] [ str "Developed" ]
str " and hosted by "
a [ _href "https://bitbadger.solutions"; _target "_blank" ] [ str "Bit Badger Solutions" ]
]
]
]
script [
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
_integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
_crossorigin "anonymous"
] []
]
let full content =
html [ _lang "en" ] [
htmlHead
body [] [
Navigation.navBar
content
htmlFoot
]
]
let standard content =
main [ _class "container" ] [ content ] |> full
let wide content =
main [ _class "container-fluid" ] [ content ] |> full