Version 2.1 (#41)
- Add full chapter support (#6) - Add built-in redirect functionality (#39) - Support building Docker containers for release (#38) - Support canonical domain configuration (#37) - Add unit tests for domain/models and integration tests for all three data stores - Convert SQLite storage to use JSON documents, similar to PostgreSQL - Convert admin templates to Giraffe View Engine (from Liquid) - Add .NET 8 support
This commit was merged in pull request #41.
This commit is contained in:
190
src/MyWebLog/Views/Admin.fs
Normal file
190
src/MyWebLog/Views/Admin.fs
Normal file
@@ -0,0 +1,190 @@
|
||||
module MyWebLog.Views.Admin
|
||||
|
||||
open Giraffe.Htmx.Common
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// The administrator dashboard
|
||||
let dashboard (themes: Theme list) app = [
|
||||
let templates = TemplateCache.allNames ()
|
||||
let cacheBaseUrl = relUrl app "admin/cache/"
|
||||
let webLogCacheUrl = $"{cacheBaseUrl}web-log/"
|
||||
let themeCacheUrl = $"{cacheBaseUrl}theme/"
|
||||
let webLogDetail (webLog: WebLog) =
|
||||
let refreshUrl = $"{webLogCacheUrl}{webLog.Id}/refresh"
|
||||
div [ _class "row mwl-table-detail" ] [
|
||||
div [ _class "col" ] [
|
||||
txt webLog.Name; br []
|
||||
small [] [
|
||||
span [ _class "text-muted" ] [ raw webLog.UrlBase ]; br []
|
||||
a [ _href refreshUrl; _hxPost refreshUrl ] [ raw "Refresh" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
let themeDetail (theme: Theme) =
|
||||
let refreshUrl = $"{themeCacheUrl}{theme.Id}/refresh"
|
||||
div [ _class "row mwl-table-detail" ] [
|
||||
div [ _class "col-8" ] [
|
||||
txt theme.Name; br []
|
||||
small [] [
|
||||
span [ _class "text-muted" ] [ txt (string theme.Id); raw " • " ]
|
||||
a [ _href refreshUrl; _hxPost refreshUrl ] [ raw "Refresh" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-4" ] [
|
||||
raw (templates |> List.filter _.StartsWith(string theme.Id) |> List.length |> string)
|
||||
]
|
||||
]
|
||||
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
fieldset [ _class "container mb-3 pb-0" ] [
|
||||
legend [] [ raw "Themes" ]
|
||||
span [ _hxGet (relUrl app "admin/theme/list"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] []
|
||||
]
|
||||
fieldset [ _class "container mb-3 pb-0" ] [
|
||||
legend [] [ raw "Caches" ]
|
||||
p [ _class "pb-2" ] [
|
||||
raw "myWebLog uses a few caches to ensure that it serves pages as fast as possible. ("
|
||||
a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#cache-management"
|
||||
_target "_blank" ] [
|
||||
raw "more information"
|
||||
]; raw ")"
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-lg-6 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-secondary" ] [ raw "Web Logs" ]
|
||||
div [ _class "card-body pb-0" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3" ] [
|
||||
raw "These caches include the page list and categories for each web log"
|
||||
]
|
||||
let webLogUrl = $"{cacheBaseUrl}web-log/"
|
||||
form [ _method "post"; _class "container g-0"; _hxNoBoost; _hxTarget "body"
|
||||
_hxSwap $"{HxSwap.InnerHtml} show:window:top" ] [
|
||||
antiCsrf app
|
||||
button [ _type "submit"; _class "btn btn-sm btn-primary mb-2"
|
||||
_hxPost $"{webLogUrl}all/refresh" ] [
|
||||
raw "Refresh All"
|
||||
]
|
||||
div [ _class "row mwl-table-heading" ] [ div [ _class "col" ] [ raw "Web Log" ] ]
|
||||
yield! WebLogCache.all () |> List.sortBy _.Name |> List.map webLogDetail
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-secondary" ] [ raw "Themes" ]
|
||||
div [ _class "card-body pb-0" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3" ] [
|
||||
raw "The theme template cache is filled on demand as pages are displayed; "
|
||||
raw "refreshing a theme with no cached templates will still refresh its asset cache"
|
||||
]
|
||||
form [ _method "post"; _class "container g-0"; _hxNoBoost; _hxTarget "body"
|
||||
_hxSwap $"{HxSwap.InnerHtml} show:window:top" ] [
|
||||
antiCsrf app
|
||||
button [ _type "submit"; _class "btn btn-sm btn-primary mb-2"
|
||||
_hxPost $"{themeCacheUrl}all/refresh" ] [
|
||||
raw "Refresh All"
|
||||
]
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class "col-8" ] [ raw "Theme" ]; div [ _class "col-4" ] [ raw "Cached" ]
|
||||
]
|
||||
yield! themes |> List.filter (fun t -> t.Id <> ThemeId "admin") |> List.map themeDetail
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Display a list of themes
|
||||
let themeList (model: DisplayTheme list) app =
|
||||
let themeCol = "col-12 col-md-6"
|
||||
let slugCol = "d-none d-md-block col-md-3"
|
||||
let tmplCol = "d-none d-md-block col-md-3"
|
||||
div [ _id "theme_panel" ] [
|
||||
a [ _href (relUrl app "admin/theme/new"); _class "btn btn-primary btn-sm mb-3"; _hxTarget "#theme_new" ] [
|
||||
raw "Upload a New Theme"
|
||||
]
|
||||
div [ _class "container g-0" ] [
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class themeCol ] [ raw "Theme" ]
|
||||
div [ _class slugCol ] [ raw "Slug" ]
|
||||
div [ _class tmplCol ] [ raw "Templates" ]
|
||||
]
|
||||
]
|
||||
div [ _class "row mwl-table-detail"; _id "theme_new" ] []
|
||||
form [ _method "post"; _id "themeList"; _class "container g-0"; _hxTarget "#theme_panel"
|
||||
_hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [
|
||||
antiCsrf app
|
||||
for theme in model do
|
||||
let url = relUrl app $"admin/theme/{theme.Id}"
|
||||
div [ _class "row mwl-table-detail"; _id $"theme_{theme.Id}" ] [
|
||||
div [ _class $"{themeCol} no-wrap" ] [
|
||||
txt theme.Name
|
||||
if theme.IsInUse then span [ _class "badge bg-primary ms-2" ] [ raw "IN USE" ]
|
||||
if not theme.IsOnDisk then
|
||||
span [ _class "badge bg-warning text-dark ms-2" ] [ raw "NOT ON DISK" ]
|
||||
br []
|
||||
small [] [
|
||||
span [ _class "text-muted" ] [ txt $"v{theme.Version}" ]
|
||||
if not (theme.IsInUse || theme.Id = "default") then
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href url; _hxDelete url; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the theme “{theme.Name}”? This action cannot be undone." ] [
|
||||
raw "Delete"
|
||||
]
|
||||
span [ _class "d-md-none text-muted" ] [
|
||||
br []; raw "Slug: "; txt theme.Id; raw $" • {theme.TemplateCount} Templates"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class slugCol ] [ txt (string theme.Id) ]
|
||||
div [ _class tmplCol ] [ txt (string theme.TemplateCount) ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
|
||||
/// Form to allow a theme to be uploaded
|
||||
let themeUpload app =
|
||||
div [ _class "col" ] [
|
||||
h5 [ _class "mt-2" ] [ raw app.PageTitle ]
|
||||
form [ _action (relUrl app "admin/theme/new"); _method "post"; _class "container"
|
||||
_enctype "multipart/form-data"; _hxNoBoost ] [
|
||||
antiCsrf app
|
||||
div [ _class "row " ] [
|
||||
div [ _class "col-12 col-sm-6 pb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "file"; _id "file"; _name "file"; _class "form-control"; _accept ".zip"
|
||||
_placeholder "Theme File"; _required ]
|
||||
label [ _for "file" ] [ raw "Theme File" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 pb-3 d-flex justify-content-center align-items-center" ] [
|
||||
div [ _class "form-check form-switch pb-2" ] [
|
||||
input [ _type "checkbox"; _name "DoOverwrite"; _id "doOverwrite"; _class "form-check-input"
|
||||
_value "true" ]
|
||||
label [ _for "doOverwrite"; _class "form-check-label" ] [ raw "Overwrite" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Upload Theme" ]; raw " "
|
||||
button [ _type "button"; _class "btn btn-sm btn-secondary ms-3"
|
||||
_onclick "document.getElementById('theme_new').innerHTML = ''" ] [
|
||||
raw "Cancel"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
527
src/MyWebLog/Views/Helpers.fs
Normal file
527
src/MyWebLog/Views/Helpers.fs
Normal file
@@ -0,0 +1,527 @@
|
||||
[<AutoOpen>]
|
||||
module MyWebLog.Views.Helpers
|
||||
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
open NodaTime
|
||||
open NodaTime.Text
|
||||
|
||||
/// The rendering context for this application
|
||||
[<NoComparison; NoEquality>]
|
||||
type AppViewContext = {
|
||||
/// The web log for this request
|
||||
WebLog: WebLog
|
||||
|
||||
/// The ID of the current user
|
||||
UserId: WebLogUserId option
|
||||
|
||||
/// The title of the page being rendered
|
||||
PageTitle: string
|
||||
|
||||
/// The anti-Cross Site Request Forgery (CSRF) token set to use when rendering a form
|
||||
Csrf: AntiforgeryTokenSet option
|
||||
|
||||
/// The page list for the web log
|
||||
PageList: DisplayPage array
|
||||
|
||||
/// Categories and post counts for the web log
|
||||
Categories: DisplayCategory array
|
||||
|
||||
/// The URL of the page being rendered
|
||||
CurrentPage: string
|
||||
|
||||
/// User messages
|
||||
Messages: UserMessage array
|
||||
|
||||
/// The generator string for the rendered page
|
||||
Generator: string
|
||||
|
||||
/// A string to load the minified htmx script
|
||||
HtmxScript: string
|
||||
|
||||
/// Whether the current user is an author
|
||||
IsAuthor: bool
|
||||
|
||||
/// Whether the current user is an editor (implies author)
|
||||
IsEditor: bool
|
||||
|
||||
/// Whether the current user is a web log administrator (implies author and editor)
|
||||
IsWebLogAdmin: bool
|
||||
|
||||
/// Whether the current user is an installation administrator (implies all web log rights)
|
||||
IsAdministrator: bool
|
||||
} with
|
||||
|
||||
/// Whether there is a user logged on
|
||||
member this.IsLoggedOn = Option.isSome this.UserId
|
||||
|
||||
|
||||
/// Create a relative URL for the current web log
|
||||
let relUrl app =
|
||||
Permalink >> app.WebLog.RelativeUrl
|
||||
|
||||
/// Add a hidden input with the anti-Cross Site Request Forgery (CSRF) token
|
||||
let antiCsrf app =
|
||||
input [ _type "hidden"; _name app.Csrf.Value.FormFieldName; _value app.Csrf.Value.RequestToken ]
|
||||
|
||||
/// Shorthand for encoded text in a template
|
||||
let txt = encodedText
|
||||
|
||||
/// Shorthand for raw text in a template
|
||||
let raw = rawText
|
||||
|
||||
/// Rel attribute to prevent opener information from being provided to the new window
|
||||
let _relNoOpener = _rel "noopener"
|
||||
|
||||
/// The pattern for a long date
|
||||
let longDatePattern =
|
||||
ZonedDateTimePattern.CreateWithInvariantCulture("MMMM d, yyyy", DateTimeZoneProviders.Tzdb)
|
||||
|
||||
/// Create a long date
|
||||
let longDate app (instant: Instant) =
|
||||
DateTimeZoneProviders.Tzdb[app.WebLog.TimeZone]
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun tz -> longDatePattern.Format(instant.InZone(tz)))
|
||||
|> Option.defaultValue "--"
|
||||
|> txt
|
||||
|
||||
/// The pattern for a short time
|
||||
let shortTimePattern =
|
||||
ZonedDateTimePattern.CreateWithInvariantCulture("h:mmtt", DateTimeZoneProviders.Tzdb)
|
||||
|
||||
/// Create a short time
|
||||
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
|
||||
|
||||
/// Display "Yes" or "No" based on the state of a boolean value
|
||||
let yesOrNo value =
|
||||
raw (if value then "Yes" else "No")
|
||||
|
||||
/// Extract an attribute value from a list of attributes, remove that attribute if it is found
|
||||
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
|
||||
|
||||
/// Create a text input field
|
||||
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
|
||||
]
|
||||
|
||||
/// Create a text input field
|
||||
let textField attrs name labelText value extra =
|
||||
inputField "text" attrs name labelText value extra
|
||||
|
||||
/// Create a number input field
|
||||
let numberField attrs name labelText value extra =
|
||||
inputField "number" attrs name labelText value extra
|
||||
|
||||
/// Create an e-mail input field
|
||||
let emailField attrs name labelText value extra =
|
||||
inputField "email" attrs name labelText value extra
|
||||
|
||||
/// Create a password input field
|
||||
let passwordField attrs name labelText value extra =
|
||||
inputField "password" attrs name labelText value extra
|
||||
|
||||
/// Create a select (dropdown) field
|
||||
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
|
||||
]
|
||||
|
||||
/// Create a checkbox input styled as a switch
|
||||
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
|
||||
]
|
||||
|
||||
/// A standard save button
|
||||
let saveButton =
|
||||
button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ]
|
||||
|
||||
/// A spacer bullet to use between action links
|
||||
let actionSpacer =
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
|
||||
/// Functions for generating content in varying layouts
|
||||
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 ""
|
||||
li [ _class "nav-item" ] [
|
||||
a [ _class $"nav-link{active}"; _href (relUrl app url) ] [ 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" ] [
|
||||
div [ _class "container-fluid" ] [
|
||||
a [ _class "navbar-brand"; _href (relUrl app ""); _hxNoBoost ] [ 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" ] [
|
||||
a [ _class "nav-link"; _href (relUrl app "user/log-off"); _hxNoBoost ] [
|
||||
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" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Render a page with a partial layout (htmx request)
|
||||
let partial content app =
|
||||
html [ _lang "en" ] [
|
||||
titleTag app
|
||||
yield! pageView content app
|
||||
]
|
||||
|
||||
/// Render a page with a full layout
|
||||
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 [ _hxBoost; _hxIndicator "#loadOverlay" ] [
|
||||
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.minified
|
||||
script [ _src (relUrl app "themes/admin/admin.js") ] []
|
||||
]
|
||||
]
|
||||
|
||||
/// Render a bare layout
|
||||
let bare (content: AppViewContext -> XmlNode list) app =
|
||||
html [ _lang "en" ] [
|
||||
title [] []
|
||||
yield! content app
|
||||
]
|
||||
|
||||
|
||||
// ~~ SHARED TEMPLATES BETWEEN POSTS AND PAGES
|
||||
open Giraffe.Htmx.Common
|
||||
|
||||
/// The round-trip instant pattern
|
||||
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..]}"
|
||||
|
||||
/// The common edit form shared by pages and posts
|
||||
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" ] [
|
||||
label [ _for "text" ] [ 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
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Display a common template list
|
||||
let commonTemplates (model: EditCommonModel) (templates: MetaItem seq) =
|
||||
selectField [ _class "mb-3" ] (nameof model.Template) $"{capitalize model.Entity} Template" model.Template templates
|
||||
(_.Name) (_.Value) []
|
||||
|
||||
|
||||
/// Display the metadata item edit form
|
||||
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({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()" ] [
|
||||
raw "Add an Item"
|
||||
]
|
||||
script [] [
|
||||
raw """document.addEventListener("DOMContentLoaded", """
|
||||
raw $"() => Admin.setNextMetaIndex({items.Length}))"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Revision preview template
|
||||
let commonPreview (rev: Revision) app =
|
||||
div [ _class "mwl-revision-preview mb-3" ] [
|
||||
rev.Text.AsHtml() |> addBaseToRelativeUrls app.WebLog.ExtraPath |> raw
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
|
||||
/// Form to manage permalinks for pages or posts
|
||||
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" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Form to manage revisions for pages or posts
|
||||
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}"
|
||||
_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
|
||||
]
|
||||
]
|
||||
]
|
||||
105
src/MyWebLog/Views/Page.fs
Normal file
105
src/MyWebLog/Views/Page.fs
Normal file
@@ -0,0 +1,105 @@
|
||||
module MyWebLog.Views.Page
|
||||
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// The form to edit pages
|
||||
let pageEdit (model: EditPageModel) templates app = [
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
form [ _action (relUrl app "admin/page/save"); _method "post"; _hxPushUrl "true"; _class "container" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name (nameof model.Id); _value model.Id ]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col-9" ] (commonEdit model app)
|
||||
div [ _class "col-3" ] [
|
||||
commonTemplates model templates
|
||||
checkboxSwitch [] (nameof model.IsShownInPageList) "Show in Page List" model.IsShownInPageList []
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [ div [ _class "col" ] [ saveButton ] ]
|
||||
div [ _class "row mb-3" ] [ div [ _class "col" ] [ commonMetaItems model ] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Display a list of pages for this web log
|
||||
let pageList (pages: DisplayPage list) pageNbr hasNext app = [
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
a [ _href (relUrl app "admin/page/new/edit"); _class "btn btn-primary btn-sm mb-3" ] [ raw "Create a New Page" ]
|
||||
if pages.Length = 0 then
|
||||
p [ _class "text-muted fst-italic text-center" ] [ raw "This web log has no pages" ]
|
||||
else
|
||||
let titleCol = "col-12 col-md-5"
|
||||
let linkCol = "col-12 col-md-5"
|
||||
let upd8Col = "col-12 col-md-2"
|
||||
form [ _method "post"; _class "container mb-3"; _hxTarget "body" ] [
|
||||
antiCsrf app
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class titleCol ] [
|
||||
span [ _class "d-none d-md-inline" ] [ raw "Title" ]; span [ _class "d-md-none" ] [ raw "Page" ]
|
||||
]
|
||||
div [ _class $"{linkCol} d-none d-md-inline-block" ] [ raw "Permalink" ]
|
||||
div [ _class $"{upd8Col} d-none d-md-inline-block" ] [ raw "Updated" ]
|
||||
]
|
||||
for pg in pages do
|
||||
let pageLink = if pg.IsDefault then "" else pg.Permalink
|
||||
div [ _class "row mwl-table-detail" ] [
|
||||
div [ _class titleCol ] [
|
||||
txt pg.Title
|
||||
if pg.IsDefault then
|
||||
raw " "; span [ _class "badge bg-success" ] [ raw "HOME PAGE" ]
|
||||
if pg.IsInPageList then
|
||||
raw " "; span [ _class "badge bg-primary" ] [ raw "IN PAGE LIST" ]
|
||||
br [] ; small [] [
|
||||
let adminUrl = relUrl app $"admin/page/{pg.Id}"
|
||||
a [ _href (relUrl app pageLink); _target "_blank" ] [ raw "View Page" ]
|
||||
if app.IsEditor || (app.IsAuthor && app.UserId.Value = WebLogUserId pg.AuthorId) then
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href $"{adminUrl}/edit" ] [ raw "Edit" ]
|
||||
if app.IsWebLogAdmin then
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href adminUrl; _hxDelete adminUrl; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the page “{pg.Title}”? This action cannot be undone." ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class linkCol ] [
|
||||
small [ _class "d-md-none" ] [ txt pageLink ]
|
||||
span [ _class "d-none d-md-inline" ] [ txt pageLink ]
|
||||
]
|
||||
div [ _class upd8Col ] [
|
||||
small [ _class "d-md-none text-muted" ] [
|
||||
raw "Updated "; txt (pg.UpdatedOn.ToString "MMMM d, yyyy")
|
||||
]
|
||||
span [ _class "d-none d-md-inline" ] [ txt (pg.UpdatedOn.ToString "MMMM d, yyyy") ]
|
||||
]
|
||||
]
|
||||
]
|
||||
if pageNbr > 1 || hasNext then
|
||||
div [ _class "d-flex justify-content-evenly mb-3" ] [
|
||||
div [] [
|
||||
if pageNbr > 1 then
|
||||
let prevPage = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}"
|
||||
p [] [
|
||||
a [ _class "btn btn-secondary"; _href (relUrl app $"admin/pages{prevPage}") ] [
|
||||
raw "« Previous"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "text-right" ] [
|
||||
if hasNext then
|
||||
p [] [
|
||||
a [ _class "btn btn-secondary"; _href (relUrl app $"admin/pages/page/{pageNbr + 1}") ] [
|
||||
raw "Next »"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
524
src/MyWebLog/Views/Post.fs
Normal file
524
src/MyWebLog/Views/Post.fs
Normal file
@@ -0,0 +1,524 @@
|
||||
module MyWebLog.Views.Post
|
||||
|
||||
open Giraffe.Htmx.Common
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
open NodaTime.Text
|
||||
|
||||
/// The pattern for chapter start times
|
||||
let startTimePattern = DurationPattern.CreateWithInvariantCulture "H:mm:ss.FF"
|
||||
|
||||
/// The form to add or edit a chapter
|
||||
let chapterEdit (model: EditChapterModel) app = [
|
||||
let postUrl = relUrl app $"admin/post/{model.PostId}/chapter/{model.Index}"
|
||||
h3 [ _class "my-3" ] [ raw (if model.Index < 0 then "Add" else "Edit"); raw " Chapter" ]
|
||||
p [ _class "form-text" ] [
|
||||
raw "Times may be entered as seconds; minutes and seconds; or hours, minutes and seconds. Fractional seconds "
|
||||
raw "are supported to two decimal places."
|
||||
]
|
||||
form [ _method "post"; _action postUrl; _hxPost postUrl; _hxTarget "#chapter_list"; _class "container" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "PostId"; _value model.PostId ]
|
||||
input [ _type "hidden"; _name "Index"; _value (string model.Index) ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-6 col-lg-3 mb-3" ] [
|
||||
textField [ _required; _autofocus ] (nameof model.StartTime) "Start Time"
|
||||
(if model.Index < 0 then "" else model.StartTime) []
|
||||
]
|
||||
div [ _class "col-6 col-lg-3 mb-3" ] [
|
||||
textField [] (nameof model.EndTime) "End Time" model.EndTime [
|
||||
span [ _class "form-text" ] [ raw "Optional; ends when next starts" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 mb-3" ] [
|
||||
textField [] (nameof model.Title) "Chapter Title" model.Title [
|
||||
span [ _class "form-text" ] [ raw "Optional" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 col-xl-5 mb-3" ] [
|
||||
textField [] (nameof model.ImageUrl) "Image URL" model.ImageUrl [
|
||||
span [ _class "form-text" ] [
|
||||
raw "Optional; a separate image to display while this chapter is playing"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 col-xl-5 mb-3" ] [
|
||||
textField [] (nameof model.Url) "URL" model.Url [
|
||||
span [ _class "form-text" ] [ raw "Optional; informational link for this chapter" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6 offset-lg-3 col-xl-2 offset-xl-0 mb-3 align-self-end d-flex flex-column" ] [
|
||||
checkboxSwitch [] (nameof model.IsHidden) "Hidden Chapter" model.IsHidden []
|
||||
span [ _class "mt-2 form-text" ] [ raw "Not displayed, but may update image and location" ]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
let hasLoc, attrs = if model.LocationName = "" then false, [ _disabled ] else true, []
|
||||
div [ _class "col-12 col-md-4 col-lg-3 offset-lg-1 mb-3 align-self-end" ] [
|
||||
checkboxSwitch [ _onclick "Admin.checkChapterLocation()" ] "has_location" "Associate Location" hasLoc []
|
||||
]
|
||||
div [ _class "col-12 col-md-8 col-lg-6 offset-lg-1 mb-3" ] [
|
||||
textField (_required :: attrs) (nameof model.LocationName) "Name" model.LocationName []
|
||||
]
|
||||
div [ _class "col-6 col-lg-4 offset-lg-2 mb-3" ] [
|
||||
textField (_required :: attrs) (nameof model.LocationGeo) "Geo URL" model.LocationGeo [
|
||||
em [ _class "form-text" ] [
|
||||
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended"
|
||||
_target "_blank"; _relNoOpener ] [
|
||||
raw "see spec"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-6 col-lg-4 mb-3" ] [
|
||||
textField attrs (nameof model.LocationOsm) "OpenStreetMap ID" model.LocationOsm [
|
||||
em [ _class "form-text" ] [
|
||||
raw "Optional; "
|
||||
a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _relNoOpener ] [ raw "get ID" ]
|
||||
raw ", "
|
||||
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#osm-recommended"
|
||||
_target "_blank"; _relNoOpener ] [
|
||||
raw "see spec"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col" ] [
|
||||
let cancelLink = relUrl app $"admin/post/{model.PostId}/chapters"
|
||||
if model.Index < 0 then
|
||||
checkboxSwitch [ _checked ] (nameof model.AddAnother) "Add Another New Chapter" true []
|
||||
else
|
||||
input [ _type "hidden"; _name "AddAnother"; _value "false" ]
|
||||
saveButton; raw " "
|
||||
a [ _href cancelLink; _hxGet cancelLink; _class "btn btn-secondary"; _hxTarget "body" ] [ raw "Cancel" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
/// Display a list of chapters
|
||||
let chapterList withNew (model: ManageChaptersModel) app =
|
||||
form [ _method "post"; _id "chapter_list"; _class "container mb-3"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class "col-3 col-md-2" ] [ raw "Start" ]
|
||||
div [ _class "col-3 col-md-6 col-lg-8" ] [ raw "Title" ]
|
||||
div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ raw "Image?" ]
|
||||
div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ raw "Location?" ]
|
||||
]
|
||||
yield! model.Chapters |> List.mapi (fun idx chapter ->
|
||||
div [ _class "row mwl-table-detail"; _id $"chapter{idx}" ] [
|
||||
div [ _class "col-3 col-md-2" ] [ txt (startTimePattern.Format chapter.StartTime) ]
|
||||
div [ _class "col-3 col-md-6 col-lg-8" ] [
|
||||
match chapter.Title with
|
||||
| Some title -> txt title
|
||||
| None -> em [ _class "text-muted" ] [ raw "no title" ]
|
||||
br []
|
||||
small [] [
|
||||
if withNew then
|
||||
raw " "
|
||||
else
|
||||
let chapterUrl = relUrl app $"admin/post/{model.Id}/chapter/{idx}"
|
||||
a [ _href chapterUrl; _hxGet chapterUrl; _hxTarget $"#chapter{idx}"
|
||||
_hxSwap $"{HxSwap.InnerHtml} show:#chapter{idx}:top" ] [
|
||||
raw "Edit"
|
||||
]
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href chapterUrl; _hxDelete chapterUrl; _class "text-danger" ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ yesOrNo (Option.isSome chapter.ImageUrl) ]
|
||||
div [ _class "col-3 col-md-2 col-lg-1 text-center" ] [ yesOrNo (Option.isSome chapter.Location) ]
|
||||
])
|
||||
div [ _class "row pb-3"; _id "chapter-1" ] [
|
||||
let newLink = relUrl app $"admin/post/{model.Id}/chapter/-1"
|
||||
if withNew then
|
||||
span [ _hxGet newLink; _hxTarget "#chapter-1"; _hxTrigger "load"; _hxSwap "show:#chapter-1:top" ] []
|
||||
else
|
||||
div [ _class "row pb-3 mwl-table-detail" ] [
|
||||
div [ _class "col-12" ] [
|
||||
a [ _class "btn btn-primary"; _href newLink; _hxGet newLink; _hxTarget "#chapter-1"
|
||||
_hxSwap "show:#chapter-1:top" ] [
|
||||
raw "Add a New Chapter"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
/// Manage Chapters page
|
||||
let chapters withNew (model: ManageChaptersModel) app = [
|
||||
h2 [ _class "my-3" ] [ txt app.PageTitle ]
|
||||
article [] [
|
||||
p [ _style "line-height:1.2rem;" ] [
|
||||
strong [] [ txt model.Title ]; br []
|
||||
small [ _class "text-muted" ] [
|
||||
a [ _href (relUrl app $"admin/post/{model.Id}/edit") ] [
|
||||
raw "« Back to Edit Post"
|
||||
]
|
||||
]
|
||||
]
|
||||
yield! chapterList withNew model app
|
||||
]
|
||||
]
|
||||
|
||||
/// Display a list of posts
|
||||
let list (model: PostDisplay) app = [
|
||||
let dateCol = "col-xs-12 col-md-3 col-lg-2"
|
||||
let titleCol = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4"
|
||||
let authorCol = "col-xs-12 col-md-2 col-lg-1"
|
||||
let tagCol = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block"
|
||||
h2 [ _class "my-3" ] [ txt app.PageTitle ]
|
||||
article [] [
|
||||
a [ _href (relUrl app "admin/post/new/edit"); _class "btn btn-primary btn-sm mb-3" ] [ raw "Write a New Post" ]
|
||||
if model.Posts.Length > 0 then
|
||||
form [ _method "post"; _class "container mb-3"; _hxTarget "body" ] [
|
||||
antiCsrf app
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class dateCol ] [
|
||||
span [ _class "d-md-none" ] [ raw "Post" ]; span [ _class "d-none d-md-inline" ] [ raw "Date" ]
|
||||
]
|
||||
div [ _class $"{titleCol} d-none d-md-inline-block" ] [ raw "Title" ]
|
||||
div [ _class $"{authorCol} d-none d-md-inline-block" ] [ raw "Author" ]
|
||||
div [ _class tagCol ] [ raw "Tags" ]
|
||||
]
|
||||
for post in model.Posts do
|
||||
div [ _class "row mwl-table-detail" ] [
|
||||
div [ _class $"{dateCol} no-wrap" ] [
|
||||
small [ _class "d-md-none" ] [
|
||||
if post.PublishedOn.HasValue then
|
||||
raw "Published "; txt (post.PublishedOn.Value.ToString "MMMM d, yyyy")
|
||||
else raw "Not Published"
|
||||
if post.PublishedOn.HasValue && post.PublishedOn.Value <> post.UpdatedOn then
|
||||
em [ _class "text-muted" ] [
|
||||
raw " (Updated "; txt (post.UpdatedOn.ToString "MMMM d, yyyy"); raw ")"
|
||||
]
|
||||
]
|
||||
span [ _class "d-none d-md-inline" ] [
|
||||
if post.PublishedOn.HasValue then txt (post.PublishedOn.Value.ToString "MMMM d, yyyy")
|
||||
else raw "Not Published"
|
||||
if not post.PublishedOn.HasValue || post.PublishedOn.Value <> post.UpdatedOn then
|
||||
br []
|
||||
small [ _class "text-muted" ] [
|
||||
em [] [ txt (post.UpdatedOn.ToString "MMMM d, yyyy") ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class titleCol ] [
|
||||
if Option.isSome post.Episode then
|
||||
span [ _class "badge bg-success float-end text-uppercase mt-1" ] [ raw "Episode" ]
|
||||
raw post.Title; br []
|
||||
small [] [
|
||||
let postUrl = relUrl app $"admin/post/{post.Id}"
|
||||
a [ _href (relUrl app post.Permalink); _target "_blank" ] [ raw "View Post" ]
|
||||
if app.IsEditor || (app.IsAuthor && app.UserId.Value = WebLogUserId post.AuthorId) then
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href $"{postUrl}/edit" ] [ raw "Edit" ]
|
||||
if app.IsWebLogAdmin then
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href postUrl; _hxDelete postUrl; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the post “{post.Title}”? This action cannot be undone." ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class authorCol ] [
|
||||
let author =
|
||||
model.Authors
|
||||
|> List.tryFind (fun a -> a.Name = post.AuthorId)
|
||||
|> Option.map _.Value
|
||||
|> Option.defaultValue "--"
|
||||
|> txt
|
||||
small [ _class "d-md-none" ] [
|
||||
raw "Authored by "; author; raw " | "
|
||||
raw (if post.Tags.Length = 0 then "No" else string post.Tags.Length)
|
||||
raw " Tag"; if post.Tags.Length <> 0 then raw "s"
|
||||
]
|
||||
span [ _class "d-none d-md-inline" ] [ author ]
|
||||
]
|
||||
div [ _class tagCol ] [
|
||||
let tags =
|
||||
post.Tags |> List.mapi (fun idx tag -> idx, span [ _class "no-wrap" ] [ txt tag ])
|
||||
for tag in tags do
|
||||
snd tag
|
||||
if fst tag < tags.Length - 1 then raw ", "
|
||||
]
|
||||
]
|
||||
]
|
||||
if Option.isSome model.NewerLink || Option.isSome model.OlderLink then
|
||||
div [ _class "d-flex justify-content-evenly mb-3" ] [
|
||||
div [] [
|
||||
if Option.isSome model.NewerLink then
|
||||
p [] [
|
||||
a [ _href model.NewerLink.Value; _class "btn btn-secondary"; ] [
|
||||
raw "« Newer Posts"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "text-right" ] [
|
||||
if Option.isSome model.OlderLink then
|
||||
p [] [
|
||||
a [ _href model.OlderLink.Value; _class "btn btn-secondary" ] [
|
||||
raw "Older Posts »"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
else
|
||||
p [ _class "text-muted fst-italic text-center" ] [ raw "This web log has no posts" ]
|
||||
]
|
||||
]
|
||||
|
||||
let postEdit (model: EditPostModel) templates (ratings: MetaItem list) app = [
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
form [ _action (relUrl app "admin/post/save"); _method "post"; _hxPushUrl "true"; _class "container" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name (nameof model.Id); _value model.Id ]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col-12 col-lg-9" ] [
|
||||
yield! commonEdit model app
|
||||
textField [ _class "mb-3" ] (nameof model.Tags) "Tags" model.Tags [
|
||||
div [ _class "form-text" ] [ raw "comma-delimited" ]
|
||||
]
|
||||
if model.Status = string Draft then
|
||||
checkboxSwitch [ _class "mb-2" ] (nameof model.DoPublish) "Publish This Post" model.DoPublish []
|
||||
saveButton
|
||||
hr [ _class "mb-3" ]
|
||||
fieldset [ _class "mb-3" ] [
|
||||
legend [] [
|
||||
span [ _class "form-check form-switch" ] [
|
||||
small [] [
|
||||
input [ _type "checkbox"; _name (nameof model.IsEpisode)
|
||||
_id (nameof model.IsEpisode); _class "form-check-input"; _value "true"
|
||||
_data "bs-toggle" "collapse"; _data "bs-target" "#episode_items"
|
||||
_onclick "Admin.toggleEpisodeFields()"; if model.IsEpisode then _checked ]
|
||||
]
|
||||
label [ _for (nameof model.IsEpisode) ] [ raw "Podcast Episode" ]
|
||||
]
|
||||
]
|
||||
div [ _id "episode_items"
|
||||
_class $"""container p-0 collapse{if model.IsEpisode then " show" else ""}""" ] [
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-8 pb-3" ] [
|
||||
textField [ _required ] (nameof model.Media) "Media File" model.Media [
|
||||
div [ _class "form-text" ] [
|
||||
raw "Relative URL will be appended to base media path (if set) "
|
||||
raw "or served from this web log"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 pb-3" ] [
|
||||
textField [] (nameof model.MediaType) "Media MIME Type" model.MediaType [
|
||||
div [ _class "form-text" ] [ raw "Optional; overrides podcast default" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col" ] [
|
||||
numberField [ _required ] (nameof model.Length) "Media Length (bytes)"
|
||||
(string model.Length) [
|
||||
div [ _class "form-text" ] [ raw "TODO: derive from above file name" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col" ] [
|
||||
textField [] (nameof model.Duration) "Duration" model.Duration [
|
||||
div [ _class "form-text" ] [
|
||||
raw "Recommended; enter in "; code [] [ raw "HH:MM:SS"]; raw " format"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col" ] [
|
||||
textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle [
|
||||
div [ _class "form-text" ] [ raw "Optional; a subtitle for this episode" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-8 pb-3" ] [
|
||||
textField [] (nameof model.ImageUrl) "Image URL" model.ImageUrl [
|
||||
div [ _class "form-text" ] [
|
||||
raw "Optional; overrides podcast default; "
|
||||
raw "relative URL served from this web log"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 pb-3" ] [
|
||||
selectField [] (nameof model.Explicit) "Explicit Rating" model.Explicit ratings
|
||||
(_.Name) (_.Value) [
|
||||
div [ _class "form-text" ] [ raw "Optional; overrides podcast default" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-8 pb-3" ] [
|
||||
div [ _class "form-text" ] [ raw "Chapters" ]
|
||||
div [ _class "form-check form-check-inline" ] [
|
||||
input [ _type "radio"; _name (nameof model.ChapterSource)
|
||||
_id "chapter_source_none"; _value "none"; _class "form-check-input"
|
||||
if model.ChapterSource = "none" then _checked
|
||||
_onclick "Admin.setChapterSource('none')" ]
|
||||
label [ _for "chapter_source_none" ] [ raw "None" ]
|
||||
]
|
||||
div [ _class "form-check form-check-inline" ] [
|
||||
input [ _type "radio"; _name (nameof model.ChapterSource)
|
||||
_id "chapter_source_internal"; _value "internal"
|
||||
_class "form-check-input"
|
||||
if model.ChapterSource= "internal" then _checked
|
||||
_onclick "Admin.setChapterSource('internal')" ]
|
||||
label [ _for "chapter_source_internal" ] [ raw "Defined Here" ]
|
||||
]
|
||||
div [ _class "form-check form-check-inline" ] [
|
||||
input [ _type "radio"; _name (nameof model.ChapterSource)
|
||||
_id "chapter_source_external"; _value "external"
|
||||
_class "form-check-input"
|
||||
if model.ChapterSource = "external" then _checked
|
||||
_onclick "Admin.setChapterSource('external')" ]
|
||||
label [ _for "chapter_source_external" ] [ raw "Separate File" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-md-4 d-flex justify-content-center" ] [
|
||||
checkboxSwitch [ _class "align-self-center pb-3" ] (nameof model.ContainsWaypoints)
|
||||
"Chapters contain waypoints" model.ContainsWaypoints []
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-8 pb-3" ] [
|
||||
textField [] (nameof model.ChapterFile) "Chapter File" model.ChapterFile [
|
||||
div [ _class "form-text" ] [ raw "Relative URL served from this web log" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 pb-3" ] [
|
||||
textField [] (nameof model.ChapterType) "Chapter MIME Type" model.ChapterType [
|
||||
div [ _class "form-text" ] [
|
||||
raw "Optional; "; code [] [ raw "application/json+chapters" ]
|
||||
raw " assumed if chapter file ends with "; code [] [ raw ".json" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-8 pb-3" ] [
|
||||
textField [ _onkeyup "Admin.requireTranscriptType()" ] (nameof model.TranscriptUrl)
|
||||
"Transcript URL" model.TranscriptUrl [
|
||||
div [ _class "form-text" ] [
|
||||
raw "Optional; relative URL served from this web log"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 pb-3" ] [
|
||||
textField [ if model.TranscriptUrl <> "" then _required ]
|
||||
(nameof model.TranscriptType) "Transcript MIME Type"
|
||||
model.TranscriptType [
|
||||
div [ _class "form-text" ] [ raw "Required if transcript URL provided" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col" ] [
|
||||
textField [] (nameof model.TranscriptLang) "Transcript Language"
|
||||
model.TranscriptLang [
|
||||
div [ _class "form-text" ] [ raw "Optional; overrides podcast default" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col d-flex justify-content-center" ] [
|
||||
checkboxSwitch [ _class "align-self-center pb-3" ] (nameof model.TranscriptCaptions)
|
||||
"This is a captions file" model.TranscriptCaptions []
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col col-md-4" ] [
|
||||
numberField [] (nameof model.SeasonNumber) "Season Number"
|
||||
(string model.SeasonNumber) [
|
||||
div [ _class "form-text" ] [ raw "Optional" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col col-md-8" ] [
|
||||
textField [ _maxlength "128" ] (nameof model.SeasonDescription) "Season Description"
|
||||
model.SeasonDescription [
|
||||
div [ _class "form-text" ] [ raw "Optional" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col col-md-4" ] [
|
||||
numberField [ _step "0.01" ] (nameof model.EpisodeNumber) "Episode Number"
|
||||
model.EpisodeNumber [
|
||||
div [ _class "form-text" ] [ raw "Optional; up to 2 decimal points" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col col-md-8" ] [
|
||||
textField [ _maxlength "128" ] (nameof model.EpisodeDescription)
|
||||
"Episode Description" model.EpisodeDescription [
|
||||
div [ _class "form-text" ] [ raw "Optional" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
script [] [
|
||||
raw """document.addEventListener("DOMContentLoaded", () => Admin.toggleEpisodeFields())"""
|
||||
]
|
||||
]
|
||||
commonMetaItems model
|
||||
if model.Status = string Published then
|
||||
fieldset [ _class "pb-3" ] [
|
||||
legend [] [ raw "Maintenance" ]
|
||||
div [ _class "container" ] [
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col align-self-center" ] [
|
||||
checkboxSwitch [ _class "pb-2" ] (nameof model.SetPublished)
|
||||
"Set Published Date" model.SetPublished []
|
||||
]
|
||||
div [ _class "col-4" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "datetime-local"; _name (nameof model.PubOverride)
|
||||
_id (nameof model.PubOverride); _class "form-control"
|
||||
_placeholder "Override Date"
|
||||
if model.PubOverride.HasValue then
|
||||
_value (model.PubOverride.Value.ToString "yyyy-MM-dd\THH:mm") ]
|
||||
label [ _for (nameof model.PubOverride); _class "form-label" ] [
|
||||
raw "Published On"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-5 align-self-center" ] [
|
||||
checkboxSwitch [ _class "pb-2" ] (nameof model.SetUpdated)
|
||||
"Purge revisions and<br>set as updated date as well"
|
||||
model.SetUpdated []
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-3" ] [
|
||||
commonTemplates model templates
|
||||
fieldset [] [
|
||||
legend [] [ raw "Categories" ]
|
||||
for cat in app.Categories do
|
||||
div [ _class "form-check" ] [
|
||||
input [ _type "checkbox"; _name (nameof model.CategoryIds); _id $"category_{cat.Id}"
|
||||
_class "form-check-input"; _value cat.Id
|
||||
if model.CategoryIds |> Array.contains cat.Id then _checked ]
|
||||
label [ _for $"category_{cat.Id}"; _class "form-check-label"
|
||||
match cat.Description with Some it -> _title it | None -> () ] [
|
||||
yield! cat.ParentNames |> Array.map (fun _ -> raw " ⟩ ")
|
||||
txt cat.Name
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
script [] [ raw "window.setTimeout(() => Admin.toggleEpisodeFields(), 500)" ]
|
||||
]
|
||||
258
src/MyWebLog/Views/User.fs
Normal file
258
src/MyWebLog/Views/User.fs
Normal file
@@ -0,0 +1,258 @@
|
||||
module MyWebLog.Views.User
|
||||
|
||||
open Giraffe.Htmx.Common
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// User edit form
|
||||
let edit (model: EditUserModel) app =
|
||||
let levelOption value name =
|
||||
option [ _value value; if model.AccessLevel = value then _selected ] [ txt name ]
|
||||
div [ _class "col-12" ] [
|
||||
h5 [ _class "my-3" ] [ txt app.PageTitle ]
|
||||
form [ _hxPost (relUrl app "admin/settings/user/save"); _method "post"; _class "container"
|
||||
_hxTarget "#user_panel"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-5 col-lg-3 col-xxl-2 offset-xxl-1 mb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
select [ _name "AccessLevel"; _id "accessLevel"; _class "form-control"; _required
|
||||
_autofocus ] [
|
||||
levelOption (string Author) "Author"
|
||||
levelOption (string Editor) "Editor"
|
||||
levelOption (string WebLogAdmin) "Web Log Admin"
|
||||
if app.IsAdministrator then levelOption (string Administrator) "Administrator"
|
||||
]
|
||||
label [ _for "accessLevel" ] [ raw "Access Level" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-7 col-lg-4 col-xxl-3 mb-3" ] [
|
||||
emailField [ _required ] (nameof model.Email) "E-mail Address" model.Email []
|
||||
]
|
||||
div [ _class "col-12 col-lg-5 mb-3" ] [
|
||||
textField [] (nameof model.Url) "User’s Personal URL" model.Url []
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col-12 col-md-6 col-lg-4 col-xl-3 offset-xl-1 pb-3" ] [
|
||||
textField [ _required ] (nameof model.FirstName) "First Name" model.FirstName []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-lg-4 col-xl-3 pb-3" ] [
|
||||
textField [ _required ] (nameof model.LastName) "Last Name" model.LastName []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 offset-md-3 col-lg-4 offset-lg-0 col-xl-3 offset-xl-1 pb-3" ] [
|
||||
textField [ _required ] (nameof model.PreferredName) "Preferred Name" model.PreferredName []
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col-12 col-xl-10 offset-xl-1" ] [
|
||||
fieldset [ _class "p-2" ] [
|
||||
legend [ _class "ps-1" ] [
|
||||
if not model.IsNew then raw "Change "
|
||||
raw "Password"
|
||||
]
|
||||
if not model.IsNew then
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col" ] [
|
||||
p [ _class "form-text" ] [
|
||||
raw "Optional; leave blank not change the user’s password"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
let attrs, newLbl = if model.IsNew then [ _required ], "" else [], "New "
|
||||
div [ _class "col-12 col-md-6 pb-3" ] [
|
||||
passwordField attrs (nameof model.Password) $"{newLbl}Password" "" []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 pb-3" ] [
|
||||
passwordField attrs (nameof model.PasswordConfirm) $"Confirm {newLbl}Password" "" []
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
saveButton; raw " "
|
||||
if model.IsNew then
|
||||
button [ _type "button"; _class "btn btn-sm btn-secondary ms-3"
|
||||
_onclick "document.getElementById('user_new').innerHTML = ''" ] [
|
||||
raw "Cancel"
|
||||
]
|
||||
else
|
||||
a [ _href (relUrl app "admin/settings/users"); _class "btn btn-sm btn-secondary ms-3" ] [
|
||||
raw "Cancel"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
|
||||
/// User log on form
|
||||
let logOn (model: LogOnModel) (app: AppViewContext) = [
|
||||
h2 [ _class "my-3" ] [ rawText "Log On to "; encodedText app.WebLog.Name ]
|
||||
article [ _class "py-3" ] [
|
||||
form [ _action (relUrl app "user/log-on"); _method "post"; _class "container"; _hxPushUrl "true" ] [
|
||||
antiCsrf app
|
||||
if Option.isSome model.ReturnTo then input [ _type "hidden"; _name "ReturnTo"; _value model.ReturnTo.Value ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-6 col-lg-4 offset-lg-2 pb-3" ] [
|
||||
emailField [ _required; _autofocus ] (nameof model.EmailAddress) "E-mail Address" "" []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [
|
||||
passwordField [ _required ] (nameof model.Password) "Password" "" []
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ rawText "Log On" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The list of users for a web log (part of web log settings page)
|
||||
let userList (model: WebLogUser list) app =
|
||||
let userCol = "col-12 col-md-4 col-xl-3"
|
||||
let emailCol = "col-12 col-md-4 col-xl-4"
|
||||
let cre8Col = "d-none d-xl-block col-xl-2"
|
||||
let lastCol = "col-12 col-md-4 col-xl-3"
|
||||
let badge = "ms-2 badge bg"
|
||||
let userDetail (user: WebLogUser) =
|
||||
div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [
|
||||
div [ _class $"{userCol} no-wrap" ] [
|
||||
txt user.PreferredName; raw " "
|
||||
match user.AccessLevel with
|
||||
| Administrator -> span [ _class $"{badge}-success" ] [ raw "ADMINISTRATOR" ]
|
||||
| WebLogAdmin -> span [ _class $"{badge}-primary" ] [ raw "WEB LOG ADMIN" ]
|
||||
| Editor -> span [ _class $"{badge}-secondary" ] [ raw "EDITOR" ]
|
||||
| Author -> span [ _class $"{badge}-dark" ] [ raw "AUTHOR" ]
|
||||
br []
|
||||
if app.IsAdministrator || (app.IsWebLogAdmin && not (user.AccessLevel = Administrator)) then
|
||||
let userUrl = relUrl app $"admin/settings/user/{user.Id}"
|
||||
small [] [
|
||||
a [ _href $"{userUrl}/edit"; _hxTarget $"#user_{user.Id}"
|
||||
_hxSwap $"{HxSwap.InnerHtml} show:#user_{user.Id}:top" ] [
|
||||
raw "Edit"
|
||||
]
|
||||
if app.UserId.Value <> user.Id then
|
||||
span [ _class "text-muted" ] [ raw " • " ]
|
||||
a [ _href userUrl; _hxDelete userUrl; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the user “{user.PreferredName}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)" ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class emailCol ] [
|
||||
txt $"{user.FirstName} {user.LastName}"; br []
|
||||
small [ _class "text-muted" ] [
|
||||
txt user.Email
|
||||
if Option.isSome user.Url then
|
||||
br []; txt user.Url.Value
|
||||
]
|
||||
]
|
||||
div [ _class "d-none d-xl-block col-xl-2" ] [
|
||||
if user.CreatedOn = Noda.epoch then raw "N/A" else longDate app user.CreatedOn
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-xl-3" ] [
|
||||
match user.LastSeenOn with
|
||||
| Some it -> longDate app it; raw " at "; shortTime app it
|
||||
| None -> raw "--"
|
||||
]
|
||||
]
|
||||
div [ _id "user_panel" ] [
|
||||
a [ _href (relUrl app "admin/settings/user/new/edit"); _class "btn btn-primary btn-sm mb-3"
|
||||
_hxTarget "#user_new" ] [
|
||||
raw "Add a New User"
|
||||
]
|
||||
div [ _class "container g-0" ] [
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class userCol ] [
|
||||
raw "User"; span [ _class "d-md-none" ] [ raw "; Full Name / E-mail; Last Log On" ]
|
||||
]
|
||||
div [ _class $"{emailCol} d-none d-md-inline-block" ] [ raw "Full Name / E-mail" ]
|
||||
div [ _class cre8Col ] [ raw "Created" ]
|
||||
div [ _class $"{lastCol} d-none d-md-block" ] [ raw "Last Log On" ]
|
||||
]
|
||||
]
|
||||
div [ _id "userList" ] [
|
||||
div [ _class "container g-0" ] [
|
||||
div [ _class "row mwl-table-detail"; _id "user_new" ] []
|
||||
]
|
||||
form [ _method "post"; _class "container g-0"; _hxTarget "#user_panel"
|
||||
_hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [
|
||||
antiCsrf app
|
||||
yield! List.map userDetail model
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
|
||||
/// Edit My Info form
|
||||
let myInfo (model: EditMyInfoModel) (user: WebLogUser) app = [
|
||||
h2 [ _class "my-3" ] [ txt app.PageTitle ]
|
||||
article [] [
|
||||
form [ _action (relUrl app "admin/my-info"); _method "post" ] [
|
||||
antiCsrf app
|
||||
div [ _class "d-flex flex-row flex-wrap justify-content-around" ] [
|
||||
div [ _class "text-center mb-3 lh-sm" ] [
|
||||
strong [ _class "text-decoration-underline" ] [ raw "Access Level" ]; br []
|
||||
raw (string user.AccessLevel)
|
||||
]
|
||||
div [ _class "text-center mb-3 lh-sm" ] [
|
||||
strong [ _class "text-decoration-underline" ] [ raw "Created" ]; br []
|
||||
if user.CreatedOn = Noda.epoch then raw "N/A" else longDate app user.CreatedOn
|
||||
]
|
||||
div [ _class "text-center mb-3 lh-sm" ] [
|
||||
strong [ _class "text-decoration-underline" ] [ raw "Last Log On" ]; br []
|
||||
longDate app user.LastSeenOn.Value; raw " at "; shortTime app user.LastSeenOn.Value
|
||||
]
|
||||
]
|
||||
div [ _class "container" ] [
|
||||
div [ _class "row" ] [ div [ _class "col" ] [ hr [ _class "mt-0" ] ] ]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [
|
||||
textField [ _required; _autofocus ] (nameof model.FirstName) "First Name" model.FirstName []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [
|
||||
textField [ _required ] (nameof model.LastName) "Last Name" model.LastName []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [
|
||||
textField [ _required ] (nameof model.PreferredName) "Preferred Name" model.PreferredName []
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col" ] [
|
||||
fieldset [ _class "p-2" ] [
|
||||
legend [ _class "ps-1" ] [ raw "Change Password" ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col" ] [
|
||||
p [ _class "form-text" ] [
|
||||
raw "Optional; leave blank to keep your current password"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-6 pb-3" ] [
|
||||
passwordField [] (nameof model.NewPassword) "New Password" "" []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 pb-3" ] [
|
||||
passwordField [] (nameof model.NewPasswordConfirm) "Confirm New Password" "" []
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [ div [ _class "col text-center mb-3" ] [ saveButton ] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
895
src/MyWebLog/Views/WebLog.fs
Normal file
895
src/MyWebLog/Views/WebLog.fs
Normal file
@@ -0,0 +1,895 @@
|
||||
module MyWebLog.Views.WebLog
|
||||
|
||||
open Giraffe.Htmx.Common
|
||||
open Giraffe.ViewEngine
|
||||
open Giraffe.ViewEngine.Accessibility
|
||||
open Giraffe.ViewEngine.Htmx
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Form to add or edit a category
|
||||
let categoryEdit (model: EditCategoryModel) app =
|
||||
div [ _class "col-12" ] [
|
||||
h5 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
form [ _action (relUrl app "admin/category/save"); _method "post"; _class "container" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name (nameof model.CategoryId); _value model.CategoryId ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3" ] [
|
||||
textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name []
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3" ] [
|
||||
textField [ _required ] (nameof model.Slug) "Slug" model.Slug []
|
||||
]
|
||||
div [ _class "col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3" ] [
|
||||
let cats =
|
||||
app.Categories
|
||||
|> Seq.ofArray
|
||||
|> Seq.filter (fun c -> c.Id <> model.CategoryId)
|
||||
|> Seq.map (fun c ->
|
||||
let parents =
|
||||
c.ParentNames
|
||||
|> Array.map (fun it -> $"{it} ⟩ ")
|
||||
|> String.concat ""
|
||||
{ Name = c.Id; Value = $"{parents}{c.Name}" })
|
||||
|> Seq.append [ { Name = ""; Value = "– None –" } ]
|
||||
selectField [] (nameof model.ParentId) "Parent Category" model.ParentId cats (_.Name) (_.Value) []
|
||||
]
|
||||
div [ _class "col-12 col-xl-10 offset-xl-1 mb-3" ] [
|
||||
textField [] (nameof model.Description) "Description" model.Description []
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
saveButton
|
||||
a [ _href (relUrl app "admin/categories"); _class "btn btn-sm btn-secondary ms-3" ] [ raw "Cancel" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
|
||||
/// Category list page
|
||||
let categoryList includeNew app = [
|
||||
let catCol = "col-12 col-md-6 col-xl-5 col-xxl-4"
|
||||
let descCol = "col-12 col-md-6 col-xl-7 col-xxl-8"
|
||||
let categoryDetail (cat: DisplayCategory) =
|
||||
div [ _class "row mwl-table-detail"; _id $"cat_{cat.Id}" ] [
|
||||
div [ _class $"{catCol} no-wrap" ] [
|
||||
if cat.ParentNames.Length > 0 then
|
||||
cat.ParentNames
|
||||
|> Seq.ofArray
|
||||
|> Seq.map (fun it -> raw $"{it} ⟩ ")
|
||||
|> List.ofSeq
|
||||
|> small [ _class "text-muted" ]
|
||||
raw cat.Name; br []
|
||||
small [] [
|
||||
let catUrl = relUrl app $"admin/category/{cat.Id}"
|
||||
if cat.PostCount > 0 then
|
||||
a [ _href (relUrl app $"category/{cat.Slug}"); _target "_blank" ] [
|
||||
raw $"View { cat.PostCount} Post"; if cat.PostCount <> 1 then raw "s"
|
||||
]; actionSpacer
|
||||
a [ _href $"{catUrl}/edit"; _hxTarget $"#cat_{cat.Id}"
|
||||
_hxSwap $"{HxSwap.InnerHtml} show:#cat_{cat.Id}:top" ] [
|
||||
raw "Edit"
|
||||
]; actionSpacer
|
||||
a [ _href catUrl; _hxDelete catUrl; _hxTarget "body"; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the category “{cat.Name}”? This action cannot be undone." ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class descCol ] [
|
||||
match cat.Description with Some value -> raw value | None -> em [ _class "text-muted" ] [ raw "none" ]
|
||||
]
|
||||
]
|
||||
let loadNew =
|
||||
span [ _hxGet (relUrl app "admin/category/new/edit"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] []
|
||||
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
a [ _href (relUrl app "admin/category/new/edit"); _class "btn btn-primary btn-sm mb-3"; _hxTarget "#cat_new" ] [
|
||||
raw "Add a New Category"
|
||||
]
|
||||
div [ _id "catList"; _class "container" ] [
|
||||
if app.Categories.Length = 0 then
|
||||
if includeNew then loadNew
|
||||
else
|
||||
div [ _id "cat_new" ] [
|
||||
p [ _class "text-muted fst-italic text-center" ] [
|
||||
raw "This web log has no categories defined"
|
||||
]
|
||||
]
|
||||
else
|
||||
div [ _class "container" ] [
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class catCol ] [ raw "Category"; span [ _class "d-md-none" ] [ raw "; Description" ] ]
|
||||
div [ _class $"{descCol} d-none d-md-inline-block" ] [ raw "Description" ]
|
||||
]
|
||||
]
|
||||
form [ _method "post"; _class "container" ] [
|
||||
antiCsrf app
|
||||
div [ _class "row mwl-table-detail"; _id "cat_new" ] [ if includeNew then loadNew ]
|
||||
yield! app.Categories |> Seq.ofArray |> Seq.map categoryDetail |> List.ofSeq
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The main dashboard
|
||||
let dashboard (model: DashboardModel) app = [
|
||||
h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " • Dashboard" ]
|
||||
article [ _class "container" ] [
|
||||
div [ _class "row" ] [
|
||||
section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-primary" ] [ raw "Posts" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3" ] [
|
||||
raw "Published "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Posts) ]
|
||||
raw " Drafts "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Drafts) ]
|
||||
]
|
||||
if app.IsAuthor then
|
||||
a [ _href (relUrl app "admin/posts"); _class "btn btn-secondary me-2" ] [ raw "View All" ]
|
||||
a [ _href (relUrl app "admin/post/new/edit"); _class "btn btn-primary" ] [
|
||||
raw "Write a New Post"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
section [ _class "col-lg-5 col-xl-4 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-primary" ] [ raw "Pages" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3" ] [
|
||||
raw "All "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Pages) ]
|
||||
raw " Shown in Page List "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.ListedPages) ]
|
||||
]
|
||||
if app.IsAuthor then
|
||||
a [ _href (relUrl app "admin/pages"); _class "btn btn-secondary me-2" ] [ raw "View All" ]
|
||||
a [ _href (relUrl app "admin/page/new/edit"); _class "btn btn-primary" ] [
|
||||
raw "Create a New Page"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [
|
||||
div [ _class "card" ] [
|
||||
header [ _class "card-header text-white bg-secondary" ] [ raw "Categories" ]
|
||||
div [ _class "card-body" ] [
|
||||
h6 [ _class "card-subtitle text-muted pb-3"] [
|
||||
raw "All "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Categories) ]
|
||||
raw " Top Level "
|
||||
span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.TopLevelCategories) ]
|
||||
]
|
||||
if app.IsWebLogAdmin then
|
||||
a [ _href (relUrl app "admin/categories"); _class "btn btn-secondary me-2" ] [
|
||||
raw "View All"
|
||||
]
|
||||
a [ _href (relUrl app "admin/categories?new"); _class "btn btn-secondary" ] [
|
||||
raw "Add a New Category"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
if app.IsWebLogAdmin then
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-end" ] [
|
||||
a [ _href (relUrl app "admin/settings"); _class "btn btn-secondary" ] [ raw "Modify Settings" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Custom RSS feed edit form
|
||||
let feedEdit (model: EditCustomFeedModel) (ratings: MetaItem list) (mediums: MetaItem list) app = [
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
form [ _action (relUrl app "admin/settings/rss/save"); _method "post"; _class "container" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col" ] [
|
||||
a [ _href (relUrl app "admin/settings#rss-settings") ] [ raw "« Back to Settings" ]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col-12 col-lg-6" ] [
|
||||
fieldset [ _class "container pb-0" ] [
|
||||
legend [] [ raw "Identification" ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col" ] [
|
||||
textField [ _required ] (nameof model.Path) "Relative Feed Path" model.Path [
|
||||
span [ _class "form-text fst-italic" ] [ raw "Appended to "; txt app.WebLog.UrlBase ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col py-3 d-flex align-self-center justify-content-center" ] [
|
||||
checkboxSwitch [ _onclick "Admin.checkPodcast()"; if model.IsPodcast then _checked ]
|
||||
(nameof model.IsPodcast) "This Is a Podcast Feed" model.IsPodcast []
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-6" ] [
|
||||
fieldset [ _class "container pb-0" ] [
|
||||
legend [] [ raw "Feed Source" ]
|
||||
div [ _class "row d-flex align-items-center" ] [
|
||||
div [ _class "col-1 d-flex justify-content-end pb-3" ] [
|
||||
div [ _class "form-check form-check-inline me-0" ] [
|
||||
input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeCat"
|
||||
_class "form-check-input"; _value "category"
|
||||
if model.SourceType <> "tag" then _checked
|
||||
_onclick "Admin.customFeedBy('category')" ]
|
||||
label [ _for "SourceTypeCat"; _class "form-check-label d-none" ] [ raw "Category" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-11 pb-3" ] [
|
||||
let cats =
|
||||
app.Categories
|
||||
|> Seq.ofArray
|
||||
|> Seq.map (fun c ->
|
||||
let parents =
|
||||
c.ParentNames
|
||||
|> Array.map (fun it -> $"{it} ⟩ ")
|
||||
|> String.concat ""
|
||||
{ Name = c.Id; Value = $"{parents}{c.Name}" })
|
||||
|> Seq.append [ { Name = ""; Value = "– Select Category –" } ]
|
||||
selectField [ _id "SourceValueCat"; _required
|
||||
if model.SourceType = "tag" then _disabled ]
|
||||
(nameof model.SourceValue) "Category" model.SourceValue cats (_.Name)
|
||||
(_.Value) []
|
||||
]
|
||||
div [ _class "col-1 d-flex justify-content-end pb-3" ] [
|
||||
div [ _class "form-check form-check-inline me-0" ] [
|
||||
input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeTag"
|
||||
_class "form-check-input"; _value "tag"
|
||||
if model.SourceType= "tag" then _checked
|
||||
_onclick "Admin.customFeedBy('tag')" ]
|
||||
label [ _for "sourceTypeTag"; _class "form-check-label d-none" ] [ raw "Tag" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-11 pb-3" ] [
|
||||
textField [ _id "SourceValueTag"; _required
|
||||
if model.SourceType <> "tag" then _disabled ]
|
||||
(nameof model.SourceValue) "Tag"
|
||||
(if model.SourceType = "tag" then model.SourceValue else "") []
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col" ] [
|
||||
fieldset [ _class "container"; _id "podcastFields"; if not model.IsPodcast then _disabled ] [
|
||||
legend [] [ raw "Podcast Settings" ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [
|
||||
textField [ _required ] (nameof model.Title) "Title" model.Title []
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-lg-4 pb-3" ] [
|
||||
textField [] (nameof model.Subtitle) "Podcast Subtitle" model.Subtitle []
|
||||
]
|
||||
div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [
|
||||
numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes"
|
||||
(string model.ItemsInFeed) []
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [
|
||||
textField [ _required ] (nameof model.AppleCategory) "iTunes Category"
|
||||
model.AppleCategory [
|
||||
span [ _class "form-text fst-italic" ] [
|
||||
a [ _href "https://www.thepodcasthost.com/planning/itunes-podcast-categories/"
|
||||
_target "_blank"; _relNoOpener ] [
|
||||
raw "iTunes Category / Subcategory List"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 pb-3" ] [
|
||||
textField [] (nameof model.AppleSubcategory) "iTunes Subcategory" model.AppleSubcategory
|
||||
[]
|
||||
]
|
||||
div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [
|
||||
selectField [ _required ] (nameof model.Explicit) "Explicit Rating" model.Explicit
|
||||
ratings (_.Name) (_.Value) []
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-6 col-lg-4 offset-xxl-1 pb-3" ] [
|
||||
textField [ _required ] (nameof model.DisplayedAuthor) "Displayed Author"
|
||||
model.DisplayedAuthor []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [
|
||||
emailField [ _required ] (nameof model.Email) "Author E-mail" model.Email [
|
||||
span [ _class "form-text fst-italic" ] [
|
||||
raw "For iTunes, must match registered e-mail"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-sm-5 col-md-4 col-lg-4 col-xl-3 offset-xl-1 col-xxl-2 offset-xxl-0 pb-3" ] [
|
||||
textField [] (nameof model.DefaultMediaType) "Default Media Type"
|
||||
model.DefaultMediaType [
|
||||
span [ _class "form-text fst-italic" ] [ raw "Optional; blank for no default" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-sm-7 col-md-8 col-lg-10 offset-lg-1 pb-3" ] [
|
||||
textField [ _required ] (nameof model.ImageUrl) "Image URL" model.ImageUrl [
|
||||
span [ _class "form-text fst-italic"] [
|
||||
raw "Relative URL will be appended to "; txt app.WebLog.UrlBase; raw "/"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col-12 col-lg-10 offset-lg-1" ] [
|
||||
textField [ _required ] (nameof model.Summary) "Summary" model.Summary [
|
||||
span [ _class "form-text fst-italic" ] [ raw "Displayed in podcast directories" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col-12 col-lg-10 offset-lg-1" ] [
|
||||
textField [] (nameof model.MediaBaseUrl) "Media Base URL" model.MediaBaseUrl [
|
||||
span [ _class "form-text fst-italic" ] [
|
||||
raw "Optional; prepended to episode media file if present"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-lg-5 offset-lg-1 pb-3" ] [
|
||||
textField [] (nameof model.FundingUrl) "Funding URL" model.FundingUrl [
|
||||
span [ _class "form-text fst-italic" ] [
|
||||
raw "Optional; URL describing donation options for this podcast, "
|
||||
raw "relative URL supported"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-5 pb-3" ] [
|
||||
textField [ _maxlength "128" ] (nameof model.FundingText) "Funding Text"
|
||||
model.FundingText [
|
||||
span [ _class "form-text fst-italic" ] [ raw "Optional; text for the funding link" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col-8 col-lg-5 offset-lg-1 pb-3" ] [
|
||||
textField [] (nameof model.PodcastGuid) "Podcast GUID" model.PodcastGuid [
|
||||
span [ _class "form-text fst-italic" ] [
|
||||
raw "Optional; v5 UUID uniquely identifying this podcast; "
|
||||
raw "once entered, do not change this value ("
|
||||
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid"
|
||||
_target "_blank"; _relNoOpener ] [
|
||||
raw "documentation"
|
||||
]; raw ")"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-4 col-lg-3 offset-lg-2 pb-3" ] [
|
||||
selectField [] (nameof model.Medium) "Medium" model.Medium mediums (_.Name) (_.Value) [
|
||||
span [ _class "form-text fst-italic" ] [
|
||||
raw "Optional; medium of the podcast content ("
|
||||
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#medium"
|
||||
_target "_blank"; _relNoOpener ] [
|
||||
raw "documentation"
|
||||
]; raw ")"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [ div [ _class "col text-center" ] [ saveButton ] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Redirect Rule edit form
|
||||
let redirectEdit (model: EditRedirectRuleModel) app = [
|
||||
let url = relUrl app $"admin/settings/redirect-rules/{model.RuleId}"
|
||||
h3 [] [ raw (if model.RuleId < 0 then "Add" else "Edit"); raw " Redirect Rule" ]
|
||||
form [ _action url; _hxPost url; _hxTarget "body"; _method "post"; _class "container" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "RuleId"; _value (string model.RuleId) ]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-lg-5 mb-3" ] [
|
||||
textField [ _autofocus; _required ] (nameof model.From) "From" model.From [
|
||||
span [ _class "form-text" ] [ raw "From local URL/pattern" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-5 mb-3" ] [
|
||||
textField [ _required ] (nameof model.To) "To" model.To [
|
||||
span [ _class "form-text" ] [ raw "To URL/pattern" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-lg-2 mb-3" ] [
|
||||
checkboxSwitch [] (nameof model.IsRegex) "Use RegEx" model.IsRegex []
|
||||
]
|
||||
]
|
||||
if model.RuleId < 0 then
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col-12 text-center" ] [
|
||||
label [ _class "me-1" ] [ raw "Add Rule" ]
|
||||
div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "New rule placement button group" ] [
|
||||
input [ _type "radio"; _name "InsertAtTop"; _id "at_top"; _class "btn-check"; _value "true" ]
|
||||
label [ _class "btn btn-sm btn-outline-secondary"; _for "at_top" ] [ raw "Top" ]
|
||||
input [ _type "radio"; _name "InsertAtTop"; _id "at_bot"; _class "btn-check"; _value "false"
|
||||
_checked ]
|
||||
label [ _class "btn btn-sm btn-outline-secondary"; _for "at_bot" ] [ raw "Bottom" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
saveButton; raw " "
|
||||
a [ _href (relUrl app "admin/settings/redirect-rules"); _class "btn btn-sm btn-secondary ms-3" ] [
|
||||
raw "Cancel"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// The list of current redirect rules
|
||||
let redirectList (model: RedirectRule list) app = [
|
||||
// Generate the detail for a redirect rule
|
||||
let ruleDetail idx (rule: RedirectRule) =
|
||||
let ruleId = $"rule_{idx}"
|
||||
div [ _class "row mwl-table-detail"; _id ruleId ] [
|
||||
div [ _class "col-5 no-wrap" ] [
|
||||
txt rule.From; br []
|
||||
small [] [
|
||||
let ruleUrl = relUrl app $"admin/settings/redirect-rules/{idx}"
|
||||
a [ _href ruleUrl; _hxTarget $"#{ruleId}"; _hxSwap $"{HxSwap.InnerHtml} show:#{ruleId}:top" ] [
|
||||
raw "Edit"
|
||||
]
|
||||
if idx > 0 then
|
||||
actionSpacer; a [ _href $"{ruleUrl}/up"; _hxPost $"{ruleUrl}/up" ] [ raw "Move Up" ]
|
||||
if idx <> model.Length - 1 then
|
||||
actionSpacer; a [ _href $"{ruleUrl}/down"; _hxPost $"{ruleUrl}/down" ] [ raw "Move Down" ]
|
||||
actionSpacer
|
||||
a [ _class "text-danger"; _href ruleUrl; _hxDelete ruleUrl
|
||||
_hxConfirm "Are you sure you want to delete this redirect rule?" ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-5" ] [ txt rule.To ]
|
||||
div [ _class "col-2 text-center" ] [ yesOrNo rule.IsRegex ]
|
||||
]
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
p [ _class "mb-3" ] [
|
||||
a [ _href (relUrl app "admin/settings") ] [ raw "« Back to Settings" ]
|
||||
]
|
||||
div [ _class "container" ] [
|
||||
p [] [
|
||||
a [ _href (relUrl app "admin/settings/redirect-rules/-1"); _class "btn btn-primary btn-sm mb-3"
|
||||
_hxTarget "#rule_new" ] [
|
||||
raw "Add Redirect Rule"
|
||||
]
|
||||
]
|
||||
if List.isEmpty model then
|
||||
div [ _id "rule_new" ] [
|
||||
p [ _class "text-muted text-center fst-italic" ] [
|
||||
raw "This web log has no redirect rules defined"
|
||||
]
|
||||
]
|
||||
else
|
||||
div [ _class "container g-0" ] [
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class "col-5" ] [ raw "From" ]
|
||||
div [ _class "col-5" ] [ raw "To" ]
|
||||
div [ _class "col-2 text-center" ] [ raw "RegEx?" ]
|
||||
]
|
||||
]
|
||||
div [ _class "row mwl-table-detail"; _id "rule_new" ] []
|
||||
form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [
|
||||
antiCsrf app; yield! List.mapi ruleDetail model
|
||||
]
|
||||
]
|
||||
p [ _class "mt-3 text-muted fst-italic text-center" ] [
|
||||
raw "This is an advanced feature; please "
|
||||
a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#redirect-rules"
|
||||
_target "_blank" ] [
|
||||
raw "read and understand the documentation on this feature"
|
||||
]
|
||||
raw " before adding rules."
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Edit a tag mapping
|
||||
let tagMapEdit (model: EditTagMapModel) app = [
|
||||
h5 [ _class "my-3" ] [ txt app.PageTitle ]
|
||||
form [ _hxPost (relUrl app "admin/settings/tag-mapping/save"); _method "post"; _class "container"
|
||||
_hxTarget "#tagList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [
|
||||
antiCsrf app
|
||||
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col-6 col-lg-4 offset-lg-2" ] [
|
||||
textField [ _autofocus; _required ] (nameof model.Tag) "Tag" model.Tag []
|
||||
]
|
||||
div [ _class "col-6 col-lg-4" ] [
|
||||
textField [ _required ] (nameof model.UrlValue) "URL Value" model.UrlValue []
|
||||
]
|
||||
]
|
||||
div [ _class "row mb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
saveButton; raw " "
|
||||
a [ _href (relUrl app "admin/settings/tag-mappings"); _class "btn btn-sm btn-secondary ms-3" ] [
|
||||
raw "Cancel"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Display a list of the web log's current tag mappings
|
||||
let tagMapList (model: TagMap list) app =
|
||||
let tagMapDetail (map: TagMap) =
|
||||
let url = relUrl app $"admin/settings/tag-mapping/{map.Id}"
|
||||
div [ _class "row mwl-table-detail"; _id $"tag_{map.Id}" ] [
|
||||
div [ _class "col no-wrap" ] [
|
||||
txt map.Tag; br []
|
||||
small [] [
|
||||
a [ _href $"{url}/edit"; _hxTarget $"#tag_{map.Id}"
|
||||
_hxSwap $"{HxSwap.InnerHtml} show:#tag_{map.Id}:top" ] [
|
||||
raw "Edit"
|
||||
]; actionSpacer
|
||||
a [ _href url; _hxDelete url; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the mapping for “{map.Tag}”? This action cannot be undone." ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col" ] [ txt map.UrlValue ]
|
||||
]
|
||||
div [ _id "tagList"; _class "container" ] [
|
||||
if List.isEmpty model then
|
||||
div [ _id "tag_new" ] [
|
||||
p [ _class "text-muted text-center fst-italic" ] [ raw "This web log has no tag mappings" ]
|
||||
]
|
||||
else
|
||||
div [ _class "container g-0" ] [
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class "col" ] [ raw "Tag" ]
|
||||
div [ _class "col" ] [ raw "URL Value" ]
|
||||
]
|
||||
]
|
||||
form [ _method "post"; _class "container g-0"; _hxTarget "#tagList"; _hxSwap HxSwap.OuterHtml ] [
|
||||
antiCsrf app
|
||||
div [ _class "row mwl-table-detail"; _id "tag_new" ] []
|
||||
yield! List.map tagMapDetail model
|
||||
]
|
||||
]
|
||||
|> List.singleton
|
||||
|
||||
|
||||
/// The list of uploaded files for a web log
|
||||
let uploadList (model: DisplayUpload seq) app = [
|
||||
let webLogBase = $"upload/{app.WebLog.Slug}/"
|
||||
let relativeBase = relUrl app $"upload/{app.WebLog.Slug}/"
|
||||
let absoluteBase = app.WebLog.AbsoluteUrl(Permalink webLogBase)
|
||||
let uploadDetail (upload: DisplayUpload) =
|
||||
div [ _class "row mwl-table-detail" ] [
|
||||
div [ _class "col-6" ] [
|
||||
let badgeClass = if upload.Source = string Disk then "secondary" else "primary"
|
||||
let pathAndName = $"{upload.Path}{upload.Name}"
|
||||
span [ _class $"badge bg-{badgeClass} text-uppercase float-end mt-1" ] [ raw upload.Source ]
|
||||
raw upload.Name; br []
|
||||
small [] [
|
||||
a [ _href $"{relativeBase}{pathAndName}"; _target "_blank" ] [ raw "View File" ]
|
||||
actionSpacer; span [ _class "text-muted" ] [ raw "Copy " ]
|
||||
a [ _href $"{absoluteBase}{pathAndName}"; _hxNoBoost
|
||||
_onclick $"return Admin.copyText('{absoluteBase}{pathAndName}', this)" ] [
|
||||
raw "Absolute"
|
||||
]
|
||||
span [ _class "text-muted" ] [ raw " | " ]
|
||||
a [ _href $"{relativeBase}{pathAndName}"; _hxNoBoost
|
||||
_onclick $"return Admin.copyText('{relativeBase}{pathAndName}', this)" ] [
|
||||
raw "Relative"
|
||||
]
|
||||
if app.WebLog.ExtraPath <> "" then
|
||||
span [ _class "text-muted" ] [ raw " | " ]
|
||||
a [ _href $"{webLogBase}{pathAndName}"; _hxNoBoost
|
||||
_onclick $"return Admin.copyText('/{webLogBase}{pathAndName}', this)" ] [
|
||||
raw "For Post"
|
||||
]
|
||||
span [ _class "text-muted" ] [ raw " Link" ]
|
||||
if app.IsWebLogAdmin then
|
||||
actionSpacer
|
||||
let deleteUrl =
|
||||
if upload.Source = string "Disk" then $"admin/upload/disk/{pathAndName}"
|
||||
else $"admin/upload/{upload.Id}"
|
||||
|> relUrl app
|
||||
a [ _href deleteUrl; _hxDelete deleteUrl; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete {upload.Name}? This action cannot be undone." ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-3" ] [ raw upload.Path ]
|
||||
div [ _class "col-3" ] [
|
||||
match upload.UpdatedOn with
|
||||
| Some updated -> updated.ToString("yyyy-MM-dd/h:mmtt").ToLowerInvariant()
|
||||
| None -> "--"
|
||||
|> raw
|
||||
]
|
||||
]
|
||||
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
a [ _href (relUrl app "admin/upload/new"); _class "btn btn-primary btn-sm mb-3" ] [ raw "Upload a New File" ]
|
||||
form [ _method "post"; _class "container"; _hxTarget "body" ] [
|
||||
antiCsrf app
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
em [ _class "text-muted" ] [ raw "Uploaded files served from" ]; br []; raw relativeBase
|
||||
]
|
||||
]
|
||||
if Seq.isEmpty model then
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col text-muted fst-italic text-center" ] [
|
||||
br []; raw "This web log has uploaded files"
|
||||
]
|
||||
]
|
||||
else
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class "col-6" ] [ raw "File Name" ]
|
||||
div [ _class "col-3" ] [ raw "Path" ]
|
||||
div [ _class "col-3" ] [ raw "File Date/Time" ]
|
||||
]
|
||||
yield! model |> Seq.map uploadDetail
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Form to upload a new file
|
||||
let uploadNew app = [
|
||||
h2 [ _class "my-3" ] [ raw app.PageTitle ]
|
||||
article [] [
|
||||
form [ _action (relUrl app "admin/upload/save"); _method "post"; _class "container"
|
||||
_enctype "multipart/form-data"; _hxNoBoost ] [
|
||||
antiCsrf app
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-6 pb-3" ] [
|
||||
div [ _class "form-floating" ] [
|
||||
input [ _type "file"; _id "file"; _name "File"; _class "form-control"; _placeholder "File"
|
||||
_required ]
|
||||
label [ _for "file" ] [ raw "File to Upload" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around" ] [
|
||||
div [ _class "text-center" ] [
|
||||
raw "Destination"; br []
|
||||
div [ _class "btn-group"; _roleGroup; _ariaLabel "Upload destination button group" ] [
|
||||
input [ _type "radio"; _name "Destination"; _id "destination_db"; _class "btn-check"
|
||||
_value (string Database); if app.WebLog.Uploads = Database then _checked ]
|
||||
label [ _class "btn btn-outline-primary"; _for "destination_db" ] [ raw (string Database) ]
|
||||
input [ _type "radio"; _name "Destination"; _id "destination_disk"; _class "btn-check"
|
||||
_value (string Disk); if app.WebLog.Uploads= Disk then _checked ]
|
||||
label [ _class "btn btn-outline-secondary"; _for "destination_disk" ] [ raw "Disk" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ raw "Upload File" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
/// Web log settings page
|
||||
let webLogSettings
|
||||
(model: SettingsModel) (themes: Theme list) (pages: Page list) (uploads: UploadDestination list)
|
||||
(rss: EditRssModel) (app: AppViewContext) = [
|
||||
let feedDetail (feed: CustomFeed) =
|
||||
let source =
|
||||
match feed.Source with
|
||||
| Category (CategoryId catId) ->
|
||||
app.Categories
|
||||
|> Array.tryFind (fun cat -> cat.Id = catId)
|
||||
|> Option.map _.Name
|
||||
|> Option.defaultValue "--INVALID; DELETE THIS FEED--"
|
||||
|> sprintf "Category: %s"
|
||||
| Tag tag -> $"Tag: {tag}"
|
||||
div [ _class "row mwl-table-detail" ] [
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
txt source
|
||||
if Option.isSome feed.Podcast then
|
||||
raw " "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ]
|
||||
br []
|
||||
small [] [
|
||||
let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}"
|
||||
a [ _href (relUrl app (string feed.Path)); _target "_blank" ] [ raw "View Feed" ]
|
||||
actionSpacer
|
||||
a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ]; actionSpacer
|
||||
a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger"
|
||||
_hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [
|
||||
raw "Delete"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
small [ _class "d-md-none" ] [ raw "Served at "; txt (string feed.Path) ]
|
||||
span [ _class "d-none d-md-inline" ] [ txt (string feed.Path) ]
|
||||
]
|
||||
]
|
||||
|
||||
h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " Settings" ]
|
||||
article [] [
|
||||
p [ _class "text-muted" ] [
|
||||
raw "Go to: "; a [ _href "#users" ] [ raw "Users" ]; raw " • "
|
||||
a [ _href "#rss-settings" ] [ raw "RSS Settings" ]; raw " • "
|
||||
a [ _href "#tag-mappings" ] [ raw "Tag Mappings" ]; raw " • "
|
||||
a [ _href (relUrl app "admin/settings/redirect-rules") ] [ raw "Redirect Rules" ]
|
||||
]
|
||||
fieldset [ _class "container mb-3" ] [
|
||||
legend [] [ raw "Web Log Settings" ]
|
||||
form [ _action (relUrl app "admin/settings"); _method "post" ] [
|
||||
antiCsrf app
|
||||
div [ _class "container g-0" ] [
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [
|
||||
textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [
|
||||
textField [ _required ] (nameof model.Slug) "Slug" model.Slug [
|
||||
span [ _class "form-text" ] [
|
||||
span [ _class "badge rounded-pill bg-warning text-dark" ] [ raw "WARNING" ]
|
||||
raw " changing this value may break links ("
|
||||
a [ _href "https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
|
||||
_target "_blank" ] [
|
||||
raw "more"
|
||||
]; raw ")"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [
|
||||
textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 col-xl-4 offset-xl-1 pb-3" ] [
|
||||
selectField [ _required ] (nameof model.ThemeId) "Theme" model.ThemeId themes
|
||||
(fun t -> string t.Id) (fun t -> $"{t.Name} (v{t.Version})") []
|
||||
]
|
||||
div [ _class "col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3" ] [
|
||||
selectField [ _required ] (nameof model.DefaultPage) "Default Page" model.DefaultPage pages
|
||||
(fun p -> string p.Id) (_.Title) []
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-xl-2 pb-3" ] [
|
||||
numberField [ _required; _min "0"; _max "50" ] (nameof model.PostsPerPage) "Posts per Page"
|
||||
(string model.PostsPerPage) []
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-md-4 col-xl-3 offset-xl-2 pb-3" ] [
|
||||
textField [ _required ] (nameof model.TimeZone) "Time Zone" model.TimeZone []
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-xl-2" ] [
|
||||
checkboxSwitch [] (nameof model.AutoHtmx) "Auto-Load htmx" model.AutoHtmx []
|
||||
span [ _class "form-text fst-italic" ] [
|
||||
a [ _href "https://htmx.org"; _target "_blank"; _relNoOpener ] [ raw "What is this?" ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-4 col-xl-3 pb-3" ] [
|
||||
selectField [] (nameof model.Uploads) "Default Upload Destination" model.Uploads uploads
|
||||
string string []
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
fieldset [ _id "users"; _class "container mb-3 pb-0" ] [
|
||||
legend [] [ raw "Users" ]
|
||||
span [ _hxGet (relUrl app "admin/settings/users"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] []
|
||||
]
|
||||
fieldset [ _id "rss-settings"; _class "container mb-3 pb-0" ] [
|
||||
legend [] [ raw "RSS Settings" ]
|
||||
form [ _action (relUrl app "admin/settings/rss"); _method "post"; _class "container g-0" ] [
|
||||
antiCsrf app
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col col-xl-8 offset-xl-2" ] [
|
||||
fieldset [ _class "d-flex justify-content-evenly flex-row" ] [
|
||||
legend [] [ raw "Feeds Enabled" ]
|
||||
checkboxSwitch [] (nameof rss.IsFeedEnabled) "All Posts" rss.IsFeedEnabled []
|
||||
checkboxSwitch [] (nameof rss.IsCategoryEnabled) "Posts by Category" rss.IsCategoryEnabled
|
||||
[]
|
||||
checkboxSwitch [] (nameof rss.IsTagEnabled) "Posts by Tag" rss.IsTagEnabled []
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row" ] [
|
||||
div [ _class "col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3" ] [
|
||||
textField [] (nameof rss.FeedName) "Feed File Name" rss.FeedName [
|
||||
span [ _class "form-text" ] [ raw "Default is "; code [] [ raw "feed.xml" ] ]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-sm-6 col-md-4 col-xl-2 pb-3" ] [
|
||||
numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed"
|
||||
(string rss.ItemsInFeed) [
|
||||
span [ _class "form-text" ] [
|
||||
raw "Set to “0” to use “Posts per Page” setting ("
|
||||
raw (string app.WebLog.PostsPerPage); raw ")"
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "col-12 col-md-5 col-xl-4 pb-3" ] [
|
||||
textField [] (nameof rss.Copyright) "Copyright String" rss.Copyright [
|
||||
span [ _class "form-text" ] [
|
||||
raw "Can be a "
|
||||
a [ _href "https://creativecommons.org/share-your-work/"; _target "_blank"
|
||||
_relNoOpener ] [
|
||||
raw "Creative Commons license string"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
div [ _class "row pb-3" ] [
|
||||
div [ _class "col text-center" ] [
|
||||
button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
fieldset [ _class "container mb-3 pb-0" ] [
|
||||
legend [] [ raw "Custom Feeds" ]
|
||||
a [ _class "btn btn-sm btn-secondary"; _href (relUrl app "admin/settings/rss/new/edit") ] [
|
||||
raw "Add a New Custom Feed"
|
||||
]
|
||||
if app.WebLog.Rss.CustomFeeds.Length = 0 then
|
||||
p [ _class "text-muted fst-italic text-center" ] [ raw "No custom feeds defined" ]
|
||||
else
|
||||
form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [
|
||||
antiCsrf app
|
||||
div [ _class "row mwl-table-heading" ] [
|
||||
div [ _class "col-12 col-md-6" ] [
|
||||
span [ _class "d-md-none" ] [ raw "Feed" ]
|
||||
span [ _class "d-none d-md-inline" ] [ raw "Source" ]
|
||||
]
|
||||
div [ _class "col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ]
|
||||
]
|
||||
yield! app.WebLog.Rss.CustomFeeds |> List.map feedDetail
|
||||
]
|
||||
]
|
||||
]
|
||||
fieldset [ _id "tag-mappings"; _class "container mb-3 pb-0" ] [
|
||||
legend [] [ raw "Tag Mappings" ]
|
||||
a [ _href (relUrl app "admin/settings/tag-mapping/new/edit"); _class "btn btn-primary btn-sm mb-3"
|
||||
_hxTarget "#tag_new" ] [
|
||||
raw "Add a New Tag Mapping"
|
||||
]
|
||||
span [ _hxGet (relUrl app "admin/settings/tag-mappings"); _hxTrigger HxTrigger.Load
|
||||
_hxSwap HxSwap.OuterHtml ] []
|
||||
]
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user