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…
Reference in New Issue
Block a user