743 lines
39 KiB
FSharp
743 lines
39 KiB
FSharp
/// <summary>Helpers available for all myWebLog views</summary>
|
|
[<AutoOpen>]
|
|
module MyWebLog.Views.Helpers
|
|
|
|
open Giraffe.Htmx
|
|
open Giraffe.ViewEngine
|
|
open Giraffe.ViewEngine.Accessibility
|
|
open Giraffe.ViewEngine.Htmx
|
|
open MyWebLog
|
|
open MyWebLog.ViewModels
|
|
open NodaTime
|
|
open NodaTime.Text
|
|
|
|
/// <summary>Create a relative URL for the current web log</summary>
|
|
/// <param name="app">The app view context for the current view</param>
|
|
/// <returns>A function that, given a string, will construct a relative URL</returns>
|
|
let relUrl app =
|
|
Permalink >> app.WebLog.RelativeUrl
|
|
|
|
/// <summary>Create a hidden input with the anti-Cross Site Request Forgery (CSRF) token</summary>
|
|
/// <param name="app">The app view context for the current view</param>
|
|
/// <returns>A hidden input with the CSRF token value</returns>
|
|
let antiCsrf app =
|
|
input [ _type "hidden"; _name app.Csrf.Value.FormFieldName; _value app.Csrf.Value.RequestToken ]
|
|
|
|
/// <summary>Shorthand for encoded text in a template</summary>
|
|
let txt = encodedText
|
|
|
|
/// <summary>Shorthand for raw text in a template</summary>
|
|
let raw = rawText
|
|
|
|
/// <summary><c>rel</c> attribute to prevent opener information from being provided to the new window</summary>
|
|
let _relNoOpener = _rel "noopener"
|
|
|
|
/// <summary>The pattern for a long date</summary>
|
|
let longDatePattern =
|
|
ZonedDateTimePattern.CreateWithInvariantCulture("MMMM d, yyyy", DateTimeZoneProviders.Tzdb)
|
|
|
|
/// <summary>Create a long date</summary>
|
|
/// <param name="app">The app view context for the current view</param>
|
|
/// <param name="instant">The instant from which a localized long date should be produced</param>
|
|
/// <returns>A text node with the long date</returns>
|
|
let longDate app (instant: Instant) =
|
|
DateTimeZoneProviders.Tzdb[app.WebLog.TimeZone]
|
|
|> Option.ofObj
|
|
|> Option.map (fun tz -> longDatePattern.Format(instant.InZone(tz)))
|
|
|> Option.defaultValue "--"
|
|
|> txt
|
|
|
|
/// <summary>The pattern for a short time</summary>
|
|
let shortTimePattern =
|
|
ZonedDateTimePattern.CreateWithInvariantCulture("h:mmtt", DateTimeZoneProviders.Tzdb)
|
|
|
|
/// <summary>Create a short time</summary>
|
|
/// <param name="app">The app view context for the current view</param>
|
|
/// <param name="instant">The instant from which a localized short date should be produced</param>
|
|
/// <returns>A text node with the short date</returns>
|
|
let shortTime app (instant: Instant) =
|
|
DateTimeZoneProviders.Tzdb[app.WebLog.TimeZone]
|
|
|> Option.ofObj
|
|
|> Option.map (fun tz -> shortTimePattern.Format(instant.InZone(tz)).ToLowerInvariant())
|
|
|> Option.defaultValue "--"
|
|
|> txt
|
|
|
|
/// <summary>Display "Yes" or "No" based on the state of a boolean value</summary>
|
|
/// <param name="value">The true/false value</param>
|
|
/// <returns>A text node with <c>Yes</c> if true, <c>No</c> if false</returns>
|
|
let yesOrNo value =
|
|
raw (if value then "Yes" else "No")
|
|
|
|
/// <summary>Extract an attribute value from a list of attributes, remove that attribute if it is found</summary>
|
|
/// <param name="name">The name of the attribute to be extracted and removed</param>
|
|
/// <param name="attrs">The list of attributes to be searched</param>
|
|
/// <returns>
|
|
/// A tuple with <c>fst</c> being <c>Some</c> with the attribute if found, <c>None</c> if not; and <c>snd</c>
|
|
/// being the list of attributes with the extracted one removed
|
|
/// </returns>
|
|
let extractAttrValue name attrs =
|
|
let valueAttr = attrs |> List.tryFind (fun x -> match x with KeyValue (key, _) when key = name -> true | _ -> false)
|
|
match valueAttr with
|
|
| Some (KeyValue (_, value)) ->
|
|
Some value,
|
|
attrs |> List.filter (fun x -> match x with KeyValue (key, _) when key = name -> false | _ -> true)
|
|
| Some _ | None -> None, attrs
|
|
|
|
/// <summary>Create a text input field</summary>
|
|
/// <param name="fieldType">The <c>input</c> field type</param>
|
|
/// <param name="attrs">Attributes for the field</param>
|
|
/// <param name="name">The name of the input field</param>
|
|
/// <param name="labelText">The text of the <c>label</c> element associated with this <c>input</c></param>
|
|
/// <param name="value">The value of the <c>input</c> field</param>
|
|
/// <param name="extra">Any extra elements to include after the <c>input</c> and <c>label</c></param>
|
|
/// <returns>A <c>div</c> element with the <c>input</c> field constructed</returns>
|
|
let inputField fieldType attrs name labelText value extra =
|
|
let fieldId, attrs = extractAttrValue "id" attrs
|
|
let cssClass, attrs = extractAttrValue "class" attrs
|
|
div [ _class $"""form-floating {defaultArg cssClass ""}""" ] [
|
|
[ _type fieldType; _name name; _id (defaultArg fieldId name); _class "form-control"; _placeholder labelText
|
|
_value value ]
|
|
|> List.append attrs
|
|
|> input
|
|
label [ _for (defaultArg fieldId name) ] [ raw labelText ]
|
|
yield! extra
|
|
]
|
|
|
|
/// <summary>Create a text input field</summary>
|
|
/// <param name="attrs">Attributes for the field</param>
|
|
/// <param name="name">The name of the input field</param>
|
|
/// <param name="labelText">The text of the <c>label</c> element associated with this <c>input</c></param>
|
|
/// <param name="value">The value of the <c>input</c> field</param>
|
|
/// <param name="extra">Any extra elements to include after the <c>input</c> and <c>label</c></param>
|
|
/// <returns>A <c>div</c> element with the <input type=text> field constructed</returns>
|
|
let textField attrs name labelText value extra =
|
|
inputField "text" attrs name labelText value extra
|
|
|
|
/// <summary>Create a number input field</summary>
|
|
/// <param name="attrs">Attributes for the field</param>
|
|
/// <param name="name">The name of the input field</param>
|
|
/// <param name="labelText">The text of the <c>label</c> element associated with this <c>input</c></param>
|
|
/// <param name="value">The value of the <c>input</c> field</param>
|
|
/// <param name="extra">Any extra elements to include after the <c>input</c> and <c>label</c></param>
|
|
/// <returns>A <c>div</c> element with the <input type=number> field constructed</returns>
|
|
let numberField attrs name labelText value extra =
|
|
inputField "number" attrs name labelText value extra
|
|
|
|
/// <summary>Create an e-mail input field</summary>
|
|
/// <param name="attrs">Attributes for the field</param>
|
|
/// <param name="name">The name of the input field</param>
|
|
/// <param name="labelText">The text of the <c>label</c> element associated with this <c>input</c></param>
|
|
/// <param name="value">The value of the <c>input</c> field</param>
|
|
/// <param name="extra">Any extra elements to include after the <c>input</c> and <c>label</c></param>
|
|
/// <returns>A <c>div</c> element with the <input type=email> field constructed</returns>
|
|
let emailField attrs name labelText value extra =
|
|
inputField "email" attrs name labelText value extra
|
|
|
|
/// <summary>Create a password input field</summary>
|
|
/// <param name="attrs">Attributes for the field</param>
|
|
/// <param name="name">The name of the input field</param>
|
|
/// <param name="labelText">The text of the <c>label</c> element associated with this <c>input</c></param>
|
|
/// <param name="value">The value of the <c>input</c> field</param>
|
|
/// <param name="extra">Any extra elements to include after the <c>input</c> and <c>label</c></param>
|
|
/// <returns>A <c>div</c> element with the <input type=password> field constructed</returns>
|
|
let passwordField attrs name labelText value extra =
|
|
inputField "password" attrs name labelText value extra
|
|
|
|
/// <summary>Create a select (dropdown) field</summary>
|
|
/// <typeparam name="T">The type of value in the backing list</typeparam>
|
|
/// <typeparam name="a">The type of the <c>value</c> attribute</typeparam>
|
|
/// <param name="attrs">Attributes for the field</param>
|
|
/// <param name="name">The name of the input field</param>
|
|
/// <param name="labelText">The text of the <c>label</c> element associated with this <c>input</c></param>
|
|
/// <param name="value">The value of the <c>input</c> field</param>
|
|
/// <param name="values">The backing list for this dropdown</param>
|
|
/// <param name="idFunc">The function to extract the ID (<c>value</c> attribute)</param>
|
|
/// <param name="displayFunc">The function to extract the displayed version of the item</param>
|
|
/// <param name="extra">Any extra elements to include after the <c>input</c> and <c>label</c></param>
|
|
/// <returns>A <c>div</c> element with the <select> field constructed</returns>
|
|
let selectField<'T, 'a>
|
|
attrs name labelText value (values: 'T seq) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra =
|
|
let cssClass, attrs = extractAttrValue "class" attrs
|
|
div [ _class $"""form-floating {defaultArg cssClass ""}""" ] [
|
|
select ([ _name name; _id name; _class "form-control" ] |> List.append attrs) [
|
|
for item in values do
|
|
let itemId = string (idFunc item)
|
|
option [ _value itemId; if value = itemId then _selected ] [ raw (displayFunc item) ]
|
|
]
|
|
label [ _for name ] [ raw labelText ]
|
|
yield! extra
|
|
]
|
|
|
|
/// <summary>Create a checkbox input styled as a switch</summary>
|
|
/// <param name="attrs">Attributes for the field</param>
|
|
/// <param name="name">The name of the input field</param>
|
|
/// <param name="labelText">The text of the <c>label</c> element associated with this <c>input</c></param>
|
|
/// <param name="value">Whether the checkbox should be checked or not</param>
|
|
/// <param name="extra">Any extra elements to include after the <c>input</c> and <c>label</c></param>
|
|
/// <returns>A <c>div</c> element with the switch-style <input type=checkbox> field constructed</returns>
|
|
let checkboxSwitch attrs name labelText (value: bool) extra =
|
|
let cssClass, attrs = extractAttrValue "class" attrs
|
|
div [ _class $"""form-check form-switch {defaultArg cssClass ""}""" ] [
|
|
[ _type "checkbox"; _name name; _id name; _class "form-check-input"; _value "true"; if value then _checked ]
|
|
|> List.append attrs
|
|
|> input
|
|
label [ _for name; _class "form-check-label" ] [ raw labelText ]
|
|
yield! extra
|
|
]
|
|
|
|
/// <summary>A standard save button</summary>
|
|
let saveButton =
|
|
button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ]
|
|
|
|
/// <summary>A spacer bullet to use between action links</summary>
|
|
let actionSpacer =
|
|
span [ _class "text-muted" ] [ raw " • " ]
|
|
|
|
/// <summary>Functions for generating content in varying layouts</summary>
|
|
module Layout =
|
|
|
|
/// Generate the title tag for a page
|
|
let private titleTag (app: AppViewContext) =
|
|
title [] [ txt app.PageTitle; raw " « Admin « "; txt app.WebLog.Name ]
|
|
|
|
/// Create a navigation link
|
|
let private navLink app name url =
|
|
let extraPath = app.WebLog.ExtraPath
|
|
let path = if extraPath = "" then "" else $"{extraPath[1..]}/"
|
|
let active = if app.CurrentPage.StartsWith $"{path}{url}" then " active" else ""
|
|
let linkUrl = relUrl app url
|
|
li [ _class "nav-item" ] [ a [ _class $"nav-link{active}"; _href linkUrl; _hxBoost ] [ txt name ] ]
|
|
|
|
/// Create a page view for the given content
|
|
let private pageView (content: AppViewContext -> XmlNode list) app = [
|
|
header [] [
|
|
nav [ _class "navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2 position-fixed top-0 w-100"
|
|
hxInherited (_hxTarget "body") ] [
|
|
div [ _class "container-fluid" ] [
|
|
a [ _class "navbar-brand"; _href (relUrl app "") ] [ txt app.WebLog.Name ]
|
|
button [ _type "button"; _class "navbar-toggler"; _data "bs-toggle" "collapse"
|
|
_data "bs-target" "#navbarText"; _ariaControls "navbarText"; _ariaExpanded "false"
|
|
_ariaLabel "Toggle navigation" ] [
|
|
span [ _class "navbar-toggler-icon" ] []
|
|
]
|
|
div [ _class "collapse navbar-collapse"; _id "navbarText" ] [
|
|
if app.IsLoggedOn then
|
|
ul [ _class "navbar-nav" ] [
|
|
navLink app "Dashboard" "admin/dashboard"
|
|
if app.IsAuthor then
|
|
navLink app "Pages" "admin/pages"
|
|
navLink app "Posts" "admin/posts"
|
|
navLink app "Uploads" "admin/uploads"
|
|
if app.IsWebLogAdmin then
|
|
navLink app "Categories" "admin/categories"
|
|
navLink app "Settings" "admin/settings"
|
|
if app.IsAdministrator then navLink app "Admin" "admin/administration"
|
|
]
|
|
ul [ _class "navbar-nav flex-grow-1 justify-content-end" ] [
|
|
if app.IsLoggedOn then navLink app "My Info" "admin/my-info"
|
|
li [ _class "nav-item" ] [
|
|
a [ _class "nav-link"
|
|
_href "https://bitbadger.solutions/open-source/myweblog/#how-to-use-myweblog"
|
|
_target "_blank" ] [
|
|
raw "Docs"
|
|
]
|
|
]
|
|
if app.IsLoggedOn then
|
|
li [ _class "nav-item" ] [
|
|
form [ _id "mwl-log-off"; _method "post"; _action (relUrl app "user/log-off") ] [
|
|
a [ _class "nav-link"; _href (relUrl app "user/log-off")
|
|
_onclick "document.getElementById('mwl-log-off').submit(); return false" ] [
|
|
raw "Log Off"
|
|
]
|
|
]
|
|
]
|
|
else
|
|
navLink app "Log On" "user/log-on"
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
div [ _id "toastHost"; _class "position-fixed top-0 w-100"; _ariaLive "polite"; _ariaAtomic "true" ] [
|
|
div [ _id "toasts"; _class "toast-container position-absolute p-3 mt-5 top-0 end-0" ] [
|
|
for msg in app.Messages do
|
|
let textColor = if msg.Level = "warning" then "" else " text-white"
|
|
div [ _class "toast"; _roleAlert; _ariaLive "assertive"; _ariaAtomic "true"
|
|
if msg.Level <> "success" then _data "bs-autohide" "false" ] [
|
|
div [ _class $"toast-header bg-{msg.Level}{textColor}" ] [
|
|
strong [ _class "me-auto text-uppercase" ] [
|
|
raw (if msg.Level = "danger" then "error" else msg.Level)
|
|
]
|
|
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "toast"
|
|
_ariaLabel "Close" ] []
|
|
]
|
|
div [ _class $"toast-body bg-{msg.Level} bg-opacity-25" ] [
|
|
txt msg.Message
|
|
if Option.isSome msg.Detail then
|
|
hr []
|
|
txt msg.Detail.Value
|
|
]
|
|
]
|
|
]
|
|
]
|
|
main [ _class "mx-3 mt-3" ] [
|
|
div [ _class "load-overlay p-5"; _id "loadOverlay" ] [ h1 [ _class "p-3" ] [ raw "Loading…" ] ]
|
|
yield! content app
|
|
]
|
|
footer [ _class "position-fixed bottom-0 w-100" ] [
|
|
div [ _class "text-end text-white me-2" ] [
|
|
let version = app.Generator.Split ' '
|
|
small [ _class "me-1 align-baseline"] [ raw $"v{version[1]}" ]
|
|
img [ _src (relUrl app "themes/admin/logo-light.png"); _alt "myWebLog"; _width "120"; _height "34" ]
|
|
]
|
|
]
|
|
]
|
|
|
|
/// <summary>Render a page with a partial layout (htmx request)</summary>
|
|
/// <param name="content">A function that, when given a view context, will return a view</param>
|
|
/// <param name="app">The app view context to use when rendering the view</param>
|
|
/// <returns>A constructed Giraffe View Engine view</returns>
|
|
let partial content app =
|
|
html [ _lang "en" ] [
|
|
titleTag app
|
|
yield! pageView content app
|
|
]
|
|
|
|
/// <summary>Render a page with a full layout</summary>
|
|
/// <param name="content">A function that, when given a view context, will return a view</param>
|
|
/// <param name="app">The app view context to use when rendering the view</param>
|
|
/// <returns>A constructed Giraffe View Engine view</returns>
|
|
let full content app =
|
|
html [ _lang "en" ] [
|
|
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
|
meta [ _name "generator"; _content app.Generator ]
|
|
titleTag app
|
|
link [ _rel "stylesheet"; _href "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
|
_integrity "sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
|
_crossorigin "anonymous" ]
|
|
link [ _rel "stylesheet"; _href (relUrl app "themes/admin/admin.css") ]
|
|
body [ hxInherited (_hxIndicator "#loadOverlay"); hxInherited (_hxTarget "body") ] [
|
|
yield! pageView content app
|
|
script [ _src "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
|
_integrity "sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
|
_crossorigin "anonymous" ] []
|
|
script [ _src (relUrl app StaticAssetUrl.htmx[1..]) ] []
|
|
script [ _src (relUrl app "themes/admin/admin.js") ] []
|
|
]
|
|
]
|
|
|
|
/// <summary>Render a bare layout</summary>
|
|
/// <param name="content">A function that, when given a view context, will return a view</param>
|
|
/// <param name="app">The app view context to use when rendering the view</param>
|
|
/// <returns>A constructed Giraffe View Engine view</returns>
|
|
let bare (content: AppViewContext -> XmlNode list) app =
|
|
html [ _lang "en" ] [
|
|
title [] []
|
|
yield! content app
|
|
]
|
|
|
|
|
|
// ~~ SHARED TEMPLATES BETWEEN POSTS AND PAGES
|
|
|
|
/// <summary>The round-trip instant pattern</summary>
|
|
let roundTrip = InstantPattern.CreateWithInvariantCulture "uuuu'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff"
|
|
|
|
/// Capitalize the first letter in the given string
|
|
let private capitalize (it: string) =
|
|
$"{(string it[0]).ToUpper()}{it[1..]}"
|
|
|
|
/// <summary>The common edit form shared by pages and posts</summary>
|
|
/// <param name="model">The model to use to render this view</param>
|
|
/// <param name="app">The app view context to use to render this view</param>
|
|
/// <returns>A common edit view</returns>
|
|
let commonEdit (model: EditCommonModel) app = [
|
|
textField [ _class "mb-3"; _required; _autofocus ] (nameof model.Title) "Title" model.Title []
|
|
textField [ _class "mb-3"; _required ] (nameof model.Permalink) "Permalink" model.Permalink [
|
|
if not model.IsNew then
|
|
let urlBase = relUrl app $"admin/{model.Entity}/{model.Id}"
|
|
span [ _class "form-text" ] [
|
|
a [ _href $"{urlBase}/permalinks" ] [ raw "Manage Permalinks" ]; actionSpacer
|
|
a [ _href $"{urlBase}/revisions" ] [ raw "Manage Revisions" ]
|
|
if model.IncludeChapterLink then
|
|
span [ _id "chapterEditLink" ] [
|
|
actionSpacer; a [ _href $"{urlBase}/chapters" ] [ raw "Manage Chapters" ]
|
|
]
|
|
]
|
|
]
|
|
div [ _class "mb-2" ] [
|
|
raw "Text"; raw " "
|
|
div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "Text format button group" ] [
|
|
input [ _type "radio"; _name (nameof model.Source); _id "source_html"; _class "btn-check"
|
|
_value "HTML"; if model.Source = "HTML" then _checked ]
|
|
label [ _class "btn btn-sm btn-outline-secondary"; _for "source_html" ] [ raw "HTML" ]
|
|
input [ _type "radio"; _name (nameof model.Source); _id "source_md"; _class "btn-check"
|
|
_value "Markdown"; if model.Source = "Markdown" then _checked ]
|
|
label [ _class "btn btn-sm btn-outline-secondary"; _for "source_md" ] [ raw "Markdown" ]
|
|
]
|
|
]
|
|
div [ _class "mb-3" ] [
|
|
textarea [ _name (nameof model.Text); _id (nameof model.Text); _class "form-control"; _rows "20" ] [
|
|
raw model.Text
|
|
]
|
|
]
|
|
]
|
|
|
|
|
|
/// <summary>Display a common template list</summary>
|
|
/// <param name="model">The edit model</param>
|
|
/// <param name="templates">A list of available templates for this page or post</param>
|
|
/// <returns>A <select> element to allow a template to be selected</returns>
|
|
let commonTemplates (model: EditCommonModel) (templates: MetaItem seq) =
|
|
selectField [ _class "mb-3" ] (nameof model.Template) $"{capitalize model.Entity} Template" model.Template templates
|
|
_.Name _.Value []
|
|
|
|
|
|
/// <summary>Display the OpenGraph data edit form</summary>
|
|
/// <param name="model">The edit model</param>
|
|
/// <returns>Fields for editing OpenGraph data for a page or post</returns>
|
|
let commonOpenGraph (model: EditCommonModel) =
|
|
fieldset [ _class "mb-3" ] [
|
|
legend [] [
|
|
span [ _class "form-check form-switch" ] [
|
|
small [] [
|
|
input [ _type "checkbox"; _name (nameof model.AssignOpenGraph)
|
|
_id (nameof model.AssignOpenGraph); _class "form-check-input"; _value "true"
|
|
_data "bs-toggle" "collapse"; _data "bs-target" "#og_props"
|
|
_onclick "Admin.toggleOpenGraphFields()"; if model.AssignOpenGraph then _checked ]
|
|
]
|
|
label [ _for (nameof model.AssignOpenGraph) ] [ raw "OpenGraph Properties" ]
|
|
]
|
|
]
|
|
div [ _id "og_props"; _class $"""container p-0 collapse{if model.AssignOpenGraph then " show" else ""}""" ] [
|
|
fieldset [ _id "og_item" ] [
|
|
legend [] [ raw "Item Details" ]
|
|
div [ _class "row p-0" ] [
|
|
div [ _class "mb-3 col-xs-6 col-md-3" ] [
|
|
selectField [ _required ] (nameof model.OpenGraphType) "Type" model.OpenGraphType
|
|
OpenGraphType.Selections fst snd []
|
|
]
|
|
div [ _class "mb-3 col-xs-6 col-md-3" ] [
|
|
textField [] (nameof model.OpenGraphLocale) "Locale" model.OpenGraphLocale
|
|
[ span [ _class "form-text" ] [ raw "ex. en-US" ] ]
|
|
]
|
|
div [ _class "mb-3 col-xs-6 col-md-4" ] [
|
|
textField [] (nameof model.OpenGraphAlternateLocales) "Alternate Locales"
|
|
model.OpenGraphAlternateLocales
|
|
[ span [ _class "form-text" ] [ raw "comma separated" ] ]
|
|
]
|
|
div [ _class "mb-3 col-xs-6 col-md-2" ] [
|
|
textField [] (nameof model.OpenGraphDeterminer) "Determiner" model.OpenGraphDeterminer
|
|
[ span [ _class "form-text" ] [ raw "a/an/the"; br []; raw "(blank = auto)" ] ]
|
|
]
|
|
div [ _class "mb-3 col-12" ] [
|
|
textField [] (nameof model.OpenGraphDescription) "Short Description" model.OpenGraphDescription
|
|
[]
|
|
]
|
|
]
|
|
]
|
|
fieldset [ _id "og_image" ] [
|
|
legend [] [ raw "Image" ]
|
|
div [ _class "row p-0" ] [
|
|
let syncJS = $"Admin.pathOrFile('{nameof model.OpenGraphImageUrl}', 'OpenGraphImageFile')"
|
|
div [ _class "mb-3 col-12" ] [
|
|
textField [ _onkeyup syncJS ] (nameof model.OpenGraphImageUrl) "Existing Image URL"
|
|
model.OpenGraphImageUrl []
|
|
]
|
|
div [ _class "mb-3 col-12" ] [
|
|
if model.OpenGraphImageUrl = "" then
|
|
div [ _class "form-floating" ] [
|
|
input [ _type "file"; _id "OpenGraphImageFile"; _name "OpenGraphImageFile"
|
|
_class "form-control"; _accept "image/*"; _oninput syncJS ]
|
|
label [ _for "OpenGraphImageFile" ] [ raw "Upload Image File" ]
|
|
]
|
|
else
|
|
input [ _type "hidden"; _id "OpenGraphImageFile"; _name "OpenGraphImageFile" ]
|
|
]
|
|
div [ _class "mb-3 col-12" ] [
|
|
textField [] (nameof model.OpenGraphImageAlt) "Alternate Text" model.OpenGraphImageAlt []
|
|
]
|
|
div [ _class "mb-3 col-6" ] [
|
|
textField [] (nameof model.OpenGraphImageType) "MIME Type" model.OpenGraphImageType
|
|
[ span [ _class "form-text" ] [ raw "Leave blank to derive type from extension" ] ]
|
|
]
|
|
div [ _class "mb-3 col-3" ] [
|
|
numberField [] (nameof model.OpenGraphImageWidth) "Width" model.OpenGraphImageWidth
|
|
[ span [ _class "form-text" ] [ raw "px" ] ]
|
|
]
|
|
div [ _class "mb-3 col-3" ] [
|
|
numberField [] (nameof model.OpenGraphImageHeight) "Height" model.OpenGraphImageHeight
|
|
[ span [ _class "form-text" ] [ raw "px" ] ]
|
|
]
|
|
]
|
|
]
|
|
fieldset [ _id "og_audio" ] [
|
|
legend [] [ raw "Audio" ]
|
|
div [ _class "row p-0" ] [
|
|
let syncJS = $"Admin.pathOrFile('{nameof model.OpenGraphAudioUrl}', 'OpenGraphAudioFile')"
|
|
div [ _class "mb-3 col-12" ] [
|
|
textField [ _onkeyup syncJS ] (nameof model.OpenGraphAudioUrl) "Existing Audio URL"
|
|
model.OpenGraphAudioUrl []
|
|
]
|
|
div [ _class "mb-3 col-12" ] [
|
|
if model.OpenGraphAudioUrl = "" then
|
|
div [ _class "form-floating" ] [
|
|
input [ _type "file"; _id "OpenGraphAudioFile"; _name "OpenGraphAudioFile"
|
|
_class "form-control"; _accept "image/*"; _oninput syncJS ]
|
|
label [ _for "OpenGraphAudioFile" ] [ raw "Upload Audio File" ]
|
|
]
|
|
else
|
|
input [ _type "hidden"; _id "OpenGraphAudioFile"; _name "OpenGraphAudioFile" ]
|
|
]
|
|
div [ _class "mb-3 col-6" ] [
|
|
textField [] (nameof model.OpenGraphAudioType) "MIME Type" model.OpenGraphAudioType
|
|
[ span [ _class "form-text" ] [ raw "Leave blank to derive type from extension" ] ]
|
|
]
|
|
]
|
|
]
|
|
fieldset [ _id "og_video" ] [
|
|
legend [] [ raw "Video" ]
|
|
div [ _class "row p-0" ] [
|
|
let syncJS = $"Admin.pathOrFile('{nameof model.OpenGraphVideoUrl}', 'OpenGraphVideoFile')"
|
|
div [ _class "mb-3 col-12" ] [
|
|
textField [ _onkeyup syncJS ] (nameof model.OpenGraphVideoUrl) "Existing Video URL"
|
|
model.OpenGraphVideoUrl []
|
|
]
|
|
div [ _class "mb-3 col-12" ] [
|
|
if model.OpenGraphVideoUrl = "" then
|
|
div [ _class "form-floating" ] [
|
|
input [ _type "file"; _id "OpenGraphVideoFile"; _name "OpenGraphVideoFile"
|
|
_class "form-control"; _accept "image/*"; _oninput syncJS ]
|
|
label [ _for "OpenGraphVideoFile" ] [ raw "Upload Video File" ]
|
|
]
|
|
else
|
|
input [ _type "hidden"; _id "OpenGraphVideoFile"; _name "OpenGraphVideoFile" ]
|
|
]
|
|
div [ _class "mb-3 col-6" ] [
|
|
textField [] (nameof model.OpenGraphVideoType) "MIME Type" model.OpenGraphVideoType
|
|
[ span [ _class "form-text" ] [ raw "Leave blank to derive type from extension" ] ]
|
|
]
|
|
div [ _class "mb-3 col-3" ] [
|
|
numberField [] (nameof model.OpenGraphVideoWidth) "Width" model.OpenGraphVideoWidth
|
|
[ span [ _class "form-text" ] [ raw "px" ] ]
|
|
]
|
|
div [ _class "mb-3 col-3" ] [
|
|
numberField [] (nameof model.OpenGraphVideoHeight) "Height" model.OpenGraphVideoHeight
|
|
[ span [ _class "form-text" ] [ raw "px" ] ]
|
|
]
|
|
]
|
|
]
|
|
fieldset [] [
|
|
legend [] [ raw "Extra Properties" ]
|
|
let items = Array.zip model.OpenGraphExtraNames model.OpenGraphExtraValues
|
|
let extraDetail idx (name, value) =
|
|
div [ _id $"og_extra_%i{idx}"; _class "row mb-3" ] [
|
|
div [ _class "col-1 text-center align-self-center" ] [
|
|
button [ _type "button"; _class "btn btn-sm btn-danger"
|
|
_onclick $"Admin.removeMetaItem('og_extra', {idx})" ] [
|
|
raw "−"
|
|
]
|
|
]
|
|
div [ _class "col-3" ] [
|
|
textField [ _id $"{nameof model.OpenGraphExtraNames}_{idx}" ]
|
|
(nameof model.OpenGraphExtraNames) "Name" name []
|
|
]
|
|
div [ _class "col-8" ] [
|
|
textField [ _id $"{nameof model.OpenGraphExtraValues}_{idx}" ]
|
|
(nameof model.OpenGraphExtraValues) "Value" value []
|
|
]
|
|
]
|
|
div [] [
|
|
div [ _id "og_extra_items"; _class "container p-0" ] (items |> Array.mapi extraDetail |> List.ofArray)
|
|
button [ _type "button"; _class "btn btn-sm btn-secondary"
|
|
_onclick "Admin.addMetaItem('og_extra')" ] [
|
|
raw "Add an Extra Property"
|
|
]
|
|
script [] [
|
|
raw """document.addEventListener("DOMContentLoaded", """
|
|
raw $"() => Admin.setNextExtraIndex({items.Length}))"
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
|
|
/// <summary>Display the metadata item edit form</summary>
|
|
/// <param name="model">The edit model</param>
|
|
/// <returns>A form for editing metadata</returns>
|
|
let commonMetaItems (model: EditCommonModel) =
|
|
let items = Array.zip model.MetaNames model.MetaValues
|
|
let metaDetail idx (name, value) =
|
|
div [ _id $"meta_%i{idx}"; _class "row mb-3" ] [
|
|
div [ _class "col-1 text-center align-self-center" ] [
|
|
button [ _type "button"; _class "btn btn-sm btn-danger"
|
|
_onclick $"Admin.removeMetaItem('meta', {idx})" ] [
|
|
raw "−"
|
|
]
|
|
]
|
|
div [ _class "col-3" ] [ textField [ _id $"MetaNames_{idx}" ] (nameof model.MetaNames) "Name" name [] ]
|
|
div [ _class "col-8" ] [ textField [ _id $"MetaValues_{idx}" ] (nameof model.MetaValues) "Value" value [] ]
|
|
]
|
|
|
|
fieldset [] [
|
|
legend [] [
|
|
raw "Metadata "
|
|
button [ _type "button"; _class "btn btn-sm btn-secondary"; _data "bs-toggle" "collapse"
|
|
_data "bs-target" "#meta_item_container" ] [
|
|
raw "show"
|
|
]
|
|
]
|
|
div [ _id "meta_item_container"; _class "collapse" ] [
|
|
div [ _id "meta_items"; _class "container" ] (items |> Array.mapi metaDetail |> List.ofArray)
|
|
button [ _type "button"; _class "btn btn-sm btn-secondary"; _onclick "Admin.addMetaItem('meta')" ] [
|
|
raw "Add an Item"
|
|
]
|
|
script [] [
|
|
raw """document.addEventListener("DOMContentLoaded", """
|
|
raw $"() => Admin.setNextMetaIndex({items.Length}))"
|
|
]
|
|
]
|
|
]
|
|
|
|
|
|
/// <summary>Revision preview template</summary>
|
|
/// <param name="rev">The revision to preview</param>
|
|
/// <param name="app">The app view context to use when rendering the preview</param>
|
|
/// <returns>A view with a revision preview</returns>
|
|
let commonPreview (rev: Revision) app =
|
|
div [ _class "mwl-revision-preview mb-3" ] [
|
|
rev.Text.AsHtml() |> addBaseToRelativeUrls app.WebLog.ExtraPath |> raw
|
|
]
|
|
|> List.singleton
|
|
|
|
|
|
/// <summary>Form to manage permalinks for pages or posts</summary>
|
|
/// <param name="model">The manage permalinks model to be rendered</param>
|
|
/// <param name="app">The app view context to use when rendering this view</param>
|
|
/// <returns>A view for managing permalinks for a page or post</returns>
|
|
let managePermalinks (model: ManagePermalinksModel) app = [
|
|
let baseUrl = relUrl app $"admin/{model.Entity}/"
|
|
let linkDetail idx link =
|
|
div [ _id $"link_%i{idx}"; _class "row mb-3" ] [
|
|
div [ _class "col-1 text-center align-self-center" ] [
|
|
button [ _type "button"; _class "btn btn-sm btn-danger"
|
|
_onclick $"Admin.removePermalink({idx})" ] [
|
|
raw "−"
|
|
]
|
|
]
|
|
div [ _class "col-11" ] [
|
|
div [ _class "form-floating" ] [
|
|
input [ _type "text"; _name "Prior"; _id $"prior_{idx}"; _class "form-control"; _placeholder "Link"
|
|
_value link ]
|
|
label [ _for $"prior_{idx}" ] [ raw "Link" ]
|
|
]
|
|
]
|
|
]
|
|
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
|
article [] [
|
|
form [ _action $"{baseUrl}permalinks"; _method "post"; _class "container" ] [
|
|
antiCsrf app
|
|
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
|
div [ _class "row" ] [
|
|
div [ _class "col" ] [
|
|
p [ _style "line-height:1.2rem;" ] [
|
|
strong [] [ txt model.CurrentTitle ]; br []
|
|
small [ _class "text-muted" ] [
|
|
span [ _class "fst-italic" ] [ txt model.CurrentPermalink ]; br []
|
|
a [ _href $"{baseUrl}{model.Id}/edit" ] [
|
|
raw $"« Back to Edit {capitalize model.Entity}"
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
div [ _class "row mb-3" ] [
|
|
div [ _class "col" ] [
|
|
button [ _type "button"; _class "btn btn-sm btn-secondary"; _onclick "Admin.addPermalink()" ] [
|
|
raw "Add a Permalink"
|
|
]
|
|
]
|
|
]
|
|
div [ _class "row mb-3" ] [
|
|
div [ _class "col" ] [
|
|
div [ _id "permalinks"; _class "container g-0" ] [
|
|
yield! Array.mapi linkDetail model.Prior
|
|
script [] [
|
|
raw """document.addEventListener("DOMContentLoaded", """
|
|
raw $"() => Admin.setPermalinkIndex({model.Prior.Length}))"
|
|
]
|
|
]
|
|
]
|
|
]
|
|
div [ _class "row pb-3" ] [
|
|
div [ _class "col " ] [
|
|
button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
|
|
/// <summary>Form to manage revisions for pages or posts</summary>
|
|
/// <param name="model">The manage revisions model to be rendered</param>
|
|
/// <param name="app">The app view context to use when rendering this view</param>
|
|
/// <returns>A view for managing revisions for a page or post</returns>
|
|
let manageRevisions (model: ManageRevisionsModel) app = [
|
|
let revUrlBase = relUrl app $"admin/{model.Entity}/{model.Id}/revision"
|
|
let revDetail idx (rev: Revision) =
|
|
let asOfString = roundTrip.Format rev.AsOf
|
|
let asOfId = $"""rev_{asOfString.Replace(".", "_").Replace(":", "-")}"""
|
|
div [ _id asOfId; _class "row pb-3 mwl-table-detail" ] [
|
|
div [ _class "col-12 mb-1" ] [
|
|
longDate app rev.AsOf; raw " at "; shortTime app rev.AsOf; raw " "
|
|
span [ _class "badge bg-secondary text-uppercase ms-2" ] [ txt (string rev.Text.SourceType) ]
|
|
if idx = 0 then span [ _class "badge bg-primary text-uppercase ms-2" ] [ raw "Current Revision" ]
|
|
br []
|
|
if idx > 0 then
|
|
let revUrlPrefix = $"{revUrlBase}/{asOfString}"
|
|
let revRestore = $"{revUrlPrefix}/restore"
|
|
small [] [
|
|
a [ _href $"{revUrlPrefix}/preview"; _hxTarget $"#{asOfId}_preview" ] [ raw "Preview" ]
|
|
span [ _class "text-muted" ] [ raw " • " ]
|
|
a [ _href revRestore; _hxPost revRestore ] [ raw "Restore as Current" ]
|
|
span [ _class "text-muted" ] [ raw " • " ]
|
|
a [ _href revUrlPrefix; _hxDelete revUrlPrefix; _hxTarget $"#{asOfId}"; _hxPushUrl "false"
|
|
_hxSwap HxSwap.OuterHtml; _class "text-danger" ] [
|
|
raw "Delete"
|
|
]
|
|
]
|
|
]
|
|
if idx > 0 then div [ _id $"{asOfId}_preview"; _class "col-12" ] []
|
|
]
|
|
|
|
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
|
article [] [
|
|
form [ _method "post"; _hxTarget "body"; _class "container mb-3" ] [
|
|
antiCsrf app
|
|
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
|
div [ _class "row" ] [
|
|
div [ _class "col" ] [
|
|
p [ _style "line-height:1.2rem;" ] [
|
|
strong [] [ txt model.CurrentTitle ]; br []
|
|
small [ _class "text-muted" ] [
|
|
a [ _href (relUrl app $"admin/{model.Entity}/{model.Id}/edit") ] [
|
|
raw $"« Back to Edit {(string model.Entity[0]).ToUpper()}{model.Entity[1..]}"
|
|
]
|
|
]
|
|
]
|
|
]
|
|
]
|
|
if model.Revisions.Length > 1 then
|
|
div [ _class "row mb-3" ] [
|
|
div [ _class "col" ] [
|
|
button [ _type "button"; _class "btn btn-sm btn-danger"; _hxDelete $"{revUrlBase}s"
|
|
_hxConfirm "This will remove all revisions but the current one; are you sure this is what you wish to do?" ] [
|
|
raw "Delete All Prior Revisions"
|
|
]
|
|
]
|
|
]
|
|
div [ _class "row mwl-table-heading" ] [ div [ _class "col" ] [ raw "Revision" ] ]
|
|
yield! List.mapi revDetail model.Revisions
|
|
]
|
|
]
|
|
]
|