Version 3 #67
| @ -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", | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
| @ -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> | ||||||
|  | |||||||
| @ -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') | ||||||
|  | |||||||
| @ -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 | ||||||
| } | } | ||||||
|  | |||||||
| @ -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",
 | ||||||
|  | |||||||
| @ -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")
 | ||||||
|  | |||||||
							
								
								
									
										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 | // 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 | ||||||
|  | |||||||
							
								
								
									
										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> |     <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> | ||||||
|  | |||||||
| @ -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 | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user