Version 3 #67
@ -10,7 +10,7 @@
|
||||
"lint": "vue-cli-service lint",
|
||||
"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",
|
||||
"vue": "vue-cli-service build --development && cd ../Server && dotnet run"
|
||||
"vue": "vue-cli-service build --mode development && cd ../Server && dotnet run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^6.4.1",
|
||||
|
@ -70,7 +70,10 @@ export default {
|
||||
* Set the bearer token for all future requests
|
||||
* @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
|
||||
|
@ -1,7 +1,10 @@
|
||||
<template lang="pug">
|
||||
nav.navbar
|
||||
nav.navbar.navbar-dark
|
||||
.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
|
||||
template(v-if="isAuthenticated")
|
||||
li.nav-item: router-link(to="/journal") Journal
|
||||
@ -43,3 +46,27 @@ const logOff = () => {
|
||||
router.push("/")
|
||||
}
|
||||
</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>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { createApp } from "vue"
|
||||
import App from "./App.vue"
|
||||
import auth from "./plugins/auth"
|
||||
import auth, { key as authKey } from "./plugins/auth"
|
||||
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')
|
||||
|
@ -1,14 +1,17 @@
|
||||
import { App, inject, InjectionKey } from "vue"
|
||||
import { App, InjectionKey } from "vue"
|
||||
import authService, { AuthService } from "@/auth"
|
||||
|
||||
/** 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 {
|
||||
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({
|
||||
created () {
|
||||
@ -27,5 +30,5 @@ export default {
|
||||
|
||||
/** Use the auth service */
|
||||
export function useAuth () : AuthService {
|
||||
return inject(AuthSymbol)!
|
||||
return service
|
||||
}
|
||||
|
@ -17,12 +17,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: Home,
|
||||
meta: { title: "Welcome!" }
|
||||
},
|
||||
// {
|
||||
// path: "/journal",
|
||||
// name: "Journal",
|
||||
// component: () => import(/* webpackChunkName: "journal" */ "@/views/Journal.vue"),
|
||||
// meta: { auth: true, title: "Loading Prayer Journal..." }
|
||||
// },
|
||||
{
|
||||
path: "/journal",
|
||||
name: "Journal",
|
||||
component: () => import(/* webpackChunkName: "journal" */ "@/views/Journal.vue"),
|
||||
meta: { auth: true, title: "Loading Prayer Journal..." }
|
||||
},
|
||||
{
|
||||
path: "/legal/privacy-policy",
|
||||
name: "PrivacyPolicy",
|
||||
@ -35,12 +35,12 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import(/* webpackChunkName: "legal" */ "@/views/legal/TermsOfService.vue"),
|
||||
meta: { title: "Terms of Service" }
|
||||
},
|
||||
// {
|
||||
// path: "/request/:id/edit",
|
||||
// name: "EditRequest",
|
||||
// component: () => import(/* webpackChunkName: "edit" */ "@/views/request/EditRequest.vue"),
|
||||
// meta: { auth: true, title: "Edit Prayer Request" }
|
||||
// },
|
||||
{
|
||||
path: "/request/:id/edit",
|
||||
name: "EditRequest",
|
||||
component: () => import(/* webpackChunkName: "edit" */ "@/views/request/EditRequest.vue"),
|
||||
meta: { auth: true, title: "Edit Prayer Request" }
|
||||
},
|
||||
// {
|
||||
// path: "/request/:id/full",
|
||||
// name: "FullRequest",
|
||||
|
@ -29,6 +29,9 @@ export function useStore () : Store<State> {
|
||||
return baseUseStore(key)
|
||||
}
|
||||
|
||||
/** The authentication service */
|
||||
const auth = useAuth()
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/** The authentication service */
|
||||
const auth = useAuth()
|
||||
|
||||
export default createStore({
|
||||
state: () : State => ({
|
||||
pageTitle: appName,
|
||||
@ -123,23 +123,27 @@ export default createStore({
|
||||
} catch (_) {
|
||||
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) {
|
||||
// const { progress, requestId, status, updateText, recurType, recurCount } = p
|
||||
// progress.events.$emit("show", "indeterminate")
|
||||
|
41
src/MyPrayerJournal/App/src/views/Journal.vue
Normal file
41
src/MyPrayerJournal/App/src/views/Journal.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template lang="pug">
|
||||
main
|
||||
p(v-if="isLoadingJournal") Loading your prayer journal…
|
||||
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 “Active” link above for snoozed or deferred requests,
|
||||
and the “Answered” 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}’s Prayer Journal`)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
.no-requests
|
||||
max-width: 40rem
|
||||
margin: auto
|
||||
</style>
|
11
src/MyPrayerJournal/App/src/views/request/EditRequest.vue
Normal file
11
src/MyPrayerJournal/App/src/views/request/EditRequest.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template lang="pug">
|
||||
main
|
||||
p It works
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
id: String
|
||||
})
|
||||
|
||||
</script>
|
@ -5,13 +5,36 @@ module MyPrayerJournal.Handlers
|
||||
// fsharplint:disable RecordFieldNames
|
||||
|
||||
open Giraffe
|
||||
open Giraffe.Htmx
|
||||
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
|
||||
module Vue =
|
||||
|
||||
/// 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
|
||||
|
||||
@ -132,6 +155,23 @@ module Models =
|
||||
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
|
||||
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
|
||||
module Request =
|
||||
|
||||
@ -312,6 +363,13 @@ open Giraffe.EndpointRouting
|
||||
/// The routes for myPrayerJournal
|
||||
let routes =
|
||||
[ 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/" [
|
||||
GET [
|
||||
route "journal" Journal.journal
|
||||
|
102
src/MyPrayerJournal/Server/Htmx.fs
Normal file
102
src/MyPrayerJournal/Server/Htmx.fs
Normal 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
|
||||
}
|
@ -4,8 +4,11 @@
|
||||
<Version>3.0.0.0</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="ViewEngine.Htmx.fs" />
|
||||
<Compile Include="Htmx.fs" />
|
||||
<Compile Include="Domain.fs" />
|
||||
<Compile Include="Data.fs" />
|
||||
<Compile Include="Views.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
@ -100,7 +100,9 @@ module Configure =
|
||||
app.UseAuthentication()
|
||||
.UseStaticFiles()
|
||||
.UseRouting()
|
||||
.UseEndpoints (fun e -> e.MapGiraffeEndpoints Handlers.routes)
|
||||
.UseEndpoints (fun e ->
|
||||
e.MapGiraffeEndpoints Handlers.routes
|
||||
e.MapFallbackToFile "index.html" |> ignore)
|
||||
|> ignore
|
||||
app
|
||||
|
||||
|
136
src/MyPrayerJournal/Server/ViewEngine.Htmx.fs
Normal file
136
src/MyPrayerJournal/Server/ViewEngine.Htmx.fs
Normal 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"
|
||||
|
249
src/MyPrayerJournal/Server/Views.fs
Normal file
249
src/MyPrayerJournal/Server/Views.fs
Normal 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 (“sub”) 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 “Log Off” 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 “monetize” 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 “as is”, 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 " • "
|
||||
a [ _href "/legal/terms-of-service" ] [ str "Terms of Service" ]
|
||||
rawText " • "
|
||||
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
|
||||
|
Loading…
Reference in New Issue
Block a user