Version 8 (#43)

* Use htmx for front end (#36)
* Use short GUIDs in URLs and forms (#1)
* Refresh theme (#38)
* Use ASP.NET Core for log on/off (#39)
* Fix date/time / time zone issues (#41)
* Update help for v8 (#42)
* Add FAKE build script (#37)
This commit was merged in pull request #43.
This commit is contained in:
2022-08-19 15:08:30 -04:00
committed by GitHub
parent 58519f9a4d
commit 13ace6ca61
58 changed files with 7525 additions and 7159 deletions

View File

@@ -1,109 +1,122 @@
module PrayerTracker.Views.Church
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Accessibility
open Giraffe.ViewEngine.Htmx
open PrayerTracker
open PrayerTracker.Entities
open PrayerTracker.ViewModels
/// View for the church edit page
let edit (m : EditChurch) ctx vi =
let pageTitle = match m.isNew () with true -> "Add a New Church" | false -> "Edit Church"
let s = I18N.localizer.Force ()
[ form [ _action "/web/church/save"; _method "post"; _class "pt-center-columns" ] [
style [ _scoped ]
[ rawText "#name { width: 20rem; } #city { width: 10rem; } #st { width: 3rem; } #interfaceAddress { width: 30rem; }" ]
csrfToken ctx
input [ _type "hidden"; _name "churchId"; _value (flatGuid m.churchId) ]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "name" ] [ locStr s.["Church Name"] ]
input [ _type "text"; _name "name"; _id "name"; _required; _autofocus; _value m.name ]
]
div [ _class "pt-field" ] [
label [ _for "City"] [ locStr s.["City"] ]
input [ _type "text"; _name "city"; _id "city"; _required; _value m.city ]
]
div [ _class "pt-field" ] [
label [ _for "ST" ] [ locStr s.["State"] ]
input [ _type "text"; _name "st"; _id "st"; _required; _minlength "2"; _maxlength "2"; _value m.st ]
]
let edit (model : EditChurch) ctx viewInfo =
let pageTitle = if model.IsNew then "Add a New Church" else "Edit Church"
let s = I18N.localizer.Force ()
let vi =
viewInfo
|> AppViewInfo.withScopedStyles [
$"#{nameof model.Name} {{ width: 20rem; }}"
$"#{nameof model.City} {{ width: 10rem; }}"
$"#{nameof model.State} {{ width: 3rem; }}"
$"#{nameof model.InterfaceAddress} {{ width: 30rem; }}"
]
div [ _class "pt-field-row" ] [
div [ _class "pt-checkbox-field" ] [
input [ _type "checkbox"
_name "hasInterface"
_id "hasInterface"
_value "True"
match m.hasInterface with Some x when x -> _checked | _ -> () ]
label [ _for "hasInterface" ] [ locStr s.["Has an interface with Virtual Prayer Room"] ]
]
|> AppViewInfo.withOnLoadScript "PT.church.edit.onPageLoad"
form [ _action "/church/save"; _method "post"; _class "pt-center-columns"; Target.content ] [
csrfToken ctx
input [ _type "hidden"; _name (nameof model.ChurchId); _value model.ChurchId ]
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.Name) ] [ locStr s["Church Name"] ]
inputField "text" (nameof model.Name) model.Name [ _required; _autofocus ]
]
div [ _inputField ] [
label [ _for (nameof model.City) ] [ locStr s["City"] ]
inputField "text" (nameof model.City) model.City [ _required ]
]
div [ _inputField ] [
label [ _for (nameof model.State) ] [ locStr s["State or Province"] ]
inputField "text" (nameof model.State) model.State [ _minlength "2"; _maxlength "2"; _required ]
]
]
div [ _class "pt-field-row pt-fadeable"; _id "divInterfaceAddress" ] [
div [ _class "pt-field" ] [
label [ _for "interfaceAddress" ] [ locStr s.["VPR Interface URL"] ]
input [ _type "url"; _name "interfaceAddress"; _id "interfaceAddress";
_value (match m.interfaceAddress with Some ia -> ia | None -> "") ]
]
div [ _fieldRow ] [
div [ _checkboxField ] [
inputField "checkbox" (nameof model.HasInterface) "True"
[ if defaultArg model.HasInterface false then _checked ]
label [ _for (nameof model.HasInterface) ] [
locStr s["Has an Interface with “{0}”", "Virtual Prayer Space"]
]
]
]
div [ _class "pt-field-row" ] [ submit [] "save" s.["Save Church"] ]
]
script [] [ rawText "PT.onLoad(PT.church.edit.onPageLoad)" ]
div [ _fieldRowWith [ "pt-fadeable" ]; _id "divInterfaceAddress" ] [
div [ _inputField ] [
label [ _for (nameof model.InterfaceAddress) ] [ locStr s["Interface URL"] ]
inputField "url" (nameof model.InterfaceAddress) (defaultArg model.InterfaceAddress "") []
]
]
div [ _fieldRow ] [ submit [] "save" s["Save Church"] ]
]
|> Layout.Content.standard
|> Layout.standard vi pageTitle
|> List.singleton
|> Layout.Content.standard
|> Layout.standard vi pageTitle
/// View for church maintenance page
let maintain (churches : Church list) (stats : Map<string, ChurchStats>) ctx vi =
let s = I18N.localizer.Force ()
let chTbl =
match churches with
| [] -> space
| _ ->
table [ _class "pt-table pt-action-table" ] [
thead [] [
tr [] [
th [] [ locStr s.["Actions"] ]
th [] [ locStr s.["Name"] ]
th [] [ locStr s.["Location"] ]
th [] [ locStr s.["Groups"] ]
th [] [ locStr s.["Requests"] ]
th [] [ locStr s.["Users"] ]
th [] [ locStr s.["Interface?"] ]
]
let maintain (churches : Church list) (stats : Map<string, ChurchStats>) ctx viewInfo =
let s = I18N.localizer.Force ()
let vi = AppViewInfo.withScopedStyles [ "#churchList { grid-template-columns: repeat(7, auto); }" ] viewInfo
let churchTable =
match churches with
| [] -> space
| _ ->
section [ _id "churchList"; _class "pt-table"; _ariaLabel "Church list" ] [
div [ _class "row head" ] [
header [ _class "cell" ] [ locStr s["Actions"] ]
header [ _class "cell" ] [ locStr s["Name"] ]
header [ _class "cell" ] [ locStr s["Location"] ]
header [ _class "cell" ] [ locStr s["Groups"] ]
header [ _class "cell" ] [ locStr s["Requests"] ]
header [ _class "cell" ] [ locStr s["Users"] ]
header [ _class "cell" ] [ locStr s["Interface?"] ]
]
for church in churches do
let churchId = shortGuid church.Id.Value
let delAction = $"/church/{churchId}/delete"
let delPrompt = s["Are you sure you want to delete this {0}? This action cannot be undone.",
$"""{s["Church"].Value.ToLower ()} ({church.Name})"""]
div [ _class "row" ] [
div [ _class "cell actions" ] [
a [ _href $"/church/{churchId}/edit"; _title s["Edit This Church"].Value ] [
iconSized 18 "edit"
]
a [ _href delAction
_title s["Delete This Church"].Value
_hxPost delAction
_hxConfirm delPrompt.Value ] [
iconSized 18 "delete_forever"
]
]
div [ _class "cell" ] [ str church.Name ]
div [ _class "cell" ] [ str church.City; rawText ", "; str church.State ]
div [ _class "cell pt-right-text" ] [ rawText (stats[churchId].SmallGroups.ToString "N0") ]
div [ _class "cell pt-right-text" ] [ rawText (stats[churchId].PrayerRequests.ToString "N0") ]
div [ _class "cell pt-right-text" ] [ rawText (stats[churchId].Users.ToString "N0") ]
div [ _class "cell pt-center-text" ] [
locStr s[if church.HasVpsInterface then "Yes" else "No"]
]
]
]
churches
|> List.map (fun ch ->
let chId = flatGuid ch.churchId
let delAction = $"/web/church/{chId}/delete"
let delPrompt = s.["Are you sure you want to delete this {0}? This action cannot be undone.",
$"""{s.["Church"].Value.ToLower ()} ({ch.name})"""]
tr [] [
td [] [
a [ _href $"/web/church/{chId}/edit"; _title s.["Edit This Church"].Value ] [ icon "edit" ]
a [ _href delAction
_title s.["Delete This Church"].Value
_onclick $"return PT.confirmDelete('{delAction}','{delPrompt}')" ]
[ icon "delete_forever" ]
]
td [] [ str ch.name ]
td [] [ str ch.city; rawText ", "; str ch.st ]
td [ _class "pt-right-text" ] [ rawText (stats.[chId].smallGroups.ToString "N0") ]
td [ _class "pt-right-text" ] [ rawText (stats.[chId].prayerRequests.ToString "N0") ]
td [ _class "pt-right-text" ] [ rawText (stats.[chId].users.ToString "N0") ]
td [ _class "pt-center-text" ] [ locStr s.[match ch.hasInterface with true -> "Yes" | false -> "No"] ]
])
|> tbody []
]
[ div [ _class "pt-center-text" ] [
br []
a [ _href $"/web/church/{emptyGuid}/edit"; _title s.["Add a New Church"].Value ]
[ icon "add_circle"; rawText " &nbsp;"; locStr s.["Add a New Church"] ]
br []
br []
]
tableSummary churches.Length s
chTbl
form [ _id "DeleteForm"; _action ""; _method "post" ] [ csrfToken ctx ]
[ div [ _class "pt-center-text" ] [
br []
a [ _href $"/church/{emptyGuid}/edit"; _title s["Add a New Church"].Value ] [
icon "add_circle"; rawText " &nbsp;"; locStr s["Add a New Church"]
]
br []
br []
]
tableSummary churches.Length s
form [ _method "post" ] [
csrfToken ctx
churchTable
]
]
|> Layout.Content.wide
|> Layout.standard vi "Maintain Churches"
|> Layout.Content.wide
|> Layout.standard vi "Maintain Churches"

View File

@@ -1,26 +1,24 @@
[<AutoOpen>]
module PrayerTracker.Views.CommonFunctions
open System.IO
open System.Text.Encodings.Web
open Giraffe
open Giraffe.ViewEngine
open Microsoft.AspNetCore.Antiforgery
open Microsoft.AspNetCore.Html
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Mvc.Localization
open Microsoft.Extensions.Localization
open System
open System.IO
open System.Text.Encodings.Web
/// Encoded text for a localized string
let locStr (text : LocalizedString) = str text.Value
/// Raw text for a localized HTML string
let rawLocText (writer : StringWriter) (text : LocalizedHtmlString) =
text.WriteTo (writer, HtmlEncoder.Default)
let txt = string writer
writer.GetStringBuilder().Clear () |> ignore
rawText txt
text.WriteTo (writer, HtmlEncoder.Default)
let txt = string writer
writer.GetStringBuilder().Clear () |> ignore
rawText txt
/// A space (used for back-to-back localization string breaks)
let space = rawText " "
@@ -29,106 +27,153 @@ let space = rawText " "
let icon name = i [ _class "material-icons" ] [ rawText name ]
/// Generate a Material Design icon, specifying the point size (must be defined in CSS)
let iconSized size name = i [ _class $"material-icons md-{size}" ] [ rawText name ]
let iconSized size name = i [ _class $"material-icons md-%i{size}" ] [ rawText name ]
/// Generate a CSRF prevention token
let csrfToken (ctx : HttpContext) =
let antiForgery = ctx.GetService<IAntiforgery> ()
let tokenSet = antiForgery.GetAndStoreTokens ctx
input [ _type "hidden"; _name tokenSet.FormFieldName; _value tokenSet.RequestToken ]
let antiForgery = ctx.GetService<IAntiforgery> ()
let tokenSet = antiForgery.GetAndStoreTokens ctx
input [ _type "hidden"; _name tokenSet.FormFieldName; _value tokenSet.RequestToken ]
/// Create a summary for a table of items
let tableSummary itemCount (s : IStringLocalizer) =
div [ _class "pt-center-text" ] [
small [] [
match itemCount with
| 0 -> s.["No Entries to Display"]
| 1 -> s.["Displaying {0} Entry", itemCount]
| _ -> s.["Displaying {0} Entries", itemCount]
|> locStr
]
div [ _class "pt-center-text" ] [
small [] [
match itemCount with
| 0 -> s["No Entries to Display"]
| 1 -> s["Displaying {0} Entry", itemCount]
| _ -> s["Displaying {0} Entries", itemCount]
|> locStr
]
]
/// Generate a list of named HTML colors
let namedColorList name selected attrs (s : IStringLocalizer) =
/// The list of HTML named colors (name, display, text color)
seq {
("aqua", s.["Aqua"], "black")
("black", s.["Black"], "white")
("blue", s.["Blue"], "white")
("fuchsia", s.["Fuchsia"], "black")
("gray", s.["Gray"], "white")
("green", s.["Green"], "white")
("lime", s.["Lime"], "black")
("maroon", s.["Maroon"], "white")
("navy", s.["Navy"], "white")
("olive", s.["Olive"], "white")
("purple", s.["Purple"], "white")
("red", s.["Red"], "black")
("silver", s.["Silver"], "black")
("teal", s.["Teal"], "white")
("white", s.["White"], "black")
("yellow", s.["Yellow"], "black")
// The list of HTML named colors (name, display, text color)
seq {
("aqua", s["Aqua"], "black")
("black", s["Black"], "white")
("blue", s["Blue"], "white")
("fuchsia", s["Fuchsia"], "black")
("gray", s["Gray"], "white")
("green", s["Green"], "white")
("lime", s["Lime"], "black")
("maroon", s["Maroon"], "white")
("navy", s["Navy"], "white")
("olive", s["Olive"], "white")
("purple", s["Purple"], "white")
("red", s["Red"], "black")
("silver", s["Silver"], "black")
("teal", s["Teal"], "white")
("white", s["White"], "black")
("yellow", s["Yellow"], "black")
}
|> Seq.map (fun color ->
let (colorName, dispText, txtColor) = color
option [ yield _value colorName
yield _style $"background-color:{colorName};color:{txtColor};"
match colorName = selected with true -> yield _selected | false -> () ] [
encodedText (dispText.Value.ToLower ())
])
|> List.ofSeq
|> select (_name name :: attrs)
|> Seq.map (fun color ->
let colorName, text, txtColor = color
option
[ _value colorName
_style $"background-color:{colorName};color:{txtColor};"
if colorName = selected then _selected
] [ encodedText (text.Value.ToLower ()) ])
|> List.ofSeq
|> select (_name name :: attrs)
/// Convert a named color to its hex notation
let colorToHex (color : string) =
match color with
| it when it.StartsWith "#" -> color
| "aqua" -> "#00ffff"
| "black" -> "#000000"
| "blue" -> "#0000ff"
| "fuchsia" -> "#ff00ff"
| "gray" -> "#808080"
| "green" -> "#008000"
| "lime" -> "#00ff00"
| "maroon" -> "#800000"
| "navy" -> "#000080"
| "olive" -> "#808000"
| "purple" -> "#800080"
| "red" -> "#ff0000"
| "silver" -> "#c0c0c0"
| "teal" -> "#008080"
| "white" -> "#ffffff"
| "yellow" -> "#ffff00"
| it -> it
/// Generate an input[type=radio] that is selected if its value is the current value
let radio name domId value current =
input [ _type "radio"
_name name
_id domId
_value value
match value = current with true -> _checked | false -> () ]
input [ _type "radio"
_name name
if domId <> "" then _id domId
_value value
if value = current then _checked ]
/// Generate a select list with the current value selected
let selectList name selected attrs items =
items
|> Seq.map (fun (value, text) ->
option [ _value value
match value = selected with true -> _selected | false -> () ] [ encodedText text ])
|> List.ofSeq
|> select (List.concat [ [ _name name; _id name ]; attrs ])
items
|> Seq.map (fun (value, text) ->
option
[ _value value
if value = selected then _selected
] [ encodedText text ])
|> List.ofSeq
|> select (List.concat [ [ _name name; _id name ]; attrs ])
/// Generate the text for a default entry at the top of a select list
let selectDefault text = $"— {text} —"
let selectDefault text = $"— %s{text} —"
/// Generate a standard submit button with icon and text
let submit attrs ico text = button (_type "submit" :: attrs) [ icon ico; rawText " &nbsp;"; locStr text ]
/// Format a GUID with no dashes (used for URLs and forms)
let flatGuid (x : Guid) = x.ToString "N"
/// An empty GUID string (used for "add" actions)
let emptyGuid = flatGuid Guid.Empty
/// blockquote tag
let blockquote = tag "blockquote"
/// role attribute
let _role = attr "role"
/// aria-* attribute
let _aria typ = attr $"aria-{typ}"
/// onclick attribute
let _onclick = attr "onclick"
/// onsubmit attribute
/// Create an HTML onsubmit event handler
let _onsubmit = attr "onsubmit"
/// scoped flag (used for <style> tag)
let _scoped = flag "scoped"
/// A "rel='noopener'" attribute
let _relNoOpener = _rel "noopener"
/// A class attribute that designates a row of fields, with the additional classes passed
let _fieldRowWith classes =
let extraClasses = if List.isEmpty classes then "" else $""" {classes |> String.concat " "}"""
_class $"pt-field-row{extraClasses}"
/// The class that designates a row of fields
let _fieldRow = _fieldRowWith []
/// A class attribute that designates an input field, with the additional classes passed
let _inputFieldWith classes =
let extraClasses = if List.isEmpty classes then "" else $""" {classes |> String.concat " "}"""
_class $"pt-field{extraClasses}"
/// The class that designates an input field / label pair
let _inputField = _inputFieldWith []
/// The class that designates a checkbox / label pair
let _checkboxField = _class "pt-checkbox-field"
/// A group of related fields, inputs, links, etc., displayed in a row
let _group = _class "pt-group"
/// Create an input field of the given type, with matching name and ID and the given value
let inputField typ name value attrs =
List.concat [ [ _type typ; _name name; _id name; if value <> "" then _value value ]; attrs ] |> input
/// Generate a table heading with the given localized column names
let tableHeadings (s : IStringLocalizer) (headings : string list) =
headings
|> List.map (fun heading -> th [ _scope "col" ] [ locStr s[heading] ])
|> tr []
|> List.singleton
|> thead []
/// For a list of strings, prepend a pound sign and string them together with commas (CSS selector by ID)
let toHtmlIds it = it |> List.map (fun x -> $"#%s{x}") |> String.concat ", "
/// The name this function used to have when the view engine was part of Giraffe
let renderHtmlNode = RenderView.AsString.htmlNode
open Microsoft.AspNetCore.Html
/// Render an HTML node, then return the value as an HTML string
let renderHtmlString = renderHtmlNode >> HtmlString
@@ -136,20 +181,37 @@ let renderHtmlString = renderHtmlNode >> HtmlString
/// Utility methods to help with time zones (and localization of their names)
module TimeZones =
open System.Collections.Generic
open PrayerTracker.Entities
/// Cross-reference between time zone Ids and their English names
let private xref =
[ "America/Chicago", "Central"
"America/Denver", "Mountain"
"America/Los_Angeles", "Pacific"
"America/New_York", "Eastern"
"America/Phoenix", "Mountain (Arizona)"
"Europe/Berlin", "Central European"
]
|> Map.ofList
/// Cross-reference between time zone Ids and their English names
let private xref = [
TimeZoneId "America/Chicago", "Central"
TimeZoneId "America/Denver", "Mountain"
TimeZoneId "America/Los_Angeles", "Pacific"
TimeZoneId "America/New_York", "Eastern"
TimeZoneId "America/Phoenix", "Mountain (Arizona)"
TimeZoneId "Europe/Berlin", "Central European"
]
/// Get the name of a time zone, given its Id
let name tzId (s : IStringLocalizer) =
try s.[xref.[tzId]]
with :? KeyNotFoundException -> LocalizedString (tzId, tzId)
/// Get the name of a time zone, given its Id
let name timeZoneId (s : IStringLocalizer) =
match xref |> List.tryFind (fun it -> fst it = timeZoneId) with
| Some tz -> s[snd tz]
| None ->
let tzId = TimeZoneId.toString timeZoneId
LocalizedString (tzId, tzId)
/// All known time zones in their defined order
let all = xref |> List.map fst
open Giraffe.ViewEngine.Htmx
/// Known htmx targets
module Target =
/// htmx links target the body element
let body = _hxTarget "body"
/// htmx links target the #pt-body element
let content = _hxTarget "#pt-body"

View File

@@ -1,262 +1,259 @@
/// Views associated with the home page, or those that don't fit anywhere else
module PrayerTracker.Views.Home
open Giraffe.ViewEngine
open Microsoft.AspNetCore.Html
open PrayerTracker.ViewModels
open System.IO
open Giraffe.ViewEngine
open PrayerTracker.ViewModels
/// The error page
let error code vi =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/Error"
use sw = new StringWriter ()
let raw = rawLocText sw
let is404 = "404" = code
let pageTitle = match is404 with true -> "Page Not Found" | false -> "Server Error"
[ yield!
match is404 with
| true ->
[ p [] [
raw l.["The page you requested cannot be found."]
raw l.["Please use your &ldquo;Back&rdquo; button to return to {0}.", s.["PrayerTracker"]]
]
p [] [
raw l.["If you reached this page from a link within {0}, please copy the link from the browser's address bar, and send it to support, along with the group for which you were currently authenticated (if any).",
s.["PrayerTracker"]]
]
]
| false ->
[ p [] [
raw l.["An error ({0}) has occurred.", code]
raw l.["Please use your &ldquo;Back&rdquo; button to return to {0}.", s.["PrayerTracker"]]
]
]
br []
hr []
div [ _style "font-size:70%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',sans-serif" ] [
img [ _src $"""/img/%A{s.["footer_en"]}.png"""
_alt $"""%A{s.["PrayerTracker"]} %A{s.["from Bit Badger Solutions"]}"""
_title $"""%A{s.["PrayerTracker"]} %A{s.["from Bit Badger Solutions"]}"""
_style "vertical-align:text-bottom;" ]
str vi.version
]
let error code viewInfo =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/Error"
use sw = new StringWriter ()
let raw = rawLocText sw
let is404 = "404" = code
let pageTitle = if is404 then "Page Not Found" else "Server Error"
[ yield!
if is404 then
[ p [] [
raw l["The page you requested cannot be found."]
raw l["Please use your &ldquo;Back&rdquo; button to return to {0}.", s["PrayerTracker"]]
]
p [] [
raw l["If you reached this page from a link within {0}, please copy the link from the browser's address bar, and send it to support, along with the group for which you were currently authenticated (if any).",
s["PrayerTracker"]]
]
]
else
[ p [] [
raw l["An error ({0}) has occurred.", code]
raw l["Please use your &ldquo;Back&rdquo; button to return to {0}.", s["PrayerTracker"]]
]
]
br []
hr []
div [ _style "font-size:70%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen-Sans,Ubuntu,Cantarell,'Helvetica Neue',sans-serif" ] [
img [ _src $"""/img/%A{s["footer_en"]}.png"""
_alt $"""%A{s["PrayerTracker"]} %A{s["from Bit Badger Solutions"]}"""
_title $"""%A{s["PrayerTracker"]} %A{s["from Bit Badger Solutions"]}"""
_style "vertical-align:text-bottom;" ]
str viewInfo.Version
]
]
|> div []
|> Layout.bare pageTitle
|> div []
|> Layout.bare pageTitle
/// The home page
let index vi =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/Index"
use sw = new StringWriter ()
let raw = rawLocText sw
[ p [] [
raw l.["Welcome to <strong>{0}</strong>!", s.["PrayerTracker"]]
space
raw l.["{0} is an interactive website that provides churches, Sunday School classes, and other organizations an easy way to keep up with their prayer requests.",
s.["PrayerTracker"]]
space
raw l.["It is provided at no charge, as a ministry and a community service."]
]
h4 [] [ raw l.["What Does It Do?"] ]
p [] [
raw l.["{0} has what you need to make maintaining a prayer request list a breeze.", s.["PrayerTracker"]]
space
raw l.["Some of the things it can do..."]
]
ul [] [
li [] [
raw l.["It drops old requests off the list automatically."]
space
raw l.["Requests other than “{0}” requests will expire at 14 days, though this can be changed by the organization.",
s.["Long-Term Requests"]]
space
raw l.["This expiration is based on the last update, not the initial request."]
space
raw l.["(And, once requests do “drop off”, they are not gone - they may be recovered if needed.)"]
let index viewInfo =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/Index"
use sw = new StringWriter ()
let raw = rawLocText sw
[ p [] [
raw l["Welcome to <strong>{0}</strong>!", s["PrayerTracker"]]
space
raw l["{0} is an interactive website that provides churches, Sunday School classes, and other organizations an easy way to keep up with their prayer requests.",
s["PrayerTracker"]]
space
raw l["It is provided at no charge, as a ministry and a community service."]
]
li [] [
raw l.["Requests can be viewed any time."]
space
raw l.["Lists can be made public, or they can be secured with a password, if desired."]
h4 [] [ raw l["What Does It Do?"] ]
p [] [
raw l["{0} has what you need to make maintaining a prayer request list a breeze.", s["PrayerTracker"]]
space
raw l["Some of the things it can do..."]
]
li [] [
raw l.["Lists can be e-mailed to a pre-defined list of members."]
space
raw l.["This can be useful for folks who may not be able to write down all the requests during class, but want a list so that they can pray for them the rest of week."]
space
raw l.["E-mails are sent individually to each person, which keeps the e-mail list private and keeps the messages from being flagged as spam."]
ul [] [
li [] [
raw l["It drops old requests off the list automatically."]
space
raw l["Requests other than “{0}” requests will expire at 14 days, though this can be changed by the organization.",
s["Long-Term Requests"]]
space
raw l["This expiration is based on the last update, not the initial request."]
space
raw l["(And, once requests do “drop off”, they are not gone - they may be recovered if needed.)"]
]
li [] [
raw l["Requests can be viewed any time."]
space
raw l["Lists can be made public, or they can be secured with a password, if desired."]
]
li [] [
raw l["Lists can be e-mailed to a pre-defined list of members."]
space
raw l["This can be useful for folks who may not be able to write down all the requests during class, but want a list so that they can pray for them the rest of week."]
space
raw l["E-mails are sent individually to each person, which keeps the e-mail list private and keeps the messages from being flagged as spam."]
]
li [] [
raw l["The look and feel of the list can be configured for each group."]
space
raw l["All fonts, colors, and sizes can be customized."]
space
raw l["This allows for configuration of large-print lists, among other things."]
]
]
li [] [
raw l.["The look and feel of the list can be configured for each group."]
space
raw l.["All fonts, colors, and sizes can be customized."]
space
raw l.["This allows for configuration of large-print lists, among other things."]
h4 [] [ raw l["How Can Your Organization Use {0}?", s["PrayerTracker"]] ]
p [] [
raw l["Like Gods gift of salvation, {0} is free for the asking for any church, Sunday School class, or other organization who wishes to use it.",
s["PrayerTracker"]]
space
raw l["If your organization would like to get set up, just <a href=\"mailto:daniel@djs-consulting.com?subject=New%20{0}%20Class\">e-mail</a> Daniel and let him know.",
s["PrayerTracker"]]
]
h4 [] [ raw l["Do I Have to Register to See the Requests?"] ]
p [] [
raw l["This depends on the group."]
space
raw l["Lists can be configured to be password-protected, but they do not have to be."]
space
raw l["If you click on the “{0}” link above, you will see a list of groups - those that do not indicate that they require logging in are publicly viewable.",
s["View Request List"]]
]
h4 [] [ raw l["How Does It Work?"] ]
p [] [
raw l["Check out the “{0}” link above - it details each of the processes and how they work.", s["Help"]]
]
]
h4 [] [ raw l.["How Can Your Organization Use {0}?", s.["PrayerTracker"]] ]
p [] [
raw l.["Like Gods gift of salvation, {0} is free for the asking for any church, Sunday School class, or other organization who wishes to use it.",
s.["PrayerTracker"]]
space
raw l.["If your organization would like to get set up, just <a href=\"mailto:daniel@djs-consulting.com?subject=New%20{0}%20Class\">e-mail</a> Daniel and let him know.",
s.["PrayerTracker"]]
]
h4 [] [ raw l.["Do I Have to Register to See the Requests?"] ]
p [] [
raw l.["This depends on the group."]
space
raw l.["Lists can be configured to be password-protected, but they do not have to be."]
space
raw l.["If you click on the “{0}” link above, you will see a list of groups - those that do not indicate that they require logging in are publicly viewable.",
s.["View Request List"]]
]
h4 [] [ raw l.["How Does It Work?"] ]
p [] [
raw l.["Check out the “{0}” link above - it details each of the processes and how they work.", s.["Help"]]
]
]
|> Layout.Content.standard
|> Layout.standard vi "Welcome!"
|> Layout.Content.standard
|> Layout.standard viewInfo "Welcome!"
/// Privacy Policy page
let privacyPolicy vi =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/PrivacyPolicy"
use sw = new StringWriter ()
let raw = rawLocText sw
[ p [ _class "pt-right-text" ] [ small[] [ em [] [ raw l.["(as of July 31, 2018)"] ] ] ]
p [] [
raw l.["The nature of the service is one where privacy is a must."]
space
raw l.["The items below will help you understand the data we collect, access, and store on your behalf as you use this service."]
]
h3 [] [ raw l.["What We Collect"] ]
ul [] [
li [] [
strong [] [ raw l.["Identifying Data"] ]
rawText " &ndash; "
raw l.["{0} stores the first and last names, e-mail addresses, and hashed passwords of all authorized users.", s.["PrayerTracker"]]
space
raw l.["Users are also associated with one or more small groups."]
let privacyPolicy viewInfo =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/PrivacyPolicy"
use sw = new StringWriter ()
let raw = rawLocText sw
[ p [ _class "pt-right-text" ] [ small [] [ em [] [ raw l["(as of July 31, 2018)"] ] ] ]
p [] [
raw l["The nature of the service is one where privacy is a must."]
space
raw l["The items below will help you understand the data we collect, access, and store on your behalf as you use this service."]
]
li [] [
strong [] [ raw l.["User Provided Data"] ]
rawText " &ndash; "
raw l.["{0} stores the text of prayer requests.", s.["PrayerTracker"]]
space
raw l.["It also stores names and e-mail addreses of small group members, and plain-text passwords for small groups with password-protected lists."]
h3 [] [ raw l["What We Collect"] ]
ul [] [
li [] [
strong [] [ raw l["Identifying Data"] ]
rawText " &ndash; "
raw l["{0} stores the first and last names, e-mail addresses, and hashed passwords of all authorized users.",
s["PrayerTracker"]]
space
raw l["Users are also associated with one or more small groups."]
]
li [] [
strong [] [ raw l["User Provided Data"] ]
rawText " &ndash; "
raw l["{0} stores the text of prayer requests.", s["PrayerTracker"]]
space
raw l["It also stores names and e-mail addresses of small group members, and plain-text passwords for small groups with password-protected lists."]
]
]
]
h3 [] [ raw l.["How Your Data Is Accessed / Secured"] ]
ul [] [
li [] [
raw l.["While you are signed in, {0} utilizes a session cookie, and transmits that cookie to the server to establish your identity.",
s.["PrayerTracker"]]
space
raw l.["If you utilize the “{0}” box on sign in, a second cookie is stored, and transmitted to establish a session; this cookie is removed by clicking the “{1}” link.",
s.["Remember Me"], s.["Log Off"]]
space
raw l.["Both of these cookies are encrypted, both in your browser and in transit."]
space
raw l.["Finally, a third cookie is used to maintain your currently selected language, so that this selection is maintained across browser sessions."]
h3 [] [ raw l["How Your Data Is Accessed / Secured"] ]
ul [] [
li [] [
raw l["While you are signed in, {0} utilizes a session cookie, and transmits that cookie to the server to establish your identity.",
s["PrayerTracker"]]
space
raw l["If you utilize the “{0}” box on sign in, a second cookie is stored, and transmitted to establish a session; this cookie is removed by clicking the “{1}” link.",
s["Remember Me"], s["Log Off"]]
space
raw l["Both of these cookies are encrypted, both in your browser and in transit."]
space
raw l["Finally, a third cookie is used to maintain your currently selected language, so that this selection is maintained across browser sessions."]
]
li [] [
raw l["Data for your small group is returned to you, as required, to display and edit."]
space
raw l["{0} also sends e-mails on behalf of the configured owner of a small group; these e-mails are sent from prayer@djs-consulting.com, with the “Reply To” header set to the configured owner of the small group.",
s["PrayerTracker"]]
space
raw l["Distinct e-mails are sent to each user, as to not disclose the other recipients."]
space
raw l["On the server, all data is stored in a controlled-access database."]
]
li [] [
raw l["Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; backups are preserved for the prior 7 days, and backups from the 1st and 15th are preserved for 3 months."]
space
raw l["These backups are stored in a private cloud data repository."]
]
li [] [
raw l["Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."]
]
]
li [] [
raw l.["Data for your small group is returned to you, as required, to display and edit."]
space
raw l.["{0} also sends e-mails on behalf of the configured owner of a small group; these e-mails are sent from prayer@djs-consulting.com, with the “Reply To” header set to the configured owner of the small group.",
s.["PrayerTracker"]]
space
raw l.["Distinct e-mails are sent to each user, as to not disclose the other recipients."]
space
raw l.["On the server, all data is stored in a controlled-access database."]
h3 [] [ raw l["Removing Your Data"] ]
p [] [
raw l["At any time, you may choose to discontinue using {0}; just e-mail Daniel, as you did to register, and request deletion of your small group.",
s["PrayerTracker"]]
]
li [] [
raw l.["Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; backups are preserved for the prior 7 days, and backups from the 1st and 15th are preserved for 3 months."]
space
raw l.["These backups are stored in a private cloud data repository."]
]
li [] [
raw l.["Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."]
]
]
h3 [] [ raw l.["Removing Your Data"] ]
p [] [
raw l.["At any time, you may choose to discontinue using {0}; just e-mail Daniel, as you did to register, and request deletion of your small group.",
s.["PrayerTracker"]]
]
]
|> Layout.Content.standard
|> Layout.standard vi "Privacy Policy"
|> Layout.Content.standard
|> Layout.standard viewInfo "Privacy Policy"
/// Terms of Service page
let termsOfService vi =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/TermsOfService"
use sw = new StringWriter ()
let raw = rawLocText sw
let ppLink =
a [ _href "/web/legal/privacy-policy" ] [ str (s.["Privacy Policy"].Value.ToLower ()) ]
|> renderHtmlString
let termsOfService viewInfo =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/TermsOfService"
use sw = new StringWriter ()
let raw = rawLocText sw
let ppLink =
a [ _href "/legal/privacy-policy" ] [ str (s["Privacy Policy"].Value.ToLower ()) ]
|> renderHtmlString
[ p [ _class "pt-right-text" ] [ small [] [ em [] [ raw l.["(as of May 24, 2018)"] ] ] ]
h3 [] [ str "1. "; raw l.["Acceptance of Terms"] ]
p [] [
raw l.["By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are responsible to ensure that your use of this site complies with all applicable laws."]
space
raw l.["Your continued use of this site implies your acceptance of these terms."]
]
h3 [] [ str "2. "; raw l.["Description of Service and Registration"] ]
p [] [
raw l.["{0} is a service that allows individuals to enter and amend prayer requests on behalf of organizations.",
s.["PrayerTracker"]]
space
raw l.["Registration is accomplished via e-mail to Daniel Summers (daniel at bitbadger dot solutions, substituting punctuation)."]
space
raw l.["See our {0} for details on the personal (user) information we maintain.", ppLink]
]
h3 [] [ str "3. "; raw l.["Liability"] ]
p [] [
raw l.["This service is provided “as is”, and no warranty (express or implied) exists."]
space
raw l.["The service and its developers may not be held liable for any damages that may arise through the use of this service."]
]
h3 [] [ str "4. "; raw l.["Updates to Terms"] ]
p [] [
raw l.["These terms and conditions may be updated at any time."]
space
raw l.["When these terms are updated, users will be notified by a system-generated announcement."]
space
raw l.["Additionally, the date at the top of this page will be updated."]
]
hr []
p [] [ raw l.["You may also wish to review our {0} to learn how we handle your data.", ppLink] ]
[ p [ _class "pt-right-text" ] [ small [] [ em [] [ raw l["(as of May 24, 2018)"] ] ] ]
h3 [] [ str "1. "; raw l["Acceptance of Terms"] ]
p [] [
raw l["By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you are responsible to ensure that your use of this site complies with all applicable laws."]
space
raw l["Your continued use of this site implies your acceptance of these terms."]
]
h3 [] [ str "2. "; raw l["Description of Service and Registration"] ]
p [] [
raw l["{0} is a service that allows individuals to enter and amend prayer requests on behalf of organizations.",
s["PrayerTracker"]]
space
raw l["Registration is accomplished via e-mail to Daniel Summers (daniel at bitbadger dot solutions, substituting punctuation)."]
space
raw l["See our {0} for details on the personal (user) information we maintain.", ppLink]
]
h3 [] [ str "3. "; raw l["Liability"] ]
p [] [
raw l["This service is provided “as is”, and no warranty (express or implied) exists."]
space
raw l["The service and its developers may not be held liable for any damages that may arise through the use of this service."]
]
h3 [] [ str "4. "; raw l["Updates to Terms"] ]
p [] [
raw l["These terms and conditions may be updated at any time."]
space
raw l["When these terms are updated, users will be notified by a system-generated announcement."]
space
raw l["Additionally, the date at the top of this page will be updated."]
]
hr []
p [] [ raw l["You may also wish to review our {0} to learn how we handle your data.", ppLink] ]
]
|> Layout.Content.standard
|> Layout.standard vi "Terms of Service"
|> Layout.Content.standard
|> Layout.standard viewInfo "Terms of Service"
/// View for unauthorized page
let unauthorized vi =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/Unauthorized"
use sw = new StringWriter ()
let raw = rawLocText sw
[ p [] [
raw l.["If you feel you have reached this page in error, please <a href=\"mailto:daniel@djs-consulting.com?Subject={0}%20Unauthorized%20Access\">contact Daniel</a> and provide the details as to what you were doing (i.e., what link did you click, where had you been, etc.).",
s.["PrayerTracker"]]
]
p [] [
raw l.["Otherwise, you may select one of the links above to get back into an authorized portion of {0}.",
s.["PrayerTracker"]]
]
let unauthorized viewInfo =
let s = I18N.localizer.Force ()
let l = I18N.forView "Home/Unauthorized"
use sw = new StringWriter ()
let raw = rawLocText sw
[ p [] [
raw l["If you feel you have reached this page in error, please <a href=\"mailto:daniel@djs-consulting.com?Subject={0}%20Unauthorized%20Access\">contact Daniel</a> and provide the details as to what you were doing (i.e., what link did you click, where had you been, etc.).",
s["PrayerTracker"]]
]
p [] [
raw l["Otherwise, you may select one of the links above to get back into an authorized portion of {0}.",
s["PrayerTracker"]]
]
]
|> Layout.Content.standard
|> Layout.standard vi "Unauthorized Access"
|> Layout.Content.standard
|> Layout.standard viewInfo "Unauthorized Access"

View File

@@ -11,12 +11,12 @@ let private resAsmName = typeof<Common>.Assembly.GetName().Name
/// Set up the string and HTML localizer factories
let setUpFactories fac =
stringLocFactory <- fac
htmlLocFactory <- HtmlLocalizerFactory stringLocFactory
stringLocFactory <- fac
htmlLocFactory <- HtmlLocalizerFactory stringLocFactory
/// An instance of the common string localizer
let localizer = lazy (stringLocFactory.Create ("Common", resAsmName))
/// Get a view localizer
let forView (view : string) =
htmlLocFactory.Create ($"""Views.{view.Replace ('/', '.')}""", resAsmName)
htmlLocFactory.Create ($"""Views.{view.Replace ('/', '.')}""", resAsmName)

View File

@@ -2,289 +2,365 @@
module PrayerTracker.Views.Layout
open Giraffe.ViewEngine
open PrayerTracker
open Giraffe.ViewEngine.Accessibility
open PrayerTracker.ViewModels
open System
open System.Globalization
/// Get the two-character language code for the current request
let langCode () = match CultureInfo.CurrentCulture.Name.StartsWith "es" with true -> "es" | _ -> "en"
let langCode () = if CultureInfo.CurrentCulture.Name.StartsWith "es" then "es" else "en"
/// Navigation items
module Navigation =
/// Top navigation bar
let top m =
let s = PrayerTracker.Views.I18N.localizer.Force ()
let menuSpacer = rawText "&nbsp; "
let leftLinks = [
match m.user with
| Some u ->
li [ _class "dropdown" ] [
a [ _class "dropbtn"; _role "button"; _aria "label" s.["Requests"].Value; _title s.["Requests"].Value ]
[ icon "question_answer"; space; locStr s.["Requests"]; space; icon "keyboard_arrow_down" ]
div [ _class "dropdown-content"; _role "menu" ] [
a [ _href "/web/prayer-requests" ] [ icon "compare_arrows"; menuSpacer; locStr s.["Maintain"] ]
a [ _href "/web/prayer-requests/view" ] [ icon "list"; menuSpacer; locStr s.["View List"] ]
]
]
li [ _class "dropdown" ] [
a [ _class "dropbtn"; _role "button"; _aria "label" s.["Group"].Value; _title s.["Group"].Value ]
[ icon "group"; space; locStr s.["Group"]; space; icon "keyboard_arrow_down" ]
div [ _class "dropdown-content"; _role "menu" ] [
a [ _href "/web/small-group/members" ] [ icon "email"; menuSpacer; locStr s.["Maintain Group Members"] ]
a [ _href "/web/small-group/announcement" ] [ icon "send"; menuSpacer; locStr s.["Send Announcement"] ]
a [ _href "/web/small-group/preferences" ] [ icon "build"; menuSpacer; locStr s.["Change Preferences"] ]
]
]
match u.isAdmin with
| true ->
li [ _class "dropdown" ] [
a [ _class "dropbtn"; _role "button"; _aria "label" s.["Administration"].Value; _title s.["Administration"].Value ]
[ icon "settings"; space; locStr s.["Administration"]; space; icon "keyboard_arrow_down" ]
div [ _class "dropdown-content"; _role "menu" ] [
a [ _href "/web/churches" ] [ icon "home"; menuSpacer; locStr s.["Churches"] ]
a [ _href "/web/small-groups" ] [ icon "send"; menuSpacer; locStr s.["Groups"] ]
a [ _href "/web/users" ] [ icon "build"; menuSpacer; locStr s.["Users"] ]
]
]
| false -> ()
| None ->
match m.group with
| Some _ ->
li [] [
a [ _href "/web/prayer-requests/view"
_aria "label" s.["View Request List"].Value
_title s.["View Request List"].Value ]
[ icon "list"; space; locStr s.["View Request List"] ]
]
| None ->
li [ _class "dropdown" ] [
a [ _class "dropbtn"; _role "button"; _aria "label" s.["Log On"].Value; _title s.["Log On"].Value ]
[ icon "security"; space; locStr s.["Log On"]; space; icon "keyboard_arrow_down" ]
div [ _class "dropdown-content"; _role "menu" ] [
a [ _href "/web/user/log-on" ] [ icon "person"; menuSpacer; locStr s.["User"] ]
a [ _href "/web/small-group/log-on" ] [ icon "group"; menuSpacer; locStr s.["Group"] ]
]
]
li [] [
a [ _href "/web/prayer-requests/lists"
_aria "label" s.["View Request List"].Value
_title s.["View Request List"].Value ]
[ icon "list"; space; locStr s.["View Request List"] ]
]
li [] [
a [ _href $"https://docs.prayer.bitbadger.solutions/{langCode ()}"
_aria "label" s.["Help"].Value;
_title s.["View Help"].Value
_target "_blank"
]
[ icon "help"; space; locStr s.["Help"] ]
]
]
let rightLinks =
match m.group with
| Some _ ->
[ match m.user with
| Some _ ->
li [] [
a [ _href "/web/user/password"
_aria "label" s.["Change Your Password"].Value
_title s.["Change Your Password"].Value ]
[ icon "lock"; space; locStr s.["Change Your Password"] ]
]
| None -> ()
li [] [
a [ _href "/web/log-off"; _aria "label" s.["Log Off"].Value; _title s.["Log Off"].Value ]
[ icon "power_settings_new"; space; locStr s.["Log Off"] ]
]
]
| None -> List.empty
header [ _class "pt-title-bar" ] [
section [ _class "pt-title-bar-left" ] [
span [ _class "pt-title-bar-home" ] [
a [ _href "/web/"; _title s.["Home"].Value ] [ locStr s.["PrayerTracker"] ]
]
ul [] leftLinks
]
section [ _class "pt-title-bar-center" ] []
section [ _class "pt-title-bar-right"; _role "toolbar" ] [
ul [] rightLinks
]
]
/// Identity bar (below top nav)
let identity m =
let s = I18N.localizer.Force ()
header [ _id "pt-language" ] [
div [] [
span [ _class "u" ] [ locStr s.["Language"]; rawText ": " ]
match langCode () with
| "es" ->
locStr s.["Spanish"]
rawText " &nbsp; &bull; &nbsp; "
a [ _href "/web/language/en" ] [ locStr s.["Change to English"] ]
| _ ->
locStr s.["English"]
rawText " &nbsp; &bull; &nbsp; "
a [ _href "/web/language/es" ] [ locStr s.["Cambie a Español"] ]
]
match m.group with
| Some g ->
[ match m.user with
/// Top navigation bar
let top m =
let s = I18N.localizer.Force ()
let menuSpacer = rawText "&nbsp; "
let _dropdown = _class "dropdown-btn"
let leftLinks = [
match m.User with
| Some u ->
span [ _class "u" ] [ locStr s.["Currently Logged On"] ]
rawText "&nbsp; &nbsp;"
icon "person"
strong [] [ str u.fullName ]
rawText "&nbsp; &nbsp; "
li [ _class "dropdown" ] [
a [ _dropdown; _ariaLabel s["Requests"].Value; _title s["Requests"].Value; _roleButton ] [
icon "question_answer"; space; locStr s["Requests"]; space; icon "keyboard_arrow_down"
]
div [ _class "dropdown-content"; _roleMenuBar ] [
a [ _href "/prayer-requests"; _roleMenuItem ] [
icon "compare_arrows"; menuSpacer; locStr s["Maintain"]
]
a [ _href "/prayer-requests/view"; _roleMenuItem ] [
icon "list"; menuSpacer; locStr s["View List"]
]
]
]
li [ _class "dropdown" ] [
a [ _dropdown; _ariaLabel s["Group"].Value; _title s["Group"].Value; _roleButton ] [
icon "group"; space; locStr s["Group"]; space; icon "keyboard_arrow_down"
]
div [ _class "dropdown-content"; _roleMenuBar ] [
a [ _href "/small-group/members"; _roleMenuItem ] [
icon "email"; menuSpacer; locStr s["Maintain Group Members"]
]
a [ _href "/small-group/announcement"; _roleMenuItem ] [
icon "send"; menuSpacer; locStr s["Send Announcement"]
]
a [ _href "/small-group/preferences"; _roleMenuItem ] [
icon "build"; menuSpacer; locStr s["Change Preferences"]
]
]
]
if u.IsAdmin then
li [ _class "dropdown" ] [
a [ _dropdown
_ariaLabel s["Administration"].Value
_title s["Administration"].Value
_roleButton ] [
icon "settings"; space; locStr s["Administration"]; space; icon "keyboard_arrow_down"
]
div [ _class "dropdown-content"; _roleMenuBar ] [
a [ _href "/churches"; _roleMenuItem ] [ icon "home"; menuSpacer; locStr s["Churches"] ]
a [ _href "/small-groups"; _roleMenuItem ] [ icon "send"; menuSpacer; locStr s["Groups"] ]
a [ _href "/users"; _roleMenuItem ] [ icon "build"; menuSpacer; locStr s["Users"] ]
]
]
| None ->
locStr s.["Logged On as a Member of"]
rawText "&nbsp; "
icon "group"
space
match m.user with
| Some _ -> a [ _href "/web/small-group" ] [ strong [] [ str g.name ] ]
| None -> strong [] [ str g.name ]
rawText " &nbsp;"
match m.Group with
| Some _ ->
li [] [
a [ _href "/prayer-requests/view"
_ariaLabel s["View Request List"].Value
_title s["View Request List"].Value ] [
icon "list"; space; locStr s["View Request List"]
]
]
| None ->
li [ _class "dropdown" ] [
a [ _dropdown; _ariaLabel s["Log On"].Value; _title s["Log On"].Value; _roleButton ] [
icon "security"; space; locStr s["Log On"]; space; icon "keyboard_arrow_down"
]
div [ _class "dropdown-content"; _roleMenuBar ] [
a [ _href "/user/log-on"; _roleMenuItem ] [ icon "person"; menuSpacer; locStr s["User"] ]
a [ _href "/small-group/log-on"; _roleMenuItem ] [
icon "group"; menuSpacer; locStr s["Group"]
]
]
]
li [] [
a [ _href "/prayer-requests/lists"
_ariaLabel s["View Request List"].Value
_title s["View Request List"].Value ] [
icon "list"; space; locStr s["View Request List"]
]
]
li [] [
a [ _href $"https://docs.prayer.bitbadger.solutions/{langCode ()}"
_ariaLabel s["Help"].Value
_title s["View Help"].Value
_target "_blank"
_relNoOpener ] [
icon "help"; space; locStr s["Help"]
]
]
| None -> []
|> div []
]
]
let rightLinks =
match m.Group with
| Some _ ->
[ match m.User with
| Some _ ->
li [] [
a [ _href "/user/password"
_ariaLabel s["Change Your Password"].Value
_title s["Change Your Password"].Value ] [
icon "lock"; space; locStr s["Change Your Password"]
]
]
| None -> ()
li [] [
a [ _href "/log-off"; _ariaLabel s["Log Off"].Value; _title s["Log Off"].Value; Target.body ] [
icon "power_settings_new"; space; locStr s["Log Off"]
]
]
]
| None -> []
header [ _class "pt-title-bar"; Target.content ] [
section [ _class "pt-title-bar-left"; _ariaLabel "Left side of top menu" ] [
span [ _class "pt-title-bar-home" ] [
a [ _href "/"; _title s["Home"].Value ] [ locStr s["PrayerTracker"] ]
]
ul [] leftLinks
]
section [ _class "pt-title-bar-center"; _ariaLabel "Empty center space in top menu" ] []
section [ _class "pt-title-bar-right"; _roleToolBar; _ariaLabel "Right side of top menu" ] [
ul [] rightLinks
]
]
/// Identity bar (below top nav)
let identity m =
let s = I18N.localizer.Force ()
header [ _id "pt-language"; Target.body ] [
div [] [
span [ _title s["Language"].Value ] [ icon "record_voice_over"; space ]
match langCode () with
| "es" ->
strong [] [ locStr s["Spanish"] ]
rawText " &nbsp; &nbsp; "
a [ _href "/language/en" ] [ locStr s["Change to English"] ]
| _ ->
strong [] [ locStr s["English"] ]
rawText " &nbsp; &nbsp; "
a [ _href "/language/es" ] [ locStr s["Cambie a Español"] ]
]
match m.Group with
| Some g ->
[ match m.User with
| Some u ->
span [ _class "u" ] [ locStr s["Currently Logged On"] ]
rawText "&nbsp; &nbsp;"
icon "person"
strong [] [ str u.Name ]
rawText "&nbsp; &nbsp; "
| None ->
locStr s["Logged On as a Member of"]
rawText "&nbsp; "
icon "group"
space
match m.User with
| Some _ -> a [ _href "/small-group"; Target.content ] [ strong [] [ str g.Name ] ]
| None -> strong [] [ str g.Name ]
]
| None -> []
|> div []
]
/// Content layouts
module Content =
/// Content layout that tops at 60rem
let standard = div [ _class "pt-content" ]
/// Content layout that tops at 60rem
let standard = div [ _class "pt-content" ]
/// Content layout that uses the full width of the browser window
let wide = div [ _class "pt-content pt-full-width" ]
/// Content layout that uses the full width of the browser window
let wide = div [ _class "pt-content pt-full-width" ]
/// Separator for parts of the title
let private titleSep = rawText " &#xab; "
let private commonHead =
[ meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
/// Common HTML head tag items
let private commonHead = [
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
meta [ _name "generator"; _content "Giraffe" ]
link [ _rel "stylesheet"; _href "https://fonts.googleapis.com/icon?family=Material+Icons" ]
link [ _rel "stylesheet"; _href "/css/app.css" ]
script [ _src "/js/app.js" ] []
]
]
/// Render the <head> portion of the page
let private htmlHead m pageTitle =
let s = I18N.localizer.Force ()
head [] [
meta [ _charset "UTF-8" ]
title [] [ locStr pageTitle; titleSep; locStr s.["PrayerTracker"] ]
yield! commonHead
for cssFile in m.style do
link [ _rel "stylesheet"; _href $"/css/{cssFile}.css"; _type "text/css" ]
for jsFile in m.script do
script [ _src $"/js/{jsFile}.js" ] []
let private htmlHead viewInfo pgTitle =
let s = I18N.localizer.Force ()
head [] [
meta [ _charset "UTF-8" ]
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker"] ]
yield! commonHead
for cssFile in viewInfo.Style do
link [ _rel "stylesheet"; _href $"/css/{cssFile}.css"; _type "text/css" ]
]
open Giraffe.ViewEngine.Htmx
/// Render a link to the help page for the current page
let private helpLink link =
let s = I18N.localizer.Force ()
sup [] [
a [ _href link
_title s.["Click for Help on This Page"].Value
_onclick $"return PT.showHelp('{link}')" ] [
icon "help_outline"
]
let s = I18N.localizer.Force ()
sup [ _class "pt-help-link" ] [
a [ _href link
_title s["Click for Help on This Page"].Value
_onclick $"return PT.showHelp('{link}')"
_hxNoBoost ] [
iconSized 18 "help_outline"
]
]
/// Render the page title, and optionally a help link
let private renderPageTitle m pageTitle =
h2 [ _id "pt-page-title" ] [
match m.helpLink with Some link -> Help.fullLink (langCode ()) link |> helpLink | None -> ()
locStr pageTitle
let private renderPageTitle viewInfo pgTitle =
h2 [ _id "pt-page-title" ] [
match viewInfo.HelpLink with
| Some link -> PrayerTracker.Utils.Help.fullLink (langCode ()) link |> helpLink
| None -> ()
locStr pgTitle
]
/// Render the messages that may need to be displayed to the user
let private messages m =
let s = I18N.localizer.Force ()
m.messages
|> List.map (fun msg ->
table [ _class $"pt-msg {msg.level.ToLower ()}" ] [
tr [] [
td [] [
match msg.level with
| "Info" -> ()
| lvl ->
strong [] [ locStr s.[lvl] ]
rawText " &#xbb; "
rawText msg.text.Value
match msg.description with
| Some desc ->
br []
div [ _class "description" ] [ rawText desc.Value ]
| None -> ()
]
]
])
let private messages viewInfo =
let s = I18N.localizer.Force ()
if List.isEmpty viewInfo.Messages then []
else
viewInfo.Messages
|> List.map (fun msg ->
div [ _class $"pt-msg {MessageLevel.toCssClass msg.Level}" ] [
match msg.Level with
| Info -> ()
| lvl ->
strong [] [ locStr s[MessageLevel.toString lvl] ]
rawText " &#xbb; "
rawText msg.Text.Value
match msg.Description with
| Some desc ->
br []
div [ _class "description" ] [ rawText desc.Value ]
| None -> ()
])
|> div [ _class "pt-messages" ]
|> List.singleton
open NodaTime
/// Render the <footer> at the bottom of the page
let private htmlFooter m =
let s = I18N.localizer.Force ()
let imgText = sprintf "%O %O" s.["PrayerTracker"] s.["from Bit Badger Solutions"]
let resultTime = TimeSpan(DateTime.Now.Ticks - m.requestStart).TotalSeconds
footer [] [
div [ _id "pt-legal" ] [
a [ _href "/web/legal/privacy-policy" ] [ locStr s.["Privacy Policy"] ]
rawText " &bull; "
a [ _href "/web/legal/terms-of-service" ] [ locStr s.["Terms of Service"] ]
rawText " &bull; "
a [ _href "https://github.com/bit-badger/PrayerTracker"
_title s.["View source code and get technical support"].Value
_target "_blank"
_rel "noopener" ] [
locStr s.["Source & Support"]
let private htmlFooter viewInfo =
let s = I18N.localizer.Force ()
let imgText = $"""%O{s["PrayerTracker"]} %O{s["from Bit Badger Solutions"]}"""
let resultTime = (SystemClock.Instance.GetCurrentInstant () - viewInfo.RequestStart).TotalSeconds
footer [ _class "pt-footer" ] [
div [ _id "pt-legal" ] [
a [ _href "/legal/privacy-policy" ] [ locStr s["Privacy Policy"] ]
rawText " &nbsp; "
a [ _href "/legal/terms-of-service" ] [ locStr s["Terms of Service"] ]
rawText " &nbsp; "
a [ _href "https://github.com/bit-badger/PrayerTracker"
_title s["View source code and get technical support"].Value
_target "_blank"
_relNoOpener ] [
locStr s["Source & Support"]
]
]
]
div [ _id "pt-footer" ] [
a [ _href "/web/"; _style "line-height:28px;" ] [
img [ _src $"""/img/%O{s.["footer_en"]}.png"""; _alt imgText; _title imgText ]
div [ _id "pt-footer" ] [
a [ _href "/"; _style "line-height:28px;" ] [
img [ _src $"""/img/%O{s["footer_en"]}.png"""
_alt imgText
_title imgText
_width "331"; _height "28" ]
]
span [ _id "pt-version" ] [ str viewInfo.Version ]
space
i [ _title s["This page loaded in {0:N3} seconds", resultTime].Value; _class "material-icons md-18" ] [
str "schedule"
]
]
str m.version
space
i [ _title s.["This page loaded in {0:N3} seconds", resultTime].Value; _class "material-icons md-18" ] [
str "schedule"
]
]
]
/// The standard layout for PrayerTracker
let standard m pageTitle (content : XmlNode) =
let s = I18N.localizer.Force ()
let ttl = s.[pageTitle]
html [ _lang "" ] [
htmlHead m ttl
body [] [
Navigation.top m
div [ _id "pt-body" ] [
Navigation.identity m
renderPageTitle m ttl
yield! messages m
content
htmlFooter m
/// The content portion of the PrayerTracker layout
let private contentSection viewInfo pgTitle (content : XmlNode) = [
Navigation.identity viewInfo
renderPageTitle viewInfo pgTitle
yield! messages viewInfo
match viewInfo.ScopedStyle with
| [] -> ()
| styles -> style [] [ rawText (styles |> String.concat " ") ]
content
htmlFooter viewInfo
match viewInfo.OnLoadScript with
| Some onLoad ->
let doCall = if onLoad.EndsWith ")" then "" else "()"
script [] [
rawText $"""
window.doOnLoad = () => {{
if (window.PT) {{
{onLoad}{doCall}
delete window.doOnLoad
}} else {{ setTimeout(window.doOnLoad, 500) }}
}}
window.doOnLoad()"""
]
]
| None -> ()
]
/// The HTML head element for partial responses
let private partialHead pgTitle =
let s = I18N.localizer.Force ()
head [] [
meta [ _charset "UTF-8" ]
title [] [ locStr pgTitle; titleSep; locStr s["PrayerTracker"] ]
]
open Giraffe.Htmx.Common
/// The body of the PrayerTracker layout
let private pageLayout viewInfo pgTitle content =
body [ _hxBoost ] [
Navigation.top viewInfo
div [ _id "pt-body"; Target.content; _hxSwap $"{HxSwap.InnerHtml} show:window:top" ]
(contentSection viewInfo pgTitle content)
match viewInfo.Layout with
| FullPage ->
Script.minified
script [ _src "/js/ckeditor/ckeditor.js" ] []
script [ _src "/js/app.js" ] []
| _ -> ()
]
/// The standard layout(s) for PrayerTracker
let standard viewInfo pageTitle content =
let s = I18N.localizer.Force ()
let pgTitle = s[pageTitle]
html [ _lang (langCode ()) ] [
match viewInfo.Layout with
| FullPage ->
htmlHead viewInfo pgTitle
pageLayout viewInfo pgTitle content
| PartialPage ->
partialHead pgTitle
pageLayout viewInfo pgTitle content
| ContentOnly ->
partialHead pgTitle
body [] (contentSection viewInfo pgTitle content)
]
/// A layout with nothing but a title and content
let bare pageTitle content =
let s = I18N.localizer.Force ()
let ttl = s.[pageTitle]
html [ _lang "" ] [
head [] [
meta [ _charset "UTF-8" ]
title [] [ locStr ttl; titleSep; locStr s.["PrayerTracker"] ]
]
body [] [ content ]
let s = I18N.localizer.Force ()
html [ _lang (langCode ()) ] [
partialHead s[pageTitle]
body [] [ content ]
]

View File

@@ -1,370 +1,353 @@
module PrayerTracker.Views.PrayerRequest
open System.Globalization
open System.IO
open Giraffe
open Giraffe.ViewEngine
open Giraffe.ViewEngine.Accessibility
open Giraffe.ViewEngine.Htmx
open Microsoft.AspNetCore.Http
open NodaTime
open PrayerTracker
open PrayerTracker.Entities
open PrayerTracker.ViewModels
open System
open System.IO
open System.Text
/// View for the prayer request edit page
let edit (m : EditRequest) today ctx vi =
let s = I18N.localizer.Force ()
let pageTitle = match m.isNew () with true -> "Add a New Request" | false -> "Edit Request"
[ form [ _action "/web/prayer-request/save"; _method "post"; _class "pt-center-columns" ] [
csrfToken ctx
input [ _type "hidden"; _name "requestId"; _value (flatGuid m.requestId) ]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "requestType" ] [ locStr s.["Request Type"] ]
ReferenceList.requestTypeList s
|> Seq.ofList
|> Seq.map (fun (typ, desc) -> typ.code, desc.Value)
|> selectList "requestType" m.requestType [ _required; _autofocus ]
]
div [ _class "pt-field" ] [
label [ _for "requestor" ] [ locStr s.["Requestor / Subject"] ]
input [ _type "text"
_name "requestor"
_id "requestor"
_value (match m.requestor with Some x -> x | None -> "") ]
]
match m.isNew () with
| true ->
div [ _class "pt-field" ] [
label [ _for "enteredDate" ] [ locStr s.["Date"] ]
input [ _type "date"; _name "enteredDate"; _id "enteredDate"; _placeholder today ]
]
| false ->
div [ _class "pt-field" ] [
div [ _class "pt-checkbox-field" ] [
br []
input [ _type "checkbox"; _name "skipDateUpdate"; _id "skipDateUpdate"; _value "True" ]
label [ _for "skipDateUpdate" ] [ locStr s.["Check to not update the date"] ]
br []
small [] [ em [] [ str (s.["Typo Corrections"].Value.ToLower ()); rawText ", etc." ] ]
let edit (model : EditRequest) today ctx viewInfo =
let s = I18N.localizer.Force ()
let pageTitle = if model.IsNew then "Add a New Request" else "Edit Request"
let vi = AppViewInfo.withOnLoadScript "PT.initCKEditor" viewInfo
form [ _action "/prayer-request/save"
_method "post"
_class "pt-center-columns"
_onsubmit "PT.updateCKEditor()"
Target.content ] [
csrfToken ctx
inputField "hidden" (nameof model.RequestId) model.RequestId []
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.RequestType) ] [ locStr s["Request Type"] ]
ReferenceList.requestTypeList s
|> Seq.ofList
|> Seq.map (fun (typ, desc) -> PrayerRequestType.toCode typ, desc.Value)
|> selectList (nameof model.RequestType) model.RequestType [ _required; _autofocus ]
]
div [ _inputField ] [
label [ _for (nameof model.Requestor) ] [ locStr s["Requestor / Subject"] ]
inputField "text" (nameof model.Requestor) (defaultArg model.Requestor "") []
]
if model.IsNew then
div [ _inputField ] [
label [ _for (nameof model.EnteredDate) ] [ locStr s["Date"] ]
inputField "date" (nameof model.EnteredDate) "" [ _placeholder today ]
]
else
div [ _inputField ] [
br []
div [ _checkboxField ] [
inputField "checkbox" (nameof model.SkipDateUpdate) "True" []
label [ _for (nameof model.SkipDateUpdate) ] [ locStr s["Check to not update the date"] ]
]
small [] [ em [] [ str (s["Typo Corrections"].Value.ToLower ()); rawText ", etc." ] ]
]
]
]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [] [ locStr s.["Expiration"] ]
ReferenceList.expirationList s ((m.isNew >> not) ())
|> List.map (fun exp ->
let radioId = $"expiration_{fst exp}"
span [ _class "text-nowrap" ] [
radio "expiration" radioId (fst exp) m.expiration
label [ _for radioId ] [ locStr (snd exp) ]
rawText " &nbsp; &nbsp; "
])
|> div [ _class "pt-center-text" ]
]
div [ _fieldRow ] [
div [ _inputField ] [
label [] [ locStr s["Expiration"] ]
span [ _group ] [
for code, name in ReferenceList.expirationList s (not model.IsNew) do
label [] [ radio (nameof model.Expiration) "" code model.Expiration; locStr name ]
]
]
]
div [ _class "pt-field-row" ] [
div [ _class "pt-field pt-editor" ] [
label [ _for "text" ] [ locStr s.["Request"] ]
textarea [ _name "text"; _id "text" ] [ str m.text ]
]
div [ _fieldRow ] [
div [ _inputFieldWith [ "pt-editor" ] ] [
label [ _for (nameof model.Text) ] [ locStr s["Request"] ]
textarea [ _name (nameof model.Text); _id (nameof model.Text) ] [ str model.Text ]
]
]
div [ _class "pt-field-row" ] [ submit [] "save" s.["Save Request"] ]
]
script [] [ rawText "PT.onLoad(PT.initCKEditor)" ]
div [ _fieldRow ] [ submit [] "save" s["Save Request"] ]
]
|> Layout.Content.standard
|> Layout.standard vi pageTitle
|> List.singleton
|> Layout.Content.standard
|> Layout.standard vi pageTitle
/// View for the request e-mail results page
let email m vi =
let s = I18N.localizer.Force ()
let pageTitle = $"""{s.["Prayer Requests"].Value} {m.listGroup.name}"""
let prefs = m.listGroup.preferences
let addresses =
m.recipients
|> List.fold (fun (acc : StringBuilder) mbr -> acc.AppendFormat(", {0} <{1}>", mbr.memberName, mbr.email))
(StringBuilder ())
[ p [ _style $"font-family:{prefs.listFonts};font-size:%i{prefs.textFontSize}pt;" ] [
locStr s.["The request list was sent to the following people, via individual e-mails"]
rawText ":"
br []
small [] [ str (addresses.Remove(0, 2).ToString ()) ]
]
span [ _class "pt-email-heading" ] [ locStr s.["HTML Format"]; rawText ":" ]
div [ _class "pt-email-canvas" ] [ rawText (m.asHtml s) ]
br []
br []
span [ _class "pt-email-heading" ] [ locStr s.["Plain-Text Format"]; rawText ":" ]
div[ _class "pt-email-canvas" ] [ pre [] [ str (m.asText s) ] ]
let email model viewInfo =
let s = I18N.localizer.Force ()
let pageTitle = $"""{s["Prayer Requests"].Value} {model.SmallGroup.Name}"""
let prefs = model.SmallGroup.Preferences
let addresses = model.Recipients |> List.map (fun mbr -> $"{mbr.Name} <{mbr.Email}>") |> String.concat ", "
[ p [ _style $"font-family:{prefs.FontStack};font-size:%i{prefs.TextFontSize}pt;" ] [
locStr s["The request list was sent to the following people, via individual e-mails"]
rawText ":"
br []
small [] [ str addresses ]
]
span [ _class "pt-email-heading" ] [ locStr s["HTML Format"]; rawText ":" ]
div [ _class "pt-email-canvas" ] [ rawText (model.AsHtml s) ]
br []
br []
span [ _class "pt-email-heading" ] [ locStr s["Plain-Text Format"]; rawText ":" ]
div [ _class "pt-email-canvas" ] [ pre [] [ str (model.AsText s) ] ]
]
|> Layout.Content.standard
|> Layout.standard vi pageTitle
|> Layout.Content.standard
|> Layout.standard viewInfo pageTitle
/// View for a small group's public prayer request list
let list (m : RequestList) vi =
[ br []
I18N.localizer.Force () |> (m.asHtml >> rawText)
let list (model : RequestList) viewInfo =
[ br []
I18N.localizer.Force () |> (model.AsHtml >> rawText)
]
|> Layout.Content.standard
|> Layout.standard vi "View Request List"
|> Layout.Content.standard
|> Layout.standard viewInfo "View Request List"
/// View for the prayer request lists page
let lists (grps : SmallGroup list) vi =
let s = I18N.localizer.Force ()
let l = I18N.forView "Requests/Lists"
use sw = new StringWriter ()
let raw = rawLocText sw
[ p [] [
raw l.["The groups listed below have either public or password-protected request lists."]
space
raw l.["Those with list icons are public, and those with log on icons are password-protected."]
space
raw l.["Click the appropriate icon to log on or view the request list."]
]
match grps.Length with
| 0 -> p [] [ raw l.["There are no groups with public or password-protected request lists."] ]
| count ->
tableSummary count s
table [ _class "pt-table pt-action-table" ] [
thead [] [
tr [] [
th [] [ locStr s.["Actions"] ]
th [] [ locStr s.["Church"] ]
th [] [ locStr s.["Group"] ]
]
let lists (groups : SmallGroupInfo list) viewInfo =
let s = I18N.localizer.Force ()
let l = I18N.forView "Requests/Lists"
use sw = new StringWriter ()
let raw = rawLocText sw
let vi = AppViewInfo.withScopedStyles [ "#groupList { grid-template-columns: repeat(3, auto); }" ] viewInfo
[ p [] [
raw l["The groups listed below have either public or password-protected request lists."]
space
raw l["Those with list icons are public, and those with log on icons are password-protected."]
space
raw l["Click the appropriate icon to log on or view the request list."]
]
match groups.Length with
| 0 -> p [] [ raw l["There are no groups with public or password-protected request lists."] ]
| count ->
tableSummary count s
section [ _id "groupList"; _class "pt-table"; _ariaLabel "Small group list" ] [
div [ _class "row head" ] [
header [ _class "cell" ] [ locStr s["Actions"] ]
header [ _class "cell" ] [ locStr s["Church"] ]
header [ _class "cell" ] [ locStr s["Group"] ]
]
for group in groups do
div [ _class "row" ] [
div [ _class "cell actions" ] [
if group.IsPublic then
a [ _href $"/prayer-requests/{group.Id}/list"; _title s["View"].Value ] [
iconSized 18 "list"
]
else
a [ _href $"/small-group/log-on/{group.Id}"; _title s["Log On"].Value ] [
iconSized 18 "verified_user"
]
]
div [ _class "cell" ] [ str group.ChurchName ]
div [ _class "cell" ] [ str group.Name ]
]
]
grps
|> List.map (fun grp ->
let grpId = flatGuid grp.smallGroupId
tr [] [
match grp.preferences.isPublic with
| true ->
a [ _href $"/web/prayer-requests/{grpId}/list"; _title s.["View"].Value ] [ icon "list" ]
| false ->
a [ _href $"/web/small-group/log-on/{grpId}"; _title s.["Log On"].Value ]
[ icon "verified_user" ]
|> List.singleton
|> td []
td [] [ str grp.church.name ]
td [] [ str grp.name ]
])
|> tbody []
]
]
|> Layout.Content.standard
|> Layout.standard vi "Request Lists"
|> Layout.Content.standard
|> Layout.standard vi "Request Lists"
/// View for the prayer request maintenance page
let maintain m (ctx : HttpContext) vi =
let s = I18N.localizer.Force ()
let l = I18N.forView "Requests/Maintain"
use sw = new StringWriter ()
let raw = rawLocText sw
let now = m.smallGroup.localDateNow (ctx.GetService<IClock> ())
let typs = ReferenceList.requestTypeList s |> Map.ofList
let updReq (req : PrayerRequest) =
match req.updateRequired now m.smallGroup.preferences.daysToExpire m.smallGroup.preferences.longTermUpdateWeeks with
| true -> "pt-request-update"
| false -> ""
|> _class
let reqExp (req : PrayerRequest) =
_class (match req.isExpired now m.smallGroup.preferences.daysToExpire with true -> "pt-request-expired" | false -> "")
/// Iterate the sequence once, before we render, so we can get the count of it at the top of the table
let requests =
m.requests
|> Seq.map (fun req ->
let reqId = flatGuid req.prayerRequestId
let reqText = htmlToPlainText req.text
let delAction = $"/web/prayer-request/{reqId}/delete"
let delPrompt =
[ s.["Are you sure you want to delete this {0}? This action cannot be undone.",
s.["Prayer Request"].Value.ToLower() ]
.Value
"\\n"
l.["(If the prayer request has been answered, or an event has passed, consider inactivating it instead.)"]
.Value
let maintain (model : MaintainRequests) (ctx : HttpContext) viewInfo =
let s = I18N.localizer.Force ()
let l = I18N.forView "Requests/Maintain"
use sw = new StringWriter ()
let raw = rawLocText sw
let group = model.SmallGroup
let now = SmallGroup.localDateNow (ctx.GetService<IClock> ()) group
let types = ReferenceList.requestTypeList s |> Map.ofList
let vi = AppViewInfo.withScopedStyles [ "#requestList { grid-template-columns: repeat(5, auto); }" ] viewInfo
/// Iterate the sequence once, before we render, so we can get the count of it at the top of the table
let requests =
model.Requests
|> List.map (fun req ->
let updateClass =
_class (if PrayerRequest.updateRequired now group req then "cell pt-request-update" else "cell")
let isExpired = PrayerRequest.isExpired now group req
let expiredClass = _class (if isExpired then "cell pt-request-expired" else "cell")
let reqId = shortGuid req.Id.Value
let reqText = htmlToPlainText req.Text
let delAction = $"/prayer-request/{reqId}/delete"
let delPrompt =
[ s["Are you sure you want to delete this {0}? This action cannot be undone.",
s["Prayer Request"].Value.ToLower() ].Value
"\\n"
l["(If the prayer request has been answered, or an event has passed, consider inactivating it instead.)"]
.Value
]
|> String.concat ""
div [ _class "row" ] [
div [ _class "cell actions" ] [
a [ _href $"/prayer-request/{reqId}/edit"; _title l["Edit This Prayer Request"].Value ] [
iconSized 18 "edit"
]
if isExpired then
a [ _href $"/prayer-request/{reqId}/restore"
_title l["Restore This Inactive Request"].Value ] [
iconSized 18 "visibility"
]
else
a [ _href $"/prayer-request/{reqId}/expire"
_title l["Expire This Request Immediately"].Value ] [
iconSized 18 "visibility_off"
]
a [ _href delAction
_title l["Delete This Request"].Value
_hxPost delAction
_hxConfirm delPrompt ] [
iconSized 18 "delete_forever"
]
]
div [ updateClass ] [
str (req.UpdatedDate.ToString(s["MMMM d, yyyy"].Value, CultureInfo.CurrentUICulture))
]
div [ _class "cell" ] [ locStr types[req.RequestType] ]
div [ expiredClass ] [ str (match req.Requestor with Some r -> r | None -> " ") ]
div [ _class "cell" ] [
match reqText.Length with
| len when len < 60 -> rawText reqText
| _ -> rawText $"{reqText[0..59]}&hellip;"
]
])
|> List.ofSeq
[ br []
div [ _fieldRow ] [
span [ _group ] [
a [ _href $"/prayer-request/{emptyGuid}/edit"; _title s["Add a New Request"].Value ] [
icon "add_circle"; rawText " &nbsp;"; locStr s["Add a New Request"]
]
a [ _href "/prayer-requests/view"; _title s["View Prayer Request List"].Value ] [
icon "list"; rawText " &nbsp;"; locStr s["View Prayer Request List"]
]
match model.SearchTerm with
| Some _ ->
a [ _href "/prayer-requests"; _title l["Clear Search Criteria"].Value ] [
icon "highlight_off"; rawText " &nbsp;"; raw l["Clear Search Criteria"]
]
| None -> ()
]
|> String.concat ""
tr [] [
td [] [
a [ _href $"/web/prayer-request/{reqId}/edit"; _title l.["Edit This Prayer Request"].Value ]
[ icon "edit" ]
match req.isExpired now m.smallGroup.preferences.daysToExpire with
| true ->
a [ _href $"/web/prayer-request/{reqId}/restore"
_title l.["Restore This Inactive Request"].Value ]
[ icon "visibility" ]
| false ->
a [ _href $"/web/prayer-request/{reqId}/expire"
_title l.["Expire This Request Immediately"].Value ]
[ icon "visibility_off" ]
a [ _href delAction; _title l.["Delete This Request"].Value;
_onclick $"return PT.confirmDelete('{delAction}','{delPrompt}')" ]
[ icon "delete_forever" ]
]
form [ _action "/prayer-requests"; _method "get"; _class "pt-center-text pt-search-form"; Target.content ] [
inputField "text" "search" (defaultArg model.SearchTerm "") [ _placeholder l["Search requests..."].Value ]
space
submit [] "search" s["Search"]
]
br []
tableSummary requests.Length s
match requests.Length with
| 0 -> ()
| _ ->
form [ _method "post" ] [
csrfToken ctx
section [ _id "requestList"; _class "pt-table"; _ariaLabel "Prayer request list" ] [
div [ _class "row head" ] [
header [ _class "cell" ] [ locStr s["Actions"] ]
header [ _class "cell" ] [ locStr s["Updated Date"] ]
header [ _class "cell" ] [ locStr s["Type"] ]
header [ _class "cell" ] [ locStr s["Requestor"] ]
header [ _class "cell" ] [ locStr s["Request"] ]
]
yield! requests
]
]
td [ updReq req ] [
str (req.updatedDate.ToString(s.["MMMM d, yyyy"].Value, Globalization.CultureInfo.CurrentUICulture))
]
td [] [ locStr typs.[req.requestType] ]
td [ reqExp req ] [ str (match req.requestor with Some r -> r | None -> " ") ]
td [] [
match reqText.Length with
| len when len < 60 -> rawText reqText
| _ -> rawText $"{reqText.[0..59]}&hellip;"
]
])
|> List.ofSeq
[ div [ _class "pt-center-text" ] [
br []
a [ _href $"/web/prayer-request/{emptyGuid}/edit"; _title s.["Add a New Request"].Value ]
[ icon "add_circle"; rawText " &nbsp;"; locStr s.["Add a New Request"] ]
rawText " &nbsp; &nbsp; &nbsp; "
a [ _href "/web/prayer-requests/view"; _title s.["View Prayer Request List"].Value ]
[ icon "list"; rawText " &nbsp;"; locStr s.["View Prayer Request List"] ]
match m.searchTerm with
| Some _ ->
rawText " &nbsp; &nbsp; &nbsp; "
a [ _href "/web/prayer-requests"; _title l.["Clear Search Criteria"].Value ]
[ icon "highlight_off"; rawText " &nbsp;"; raw l.["Clear Search Criteria"] ]
| None -> ()
]
form [ _action "/web/prayer-requests"; _method "get"; _class "pt-center-text pt-search-form" ] [
input [ _type "text"
_name "search"
_placeholder l.["Search requests..."].Value
_value (defaultArg m.searchTerm "")
]
space
submit [] "search" s.["Search"]
]
br []
tableSummary requests.Length s
match requests.Length with
| 0 -> ()
| _ ->
table [ _class "pt-table pt-action-table" ] [
thead [] [
tr [] [
th [] [ locStr s.["Actions"] ]
th [] [ locStr s.["Updated Date"] ]
th [] [ locStr s.["Type"] ]
th [] [ locStr s.["Requestor"] ]
th [] [ locStr s.["Request"] ]
]
]
tbody [] requests
]
div [ _class "pt-center-text" ] [
br []
match m.onlyActive with
| Some true ->
raw l.["Inactive requests are currently not shown"]
br []
a [ _href "/web/prayer-requests/inactive" ] [ raw l.["Show Inactive Requests"] ]
| _ ->
match Option.isSome m.onlyActive with
| true ->
raw l.["Inactive requests are currently shown"]
br []
a [ _href "/web/prayer-requests" ] [ raw l.["Do Not Show Inactive Requests"] ]
br []
br []
| false -> ()
let srch = [ match m.searchTerm with Some s -> "search", s | None -> () ]
let pg = defaultArg m.pageNbr 1
let url =
match m.onlyActive with Some true | None -> "" | _ -> "/inactive" |> sprintf "/web/prayer-requests%s"
match pg with
| 1 -> ()
| _ ->
// button (_type "submit" :: attrs) [ icon ico; rawText " &nbsp;"; locStr text ]
let withPage = match pg with 2 -> srch | _ -> ("page", string (pg - 1)) :: srch
a [ _href (makeUrl url withPage) ]
[ icon "keyboard_arrow_left"; space; raw l.["Previous Page"] ]
rawText " &nbsp; &nbsp; "
match requests.Length = m.smallGroup.preferences.pageSize with
| true ->
a [ _href (makeUrl url (("page", string (pg + 1)) :: srch)) ]
[ raw l.["Next Page"]; space; icon "keyboard_arrow_right" ]
| false -> ()
]
form [ _id "DeleteForm"; _action ""; _method "post" ] [ csrfToken ctx ]
div [ _class "pt-center-text" ] [
br []
match model.OnlyActive with
| Some true ->
raw l["Inactive requests are currently not shown"]
br []
a [ _href "/prayer-requests/inactive" ] [ raw l["Show Inactive Requests"] ]
| _ ->
if Option.isSome model.OnlyActive then
raw l["Inactive requests are currently shown"]
br []
a [ _href "/prayer-requests" ] [ raw l["Do Not Show Inactive Requests"] ]
br []
br []
let search = [ match model.SearchTerm with Some s -> "search", s | None -> () ]
let pg = defaultArg model.PageNbr 1
let url =
match model.OnlyActive with Some true | None -> "" | _ -> "/inactive"
|> sprintf "/prayer-requests%s"
match pg with
| 1 -> ()
| _ ->
// button (_type "submit" :: attrs) [ icon ico; rawText " &nbsp;"; locStr text ]
let withPage = match pg with 2 -> search | _ -> ("page", string (pg - 1)) :: search
a [ _href (makeUrl url withPage) ] [ icon "keyboard_arrow_left"; space; raw l["Previous Page"] ]
rawText " &nbsp; &nbsp; "
match requests.Length = model.SmallGroup.Preferences.PageSize with
| true ->
a [ _href (makeUrl url (("page", string (pg + 1)) :: search)) ] [
raw l["Next Page"]; space; icon "keyboard_arrow_right"
]
| false -> ()
]
]
|> Layout.Content.wide
|> Layout.standard vi (match m.searchTerm with Some _ -> "Search Results" | None -> "Maintain Requests")
|> Layout.Content.wide
|> Layout.standard vi (match model.SearchTerm with Some _ -> "Search Results" | None -> "Maintain Requests")
/// View for the printable prayer request list
let print m version =
let s = I18N.localizer.Force ()
let pageTitle = $"""{s.["Prayer Requests"].Value} {m.listGroup.name}"""
let imgAlt = $"""{s.["PrayerTracker"].Value} {s.["from Bit Badger Solutions"].Value}"""
article [] [
rawText (m.asHtml s)
br []
hr []
div [ _style $"font-size:70%%;font-family:{m.listGroup.preferences.listFonts};" ] [
img [ _src $"""/img/{s.["footer_en"].Value}.png"""
_style "vertical-align:text-bottom;"
_alt imgAlt
_title imgAlt ]
space
str version
]
let print model version =
let s = I18N.localizer.Force ()
let pageTitle = $"""{s["Prayer Requests"].Value} {model.SmallGroup.Name}"""
let imgAlt = $"""{s["PrayerTracker"].Value} {s["from Bit Badger Solutions"].Value}"""
article [] [
rawText (model.AsHtml s)
br []
hr []
div [ _style $"font-size:70%%;font-family:{model.SmallGroup.Preferences.FontStack};" ] [
img [ _src $"""/img/{s["footer_en"].Value}.png"""
_style "vertical-align:text-bottom;"
_alt imgAlt
_title imgAlt ]
space
str version
]
]
|> Layout.bare pageTitle
|> Layout.bare pageTitle
/// View for the prayer request list
let view m vi =
let s = I18N.localizer.Force ()
let pageTitle = $"""{s.["Prayer Requests"].Value} {m.listGroup.name}"""
let spacer = rawText " &nbsp; &nbsp; &nbsp; "
let dtString = m.date.ToString "yyyy-MM-dd"
[ div [ _class "pt-center-text" ] [
br []
a [ _class "pt-icon-link"
_href $"/web/prayer-requests/print/{dtString}"
_title s.["View Printable"].Value ] [
icon "print"; rawText " &nbsp;"; locStr s.["View Printable"]
]
match m.canEmail with
| true ->
spacer
match m.date.DayOfWeek = DayOfWeek.Sunday with
| true -> ()
| false ->
let rec findSunday (date : DateTime) =
match date.DayOfWeek = DayOfWeek.Sunday with
| true -> date
| false -> findSunday (date.AddDays 1.)
let sunday = findSunday m.date
a [ _class "pt-icon-link"
_href $"""/web/prayer-requests/view/{sunday.ToString "yyyy-MM-dd"}"""
_title s.["List for Next Sunday"].Value ] [
icon "update"; rawText " &nbsp;"; locStr s.["List for Next Sunday"]
let view model viewInfo =
let s = I18N.localizer.Force ()
let pageTitle = $"""{s["Prayer Requests"].Value} {model.SmallGroup.Name}"""
let dtString = model.Date.ToString ("yyyy-MM-dd", CultureInfo.InvariantCulture)
[ br []
div [ _fieldRow ] [
span [ _group ] [
a [ _class "pt-icon-link"
_href $"/prayer-requests/print/{dtString}"
_target "_blank"
_title s["View Printable"].Value ] [
icon "print"; rawText " &nbsp;"; locStr s["View Printable"]
]
spacer
let emailPrompt = s.["This will e-mail the current list to every member of your group, without further prompting. Are you sure this is what you are ready to do?"].Value
a [ _class "pt-icon-link"
_href $"/web/prayer-requests/email/{dtString}"
_title s.["Send via E-mail"].Value
_onclick $"return PT.requests.view.promptBeforeEmail('{emailPrompt}')" ] [
icon "mail_outline"; rawText " &nbsp;"; locStr s.["Send via E-mail"]
if model.CanEmail then
if model.Date.DayOfWeek <> IsoDayOfWeek.Sunday then
let rec findSunday (date : LocalDate) =
if date.DayOfWeek = IsoDayOfWeek.Sunday then date else findSunday (date.PlusDays 1)
let sunday = findSunday model.Date
a [ _class "pt-icon-link"
_href $"""/prayer-requests/view/{sunday.ToString ("yyyy-MM-dd", CultureInfo.InvariantCulture)}"""
_title s["List for Next Sunday"].Value ] [
icon "update"; rawText " &nbsp;"; locStr s["List for Next Sunday"]
]
a [ _class "pt-icon-link"
_href $"/prayer-requests/email/{dtString}"
_title s["Send via E-mail"].Value
_hxConfirm s["This will e-mail the current list to every member of your group, without further prompting. Are you sure this is what you are ready to do?"].Value ] [
icon "mail_outline"; rawText " &nbsp;"; locStr s["Send via E-mail"]
]
a [ _class "pt-icon-link"; _href "/prayer-requests"; _title s["Maintain Prayer Requests"].Value ] [
icon "compare_arrows"; rawText " &nbsp;"; locStr s["Maintain Prayer Requests"]
]
]
spacer
a [ _class "pt-icon-link"; _href "/web/prayer-requests"; _title s.["Maintain Prayer Requests"].Value ] [
icon "compare_arrows"; rawText " &nbsp;"; locStr s.["Maintain Prayer Requests"]
]
| false -> ()
]
br []
rawText (m.asHtml s)
]
br []
rawText (model.AsHtml s)
]
|> Layout.Content.standard
|> Layout.standard vi pageTitle
|> Layout.Content.standard
|> Layout.standard viewInfo pageTitle

View File

@@ -18,14 +18,16 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Giraffe" Version="5.0.0" />
<PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Giraffe.ViewEngine" Version="1.4.0" />
<PackageReference Include="MailKit" Version="2.15.0" />
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.8.0" />
<PackageReference Include="MailKit" Version="3.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Html.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Microsoft.AspNetCore.Http.Extensions" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Update="FSharp.Core" Version="6.0.5" />
</ItemGroup>
<ItemGroup>

View File

@@ -396,8 +396,8 @@
<data name="The group member “{0}” was deleted successfully" xml:space="preserve">
<value>El miembro del grupo “{0}” se eliminó con éxito</value>
</data>
<data name="The group {0} and its {1} prayer request(s) was deleted successfully (revoked access from {2} user(s))" xml:space="preserve">
<value>El grupo {0} y sus {1} peticion(es) de oración se ha eliminado correctamente (acceso revocada por {2} usuario(s))</value>
<data name="The group {0} and its {1} prayer request(s) were deleted successfully; revoked access from {2} user(s)" xml:space="preserve">
<value>El grupo {0} y sus {1} peticion(es) de oración se ha eliminado correctamente; acceso revocada por {2} usuario(s)</value>
</data>
<data name="The old password was incorrect - your password was NOT changed" xml:space="preserve">
<value>La contraseña antigua es incorrecta - la contraseña NO ha cambiado</value>
@@ -417,8 +417,8 @@
<data name="There are no classes with passwords defined" xml:space="preserve">
<value>No hay clases con contraseñas se define</value>
</data>
<data name="This is likely due to one of the following reasons:&lt;ul&gt;&lt;li&gt;The e-mail address “{0}” is invalid.&lt;/li&gt;&lt;li&gt;The password entered does not match the password for the given e-mail address.&lt;/li&gt;&lt;li&gt;You are not authorized to administer the group “{1}”.&lt;/li&gt;&lt;/ul&gt;" xml:space="preserve">
<value>Esto es probablemente debido a una de las siguientes razones:&lt;ul&gt;&lt;li&gt;La dirección de correo electrónico “{0}” no es válida.&lt;/li&gt;&lt;li&gt;La contraseña introducida no coincide con la contraseña de la determinada dirección de correo electrónico.&lt;/li&gt;&lt;li&gt;Usted no está autorizado para administrar el grupo “{1}”.&lt;/li&gt;&lt;/ul&gt;</value>
<data name="This is likely due to one of the following reasons:&lt;ul&gt;&lt;li&gt;The e-mail address “{0}” is invalid.&lt;/li&gt;&lt;li&gt;The password entered does not match the password for the given e-mail address.&lt;/li&gt;&lt;li&gt;You are not authorized to administer the selected group.&lt;/li&gt;&lt;/ul&gt;" xml:space="preserve">
<value>Esto es probablemente debido a una de las siguientes razones:&lt;ul&gt;&lt;li&gt;La dirección de correo electrónico “{0}” no es válida.&lt;/li&gt;&lt;li&gt;La contraseña introducida no coincide con la contraseña de la determinada dirección de correo electrónico.&lt;/li&gt;&lt;li&gt;Usted no está autorizado para administrar el grupo seleccionado.&lt;/li&gt;&lt;/ul&gt;</value>
</data>
<data name="This page loaded in {0:N3} seconds" xml:space="preserve">
<value>Esta página cargada en {0:N3} segundos</value>
@@ -742,7 +742,7 @@
<value>Este</value>
</data>
<data name="MMMM d, yyyy" xml:space="preserve">
<value>d \de MMMM yyyy</value>
<value>d \d\e MMMM yyyy</value>
</data>
<data name="Mountain" xml:space="preserve">
<value>Montaña</value>
@@ -822,4 +822,64 @@
<data name="as of" xml:space="preserve">
<value>como de</value>
</data>
<data name="State or Province" xml:space="preserve">
<value>Estado o Provincia</value>
</data>
<data name="Last Seen" xml:space="preserve">
<value>Ultima vez Visto</value>
</data>
<data name="Administrators" xml:space="preserve">
<value>Administradores</value>
</data>
<data name="Native Fonts" xml:space="preserve">
<value>Fuentes Nativas</value>
</data>
<data name="Named Fonts" xml:space="preserve">
<value>Fuentes con Nombre</value>
</data>
<data name="Select Church" xml:space="preserve">
<value>Seleccione una Iglesia</value>
</data>
<data name="Select Group" xml:space="preserve">
<value>Seleccione un Grupo</value>
</data>
<data name="Member Name" xml:space="preserve">
<value>Nombre de Miembro</value>
</data>
<data name="Custom Color" xml:space="preserve">
<value>Color Personalizado</value>
</data>
<data name="Church Name" xml:space="preserve">
<value>Nombre de la Iglesia</value>
</data>
<data name="City" xml:space="preserve">
<value>Ciudad</value>
</data>
<data name="Has an Interface with “{0}”" xml:space="preserve">
<value>Tiene una Interfaz con “{0}”</value>
</data>
<data name="Interface URL" xml:space="preserve">
<value>URL de la Interfaz</value>
</data>
<data name="Successfully {0} church “{1}”" xml:space="preserve">
<value>Iglesia “{1}” {0} con éxito</value>
</data>
<data name="The church “{0}” and its {1} small group(s) (with {2} prayer request(s)) were deleted successfully; revoked access from {3} user(s)" xml:space="preserve">
<value>La iglesia "{0}" y sus {1} grupo(s) (con {2} peticion(es) de oración) se eliminaron correctamente; acceso revocado de {3} usuario(s)</value>
</data>
<data name="Successfully {0} group “{1}”" xml:space="preserve">
<value>El grupo “{1}” {0} con éxito</value>
</data>
<data name="First Name" xml:space="preserve">
<value>Primer Nombre</value>
</data>
<data name="Last Name" xml:space="preserve">
<value>Apellido</value>
</data>
<data name="Password Again" xml:space="preserve">
<value>Contraseña otra Vez</value>
</data>
<data name="This User Is a {0} Administrator" xml:space="preserve">
<value>Este Usuario Es un Administrador de {0}</value>
</data>
</root>

View File

@@ -147,7 +147,7 @@
<data name="If you utilize the “{0}” box on sign in, a second cookie is stored, and transmitted to establish a session; this cookie is removed by clicking the “{1}” link." xml:space="preserve">
<value>Si utilizas el cuadro "{0}" al iniciar sesión, se almacena una segunda cookie y se transmite para establecer una sesión; esta cookie se elimina haciendo clic en el enlace "{1}".</value>
</data>
<data name="It also stores names and e-mail addreses of small group members, and plain-text passwords for small groups with password-protected lists." xml:space="preserve">
<data name="It also stores names and e-mail addresses of small group members, and plain-text passwords for small groups with password-protected lists." xml:space="preserve">
<value>También almacena nombres y direcciones de correo electrónico de miembros de grupos pequeños, y contraseñas de texto sin formato para grupos pequeños con listas protegidas por contraseña.</value>
</data>
<data name="On the server, all data is stored in a controlled-access database." xml:space="preserve">

View File

@@ -117,16 +117,16 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Ending with either “serif” or “sans-serif” will cause the user's browser to use the default “serif” font (“Times New Roman” on Windows) or “sans-serif” font (“Arial” on Windows) if no other fonts in the list are found." xml:space="preserve">
<value>Con la extensión “serif” o el “sans-serif” hará que el navegador del usuario para utilizar el valor predeterminado “serif” de la fuente (“Times New Roman” en Windows) o “sans-serif” de la fuente (“Arial” en Windows) si no otras fuentes en la lista se encuentran.</value>
<data name="Ending this list with either “serif” or “sans-serif” will cause the user's browser to use the default “serif” font (“Times New Roman” on Windows) or “sans-serif” font (“Arial” on Windows) if no other fonts in the list are found." xml:space="preserve">
<value>Terminar esta lista con “serif” o “sans-serif” hará que el navegador del usuario utilice la fuente predeterminado “serif” (“Times New Roman” en Windows) o “sans-serif” (“Arial” en Windows) si no se encuentran otras fuentes en la lista.</value>
</data>
<data name="If you want a custom color, you may be able to get some ideas (and a list of RGB values for those colors) from the W3 School's &lt;a href=&quot;http://www.w3schools.com/html/html_colornames.asp&quot; title=&quot;HTML Color List - W3 School&quot;&gt;HTML color name list&lt;/a&gt;." xml:space="preserve">
<value>Si desea un color personalizado, que puede ser capaz de obtener algunas ideas (y una lista de valores RGB para los colores) de la lista de la Escuela de W3 de &lt;a href="http://www.w3schools.com/html/html_colornames.asp" title="La Lista de Nombres de Colores HTML - La Escuela de W3"&gt;nombres de colores HTML&lt;/a&gt;.</value>
</data>
<data name="List font names, separated by commas." xml:space="preserve">
<value>Lista de nombres de fuentes separados por comas.</value>
<data name="Native fonts match the default font based on the user's device (computer, phone, tablet, etc.)." xml:space="preserve">
<value>Las fuentes nativas coinciden con la fuente predeterminada según el dispositivo del usuario (computadora, teléfono, tableta, etc.).</value>
</data>
<data name="The first font that is matched is the one that is used." xml:space="preserve">
<value>La primera fuente que se corresponde es el que se utiliza.</value>
<data name="Named fonts should be separated by commas, and will be displayed using the first one the user has in the list." xml:space="preserve">
<value>Las fuentes con nombre deben estar separadas por comas y se mostrarán usando la primera que el usuario tenga en la lista.</value>
</data>
</root>

File diff suppressed because it is too large Load Diff

View File

@@ -1,226 +1,245 @@
module PrayerTracker.Views.User
open Giraffe.ViewEngine
open PrayerTracker.Entities
open Giraffe.ViewEngine.Accessibility
open Giraffe.ViewEngine.Htmx
open PrayerTracker
open PrayerTracker.ViewModels
/// View for the group assignment page
let assignGroups m groups curGroups ctx vi =
let s = I18N.localizer.Force ()
let pageTitle = sprintf "%s %A" m.userName s.["Assign Groups"]
form [ _action "/web/user/small-groups/save"; _method "post"; _class "pt-center-columns" ] [
csrfToken ctx
input [ _type "hidden"; _name "userId"; _value (flatGuid m.userId) ]
input [ _type "hidden"; _name "userName"; _value m.userName ]
table [ _class "pt-table" ] [
thead [] [
tr [] [
th [] [ rawText "&nbsp;" ]
th [] [ locStr s.["Group"] ]
]
let assignGroups model groups curGroups ctx viewInfo =
let s = I18N.localizer.Force ()
let pageTitle = sprintf "%s %A" model.UserName s["Assign Groups"]
let vi = AppViewInfo.withScopedStyles [ "#groupList { grid-template-columns: auto; }" ] viewInfo
form [ _action "/user/small-groups/save"; _method "post"; _class "pt-center-columns"; Target.content ] [
csrfToken ctx
inputField "hidden" (nameof model.UserId) model.UserId []
inputField "hidden" (nameof model.UserName) model.UserName []
section [ _id "groupList"; _class "pt-table"; _ariaLabel "Assigned small groups" ] [
div [ _class "row head" ] [
header [ _class "cell" ] [ locStr s["Group"] ]
]
for groupId, name in groups do
div [ _class "row" ] [
div [ _class "cell" ] [
input [ _type "checkbox"
_name (nameof model.SmallGroups)
_id groupId
_value groupId
if List.contains groupId curGroups then _checked ]
space
label [ _for groupId ] [ str name ]
]
]
]
groups
|> List.map (fun (grpId, grpName) ->
let inputId = $"id-{grpId}"
tr [] [
td [] [
input [ _type "checkbox"
_name "smallGroups"
_id inputId
_value grpId
match curGroups |> List.contains grpId with true -> _checked | false -> () ]
]
td [] [ label [ _for inputId ] [ str grpName ] ]
])
|> tbody []
]
div [ _class "pt-field-row" ] [ submit [] "save" s.["Save Group Assignments"] ]
div [ _fieldRow ] [ submit [] "save" s["Save Group Assignments"] ]
]
|> List.singleton
|> Layout.Content.standard
|> Layout.standard vi pageTitle
|> List.singleton
|> Layout.Content.standard
|> Layout.standard vi pageTitle
/// View for the password change page
let changePassword ctx vi =
let s = I18N.localizer.Force ()
[ p [ _class "pt-center-text" ] [
locStr s.["To change your password, enter your current password in the specified box below, then enter your new password twice."]
]
form [ _action "/web/user/password/change"
_method "post"
_onsubmit $"""return PT.compareValidation('newPassword','newPasswordConfirm','%A{s.["The passwords do not match"]}')""" ] [
style [ _scoped ] [ rawText "#oldPassword, #newPassword, #newPasswordConfirm { width: 10rem; } "]
csrfToken ctx
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "oldPassword" ] [ locStr s.["Current Password"] ]
input [ _type "password"; _name "oldPassword"; _id "oldPassword"; _required; _autofocus ]
]
let changePassword ctx viewInfo =
let s = I18N.localizer.Force ()
let model = { OldPassword = ""; NewPassword = ""; NewPasswordConfirm = "" }
let vi =
viewInfo
|> AppViewInfo.withScopedStyles [
let fields =
toHtmlIds [ nameof model.OldPassword; nameof model.NewPassword; nameof model.NewPasswordConfirm ]
$"{fields} {{ width: 10rem; }}"
]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "newPassword" ] [ locStr s.["New Password Twice"] ]
input [ _type "password"; _name "newPassword"; _id "newPassword"; _required ]
]
div [ _class "pt-field" ] [
label [] [ rawText "&nbsp;" ]
input [ _type "password"; _name "newPasswordConfirm"; _id "newPasswordConfirm"; _required ]
]
[ p [ _class "pt-center-text" ] [
locStr s["To change your password, enter your current password in the specified box below, then enter your new password twice."]
]
div [ _class "pt-field-row" ] [
submit [ _onclick "document.getElementById('newPasswordConfirm').setCustomValidity('')" ] "done"
s.["Change Your Password"]
form [ _action "/user/password/change"
_method "post"
_onsubmit $"""return PT.compareValidation('{nameof model.NewPassword}','{nameof model.NewPasswordConfirm}','%A{s["The passwords do not match"]}')"""
Target.content ] [
csrfToken ctx
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.OldPassword) ] [ locStr s["Current Password"] ]
inputField "password" (nameof model.OldPassword) "" [ _required; _autofocus ]
]
]
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.NewPassword) ] [ locStr s["New Password Twice"] ]
inputField "password" (nameof model.NewPassword) "" [ _required ]
]
div [ _inputField ] [
label [ _for (nameof model.NewPasswordConfirm) ] [ rawText "&nbsp;" ]
inputField "password" (nameof model.NewPasswordConfirm) "" [ _required ]
]
]
div [ _fieldRow ] [
submit [
_onclick $"document.getElementById('{nameof model.NewPasswordConfirm}').setCustomValidity('')"
] "done" s["Change Your Password"]
]
]
]
]
|> Layout.Content.standard
|> Layout.standard vi "Change Your Password"
|> Layout.Content.standard
|> Layout.standard vi "Change Your Password"
/// View for the edit user page
let edit (m : EditUser) ctx vi =
let s = I18N.localizer.Force ()
let pageTitle = match m.isNew () with true -> "Add a New User" | false -> "Edit User"
let pwPlaceholder = s.[match m.isNew () with true -> "" | false -> "No change"].Value
[ form [ _action "/web/user/edit/save"; _method "post"; _class "pt-center-columns"
_onsubmit $"""return PT.compareValidation('password','passwordConfirm','%A{s.["The passwords do not match"]}')""" ] [
style [ _scoped ]
[ rawText "#firstName, #lastName, #password, #passwordConfirm { width: 10rem; } #emailAddress { width: 20rem; } " ]
csrfToken ctx
input [ _type "hidden"; _name "userId"; _value (flatGuid m.userId) ]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "firstName" ] [ locStr s.["First Name"] ]
input [ _type "text"; _name "firstName"; _id "firstName"; _value m.firstName; _required; _autofocus ]
]
div [ _class "pt-field" ] [
label [ _for "lastName" ] [ locStr s.["Last Name"] ]
input [ _type "text"; _name "lastName"; _id "lastName"; _value m.lastName; _required ]
]
div [ _class "pt-field" ] [
label [ _for "emailAddress" ] [ locStr s.["E-mail Address"] ]
input [ _type "email"; _name "emailAddress"; _id "emailAddress"; _value m.emailAddress; _required ]
]
let edit (model : EditUser) ctx viewInfo =
let s = I18N.localizer.Force ()
let pageTitle = if model.IsNew then "Add a New User" else "Edit User"
let pwPlaceholder = s[if model.IsNew then "" else "No change"].Value
let vi =
viewInfo
|> AppViewInfo.withScopedStyles [
let fields =
[ nameof model.FirstName; nameof model.LastName; nameof model.Password; nameof model.PasswordConfirm ]
|> toHtmlIds
$"{fields} {{ width: 10rem; }}"
$"#{nameof model.Email} {{ width: 20rem; }}"
]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "password" ] [ locStr s.["Password"] ]
input [ _type "password"; _name "password"; _id "password"; _placeholder pwPlaceholder ]
]
div [ _class "pt-field" ] [
label [ _for "passwordConfirm" ] [ locStr s.["Password Again"] ]
input [ _type "password"; _name "passwordConfirm"; _id "passwordConfirm"; _placeholder pwPlaceholder ]
]
|> AppViewInfo.withOnLoadScript $"PT.user.edit.onPageLoad({(string model.IsNew).ToLowerInvariant ()})"
form [ _action "/user/edit/save"
_method "post"
_class "pt-center-columns"
_onsubmit $"""return PT.compareValidation('{nameof model.Password}','{nameof model.PasswordConfirm}','%A{s["The passwords do not match"]}')"""
Target.content ] [
csrfToken ctx
inputField "hidden" (nameof model.UserId) model.UserId []
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.FirstName) ] [ locStr s["First Name"] ]
inputField "text" (nameof model.FirstName) model.FirstName [ _required; _autofocus ]
]
div [ _inputField ] [
label [ _for (nameof model.LastName) ] [ locStr s["Last Name"] ]
inputField "text" (nameof model.LastName) model.LastName [ _required ]
]
div [ _inputField ] [
label [ _for (nameof model.Email) ] [ locStr s["E-mail Address"] ]
inputField "email" (nameof model.Email) model.Email [ _required ]
]
]
div [ _class "pt-checkbox-field" ] [
input [ _type "checkbox"
_name "isAdmin"
_id "isAdmin"
_value "True"
match m.isAdmin with Some x when x -> _checked | _ -> () ]
label [ _for "isAdmin" ] [ locStr s.["This user is a PrayerTracker administrator"] ]
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.Password) ] [ locStr s["Password"] ]
inputField "password" (nameof model.Password) "" [ _placeholder pwPlaceholder ]
]
div [ _inputField ] [
label [ _for "passwordConfirm" ] [ locStr s["Password Again"] ]
inputField "password" (nameof model.PasswordConfirm) "" [ _placeholder pwPlaceholder ]
]
]
div [ _class "pt-field-row" ] [ submit [] "save" s.["Save User"] ]
]
script [] [ rawText $"PT.onLoad(PT.user.edit.onPageLoad({(string (m.isNew ())).ToLower ()}))" ]
div [ _checkboxField ] [
inputField "checkbox" (nameof model.IsAdmin) "True" [ if defaultArg model.IsAdmin false then _checked ]
label [ _for (nameof model.IsAdmin) ] [ locStr s["This User Is a {0} Administrator", s["PrayerTracker"]] ]
]
div [ _fieldRow ] [ submit [] "save" s["Save User"] ]
]
|> Layout.Content.standard
|> Layout.standard vi pageTitle
|> List.singleton
|> Layout.Content.standard
|> Layout.standard vi pageTitle
/// View for the user log on page
let logOn (m : UserLogOn) groups ctx vi =
let s = I18N.localizer.Force ()
form [ _action "/web/user/log-on"; _method "post"; _class "pt-center-columns" ] [
style [ _scoped ] [ rawText "#emailAddress { width: 20rem; }" ]
csrfToken ctx
input [ _type "hidden"; _name "redirectUrl"; _value (defaultArg m.redirectUrl "") ]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "emailAddress"] [ locStr s.["E-mail Address"] ]
input [ _type "email"; _name "emailAddress"; _id "emailAddress"; _value m.emailAddress; _required; _autofocus ]
let logOn (model : UserLogOn) groups ctx viewInfo =
let s = I18N.localizer.Force ()
let vi = AppViewInfo.withScopedStyles [ $"#{nameof model.Email} {{ width: 20rem; }}" ] viewInfo
form [ _action "/user/log-on"; _method "post"; _class "pt-center-columns"; Target.body ] [
csrfToken ctx
inputField "hidden" (nameof model.RedirectUrl) (defaultArg model.RedirectUrl "") []
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.Email) ] [ locStr s["E-mail Address"] ]
inputField "email" (nameof model.Email) model.Email [ _required; _autofocus ]
]
div [ _inputField ] [
label [ _for (nameof model.Password) ] [ locStr s["Password"] ]
inputField "password" (nameof model.Password) "" [
_placeholder $"""({s["Case-Sensitive"].Value.ToLower ()})"""; _required
]
]
]
div [ _class "pt-field" ] [
label [ _for "password" ] [ locStr s.["Password"] ]
input [ _type "password"; _name "password"; _id "password"; _required;
_placeholder (sprintf "(%s)" (s.["Case-Sensitive"].Value.ToLower ())) ]
div [ _fieldRow ] [
div [ _inputField ] [
label [ _for (nameof model.SmallGroupId) ] [ locStr s["Group"] ]
seq { "", selectDefault s["Select Group"].Value; yield! groups }
|> selectList (nameof model.SmallGroupId) "" [ _required ]
]
]
]
div [ _class "pt-field-row" ] [
div [ _class "pt-field" ] [
label [ _for "smallGroupId" ] [ locStr s.["Group"] ]
seq {
"", selectDefault s.["Select Group"].Value
yield! groups
}
|> selectList "smallGroupId" "" [ _required ]
div [ _checkboxField ] [
inputField "checkbox" (nameof model.RememberMe) "True" []
label [ _for "rememberMe" ] [ locStr s["Remember Me"] ]
br []
small [] [ em [] [ str $"""({s["Requires Cookies"].Value.ToLower ()})""" ] ]
]
]
div [ _class "pt-checkbox-field" ] [
input [ _type "checkbox"; _name "rememberMe"; _id "rememberMe"; _value "True" ]
label [ _for "rememberMe" ] [ locStr s.["Remember Me"] ]
br []
small [] [ em [] [ rawText "("; str (s.["Requires Cookies"].Value.ToLower ()); rawText ")" ] ]
]
div [ _class "pt-field-row" ] [ submit [] "account_circle" s.["Log On"] ]
div [ _fieldRow ] [ submit [] "account_circle" s["Log On"] ]
]
|> List.singleton
|> Layout.Content.standard
|> Layout.standard vi "User Log On"
|> List.singleton
|> Layout.Content.standard
|> Layout.standard vi "User Log On"
open PrayerTracker.Entities
/// View for the user maintenance page
let maintain (users : User list) ctx vi =
let s = I18N.localizer.Force ()
let usrTbl =
match users with
| [] -> space
| _ ->
table [ _class "pt-table pt-action-table" ] [
thead [] [
tr [] [
th [] [ locStr s.["Actions"] ]
th [] [ locStr s.["Name"] ]
th [] [ locStr s.["Admin?"] ]
]
let maintain (users : User list) ctx viewInfo =
let s = I18N.localizer.Force ()
let vi = AppViewInfo.withScopedStyles [ "#userList { grid-template-columns: repeat(4, auto); }" ] viewInfo
let userTable =
match users with
| [] -> space
| _ ->
section [ _id "userList"; _class "pt-table"; _ariaLabel "User list" ] [
div [ _class "row head" ] [
header [ _class "cell" ] [ locStr s["Actions"] ]
header [ _class "cell" ] [ locStr s["Name"] ]
header [ _class "cell" ] [ locStr s["Last Seen"] ]
header [ _class "cell" ] [ locStr s["Admin?"] ]
]
for user in users do
let userId = shortGuid user.Id.Value
let delAction = $"/user/{userId}/delete"
let delPrompt = s["Are you sure you want to delete this {0}? This action cannot be undone.",
$"""{s["User"].Value.ToLower ()} ({user.Name})"""].Value
div [ _class "row" ] [
div [ _class "cell actions" ] [
a [ _href $"/user/{userId}/edit"; _title s["Edit This User"].Value ] [ iconSized 18 "edit" ]
a [ _href $"/user/{userId}/small-groups"; _title s["Assign Groups to This User"].Value ] [
iconSized 18 "group"
]
a [ _href delAction
_title s["Delete This User"].Value
_hxPost delAction
_hxConfirm delPrompt ] [
iconSized 18 "delete_forever"
]
]
div [ _class "cell" ] [ str user.Name ]
div [ _class "cell" ] [
match user.LastSeen with
| Some dt -> dt.ToString (s["MMMM d, yyyy"].Value, null)
| None -> "--"
|> str
]
div [ _class "cell pt-center-text" ] [
if user.IsAdmin then strong [] [ locStr s["Yes"] ] else locStr s["No"]
]
]
]
users
|> List.map (fun user ->
let userId = flatGuid user.userId
let delAction = $"/web/user/{userId}/delete"
let delPrompt = s.["Are you sure you want to delete this {0}? This action cannot be undone.",
$"""{s.["User"].Value.ToLower ()} ({user.fullName})"""].Value
tr [] [
td [] [
a [ _href $"/web/user/{userId}/edit"; _title s.["Edit This User"].Value ] [ icon "edit" ]
a [ _href $"/web/user/{userId}/small-groups"; _title s.["Assign Groups to This User"].Value ]
[ icon "group" ]
a [ _href delAction
_title s.["Delete This User"].Value
_onclick $"return PT.confirmDelete('{delAction}','{delPrompt}')" ]
[ icon "delete_forever" ]
]
td [] [ str user.fullName ]
td [ _class "pt-center-text" ] [
match user.isAdmin with
| true -> strong [] [ locStr s.["Yes"] ]
| false -> locStr s.["No"]
]
])
|> tbody []
]
[ div [ _class "pt-center-text" ] [
br []
a [ _href $"/web/user/{emptyGuid}/edit"; _title s.["Add a New User"].Value ]
[ icon "add_circle"; rawText " &nbsp;"; locStr s.["Add a New User"] ]
br []
br []
]
tableSummary users.Length s
usrTbl
form [ _id "DeleteForm"; _action ""; _method "post" ] [ csrfToken ctx ]
[ div [ _class "pt-center-text" ] [
br []
a [ _href $"/user/{emptyGuid}/edit"; _title s["Add a New User"].Value ] [
icon "add_circle"; rawText " &nbsp;"; locStr s["Add a New User"]
]
br []
br []
]
tableSummary users.Length s
form [ _method "post" ] [
csrfToken ctx
userTable
]
]
|> Layout.Content.standard
|> Layout.standard vi "Maintain Users"
|> Layout.Content.standard
|> Layout.standard vi "Maintain Users"

View File

@@ -1,207 +1,206 @@
[<AutoOpen>]
module PrayerTracker.Utils
open System.Net
open System
open System.Security.Cryptography
open System.Text
open System.Text.RegularExpressions
open System
/// Hash a string with a SHA1 hash
let sha1Hash (x : string) =
use alg = SHA1.Create ()
alg.ComputeHash (Encoding.ASCII.GetBytes x)
|> Seq.map (fun chr -> chr.ToString "x2")
|> String.concat ""
open Giraffe
/// Parse a short-GUID-based ID from a string
let idFromShort<'T> (f : Guid -> 'T) strValue =
(ShortGuid.toGuid >> f) strValue
/// Hash a string using 1,024 rounds of PBKDF2 and a salt
let pbkdf2Hash (salt : Guid) (x : string) =
use alg = new Rfc2898DeriveBytes (x, Encoding.UTF8.GetBytes (salt.ToString "N"), 1024)
(alg.GetBytes >> Convert.ToBase64String) 64
/// Format a GUID as a short GUID
let shortGuid = ShortGuid.fromGuid
/// An empty short GUID string (used for "add" actions)
let emptyGuid = shortGuid Guid.Empty
/// String helper functions
module String =
/// string.Trim()
let trim (str: string) = str.Trim ()
/// string.Trim()
let trim (str: string) = str.Trim ()
/// string.Replace()
let replace (find : string) repl (str : string) = str.Replace (find, repl)
/// string.Replace()
let replace (find : string) repl (str : string) = str.Replace (find, repl)
/// Replace the first occurrence of a string with a second string within a given string
let replaceFirst (needle : string) replacement (haystack : string) =
match haystack.IndexOf needle with
| -1 -> haystack
| idx ->
[ haystack.[0..idx - 1]
replacement
haystack.[idx + needle.Length..]
]
|> String.concat ""
/// Replace the first occurrence of a string with a second string within a given string
let replaceFirst (needle : string) replacement (haystack : string) =
match haystack.IndexOf needle with
| -1 -> haystack
| idx -> String.concat "" [ haystack[0..idx - 1]; replacement; haystack[idx + needle.Length..] ]
/// Convert a string to an option, with null, blank, and whitespace becoming None
let noneIfBlank (str : string) =
match str with
| null -> None
| it when it.Trim () = "" -> None
| it -> Some it
open System.Text.RegularExpressions
/// Strip HTML tags from the given string
// Adapted from http://www.dijksterhuis.org/safely-cleaning-html-with-strip_tags-in-csharp/
let stripTags allowedTags input =
let stripHtmlExp = Regex @"(<\/?[^>]+>)"
let mutable output = input
for tag in stripHtmlExp.Matches input do
let htmlTag = tag.Value.ToLower ()
let isAllowed =
allowedTags
|> List.fold
(fun acc t ->
acc
let stripHtmlExp = Regex @"(<\/?[^>]+>)"
let mutable output = input
for tag in stripHtmlExp.Matches input do
let htmlTag = tag.Value.ToLower ()
let shouldReplace =
allowedTags
|> List.fold (fun acc t ->
acc
|| htmlTag.IndexOf $"<{t}>" = 0
|| htmlTag.IndexOf $"<{t} " = 0
|| htmlTag.IndexOf $"</{t}" = 0) false
match isAllowed with
| true -> ()
| false -> output <- String.replaceFirst tag.Value "" output
output
|> not
if shouldReplace then output <- String.replaceFirst tag.Value "" output
output
/// Wrap a string at the specified number of characters
let wordWrap charPerLine (input : string) =
match input.Length with
| len when len <= charPerLine -> input
| _ ->
seq {
for line in input.Replace("\r", "").Split '\n' do
let mutable remaining = line
match remaining.Length with
| 0 -> ()
| _ ->
while charPerLine < remaining.Length do
match charPerLine + 1 < remaining.Length && remaining.[charPerLine] = ' ' with
| true ->
// Line length is followed by a space; return [charPerLine] as a line
yield remaining.[0..charPerLine - 1]
remaining <- remaining.[charPerLine + 1..]
| false ->
match remaining.[0..charPerLine - 1].LastIndexOf ' ' with
| -1 ->
// No whitespace; just break it at [characters]
yield remaining.[0..charPerLine - 1]
remaining <- remaining.[charPerLine..]
| spaceIdx ->
// Break on the last space in the line
yield remaining.[0..spaceIdx - 1]
remaining <- remaining.[spaceIdx + 1..]
// Leftovers - yum!
match remaining.Length with 0 -> () | _ -> yield remaining
match input.Length with
| len when len <= charPerLine -> input
| _ ->
seq {
for line in input.Replace("\r", "").Split '\n' do
let mutable remaining = line
match remaining.Length with
| 0 -> ()
| _ ->
while charPerLine < remaining.Length do
if charPerLine + 1 < remaining.Length && remaining[charPerLine] = ' ' then
// Line length is followed by a space; return [charPerLine] as a line
yield remaining[0..charPerLine - 1]
remaining <- remaining[charPerLine + 1..]
else
match remaining[0..charPerLine - 1].LastIndexOf ' ' with
| -1 ->
// No whitespace; just break it at [characters]
yield remaining[0..charPerLine - 1]
remaining <- remaining[charPerLine..]
| spaceIdx ->
// Break on the last space in the line
yield remaining[0..spaceIdx - 1]
remaining <- remaining[spaceIdx + 1..]
// Leftovers - yum!
match remaining.Length with 0 -> () | _ -> yield remaining
yield ""
}
|> Seq.fold (fun (acc : StringBuilder) line -> acc.AppendFormat ("{0}\n", line)) (StringBuilder ())
|> string
|> String.concat "\n"
/// Modify the text returned by CKEditor into the format we need for request and announcement text
let ckEditorToText (text : string) =
let trim (str : string) = str.Trim ()
[ "\n\t", ""
"&nbsp;", " "
" ", "&#xa0; "
"</p><p>", "<br><br>"
"</p>", ""
"<p>", ""
[ "\n\t", ""
"&nbsp;", " "
" ", "&#xa0; "
"</p><p>", "<br><br>"
"</p>", ""
"<p>", ""
]
|> List.fold (fun (txt : string) (x, y) -> String.replace x y txt) text
|> trim
|> List.fold (fun (txt : string) (x, y) -> String.replace x y txt) text
|> String.trim
open System.Net
/// Convert an HTML piece of text to plain text
let htmlToPlainText html =
match html with
| null | "" -> ""
| _ ->
html.Trim ()
|> stripTags [ "br" ]
|> String.replace "<br />" "\n"
|> String.replace "<br>" "\n"
|> WebUtility.HtmlDecode
|> String.replace "\u00a0" " "
match html with
| null | "" -> ""
| _ ->
html.Trim ()
|> stripTags [ "br" ]
|> String.replace "<br />" "\n"
|> String.replace "<br>" "\n"
|> WebUtility.HtmlDecode
|> String.replace "\u00a0" " "
/// Get the second portion of a tuple as a string
let sndAsString x = (snd >> string) x
/// Make a URL with query string parameters
let makeUrl (url : string) (qs : (string * string) list) =
let queryString =
qs
|> List.fold
(fun (acc : StringBuilder) (key, value) ->
acc.Append(key).Append("=").Append(WebUtility.UrlEncode value).Append "&")
(StringBuilder ())
match queryString.Length with
| 0 -> url
| _ -> queryString.Insert(0, "?").Insert(0, url).Remove(queryString.Length - 1, 1).ToString ()
let makeUrl url qs =
if List.isEmpty qs then url
else $"""{url}?{String.Join('&', List.map (fun (k, v) -> $"%s{k}={WebUtility.UrlEncode v}") qs)}"""
/// "Magic string" repository
[<RequireQualifiedAccess>]
module Key =
/// This contains constants for session-stored objects within PrayerTracker
module Session =
/// The currently logged-on small group
let currentGroup = "CurrentGroup"
/// The currently logged-on user
let currentUser = "CurrentUser"
/// User messages to be displayed the next time a page is sent
let userMessages = "UserMessages"
/// The URL to which the user should be redirected once they have logged in
let redirectUrl = "RedirectUrl"
/// Names and value names for use with cookies
module Cookie =
/// The name of the user cookie
let user = "LoggedInUser"
/// The name of the class cookie
let group = "LoggedInClass"
/// The name of the culture cookie
let culture = "CurrentCulture"
/// The name of the idle timeout cookie
let timeout = "TimeoutCookie"
/// The cookies that should be cleared when a user or group logs off
let logOffCookies = [ user; group; timeout ]
/// The request start time (added via middleware, read when rendering the footer)
let startTime = "StartTime"
/// This contains constants for session-stored objects within PrayerTracker
module Session =
/// The currently logged-on small group
let currentGroup = "CurrentGroup"
/// The currently logged-on user
let currentUser = "CurrentUser"
/// User messages to be displayed the next time a page is sent
let userMessages = "UserMessages"
/// The URL to which the user should be redirected once they have logged in
let redirectUrl = "RedirectUrl"
/// Enumerated values for small group request list visibility (derived from preferences, used in UI)
module RequestVisibility =
/// Requests are publicly accessible
[<Literal>]
let ``public`` = 1
/// The small group members can enter a password to view the request list
[<Literal>]
let passwordProtected = 2
/// No one can see the requests for a small group except its administrators ("User" access level)
[<Literal>]
let ``private`` = 3
module GroupVisibility =
/// Requests are publicly accessible
[<Literal>]
let PublicList = 1
/// The small group members can enter a password to view the request list
[<Literal>]
let HasPassword = 2
/// No one can see the requests for a small group except its administrators ("User" access level)
[<Literal>]
let PrivateList = 3
/// Links for help locations
module Help =
/// Help link for small group preference edit page
let groupPreferences = "small-group/preferences"
/// Help link for send announcement page
let sendAnnouncement = "small-group/announcement"
/// Help link for maintain group members page
let maintainGroupMembers = "small-group/members"
/// Help link for request edit page
let editRequest = "requests/edit"
/// Help link for maintain requests page
let maintainRequests = "requests/maintain"
/// Help link for view request list page
let viewRequestList = "requests/view"
/// Help link for user and class login pages
let logOn = "user/log-on"
/// Help link for user password change page
let changePassword = "user/password"
/// Create a full link for a help page
let fullLink lang url = $"https://docs.prayer.bitbadger.solutions/%s{lang}/%s{url}.html"
/// Help link for small group preference edit page
let groupPreferences = "small-group/preferences"
/// Help link for send announcement page
let sendAnnouncement = "small-group/announcement"
/// Help link for maintain group members page
let maintainGroupMembers = "small-group/members"
/// Help link for request edit page
let editRequest = "requests/edit"
/// Help link for maintain requests page
let maintainRequests = "requests/maintain"
/// Help link for view request list page
let viewRequestList = "requests/view"
/// Help link for user and class login pages
let logOn = "user/log-on"
/// Help link for user password change page
let changePassword = "user/password"
/// Create a full link for a help page
let fullLink lang url = $"https://docs.prayer.bitbadger.solutions/%s{lang}/%s{url}.html"
/// This class serves as a common anchor for resources
type Common () =
do ()
do ()

File diff suppressed because it is too large Load Diff