Version 2.1 #41
@ -1112,14 +1112,14 @@ type ManageChaptersModel = {
|
|||||||
Title: string
|
Title: string
|
||||||
|
|
||||||
/// The chapters for the post
|
/// The chapters for the post
|
||||||
Chapters: DisplayChapter array
|
Chapters: Chapter list
|
||||||
} with
|
} with
|
||||||
|
|
||||||
/// Create a model from a post and its episode's chapters
|
/// Create a model from a post and its episode's chapters
|
||||||
static member Create (post: Post) =
|
static member Create (post: Post) =
|
||||||
{ Id = string post.Id
|
{ Id = string post.Id
|
||||||
Title = post.Title
|
Title = post.Title
|
||||||
Chapters = post.Episode.Value.Chapters.Value |> List.map DisplayChapter.FromChapter |> Array.ofList }
|
Chapters = post.Episode.Value.Chapters.Value }
|
||||||
|
|
||||||
|
|
||||||
/// View model to manage permalinks
|
/// View model to manage permalinks
|
||||||
|
77
src/MyWebLog/AdminViews/Admin.fs
Normal file
77
src/MyWebLog/AdminViews/Admin.fs
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
module MyWebLog.AdminViews.Admin
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine
|
||||||
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
|
/// 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/category/new/edit"); _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" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
222
src/MyWebLog/AdminViews/Helpers.fs
Normal file
222
src/MyWebLog/AdminViews/Helpers.fs
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
[<AutoOpen>]
|
||||||
|
module MyWebLog.AdminViews.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
|
||||||
|
|
||||||
|
/// The pattern for a long date
|
||||||
|
let longDatePattern =
|
||||||
|
InstantPattern.CreateWithInvariantCulture "MMMM d, yyyy"
|
||||||
|
|
||||||
|
/// Create a long date
|
||||||
|
let longDate =
|
||||||
|
longDatePattern.Format >> txt
|
||||||
|
|
||||||
|
/// The pattern for a short time
|
||||||
|
let shortTimePattern =
|
||||||
|
InstantPattern.CreateWithInvariantCulture "h:mmtt"
|
||||||
|
|
||||||
|
/// Create a short time
|
||||||
|
let shortTime instant =
|
||||||
|
txt (shortTimePattern.Format(instant).ToLower())
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
]
|
185
src/MyWebLog/AdminViews/Post.fs
Normal file
185
src/MyWebLog/AdminViews/Post.fs
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
module MyWebLog.AdminViews.Post
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "text"; _id "start_time"; _name "StartTime"; _class "form-control"; _required
|
||||||
|
_autofocus; _placeholder "Start Time"
|
||||||
|
if model.Index >= 0 then _value model.StartTime ]
|
||||||
|
label [ _for "start_time" ] [ raw "Start Time" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col-6 col-lg-3 mb-3" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "text"; _id "end_time"; _name "EndTime"; _class "form-control"; _value model.EndTime
|
||||||
|
_placeholder "End Time" ]
|
||||||
|
label [ _for "end_time" ] [ raw "End Time" ]
|
||||||
|
span [ _class "form-text" ] [ raw "Optional; ends when next starts" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col-12 col-lg-6 mb-3" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "text"; _id "title"; _name "Title"; _class "form-control"; _value model.Title
|
||||||
|
_placeholder "Title" ]
|
||||||
|
label [ _for "title" ] [ raw "Chapter Title" ]
|
||||||
|
span [ _class "form-text" ] [ raw "Optional" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col-12 col-lg-6 offset-xl-1 mb-3" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "text"; _id "image_url"; _name "ImageUrl"; _class "form-control"
|
||||||
|
_value model.ImageUrl; _placeholder "Image URL" ]
|
||||||
|
label [ _for "image_url" ] [ raw "Image URL" ]
|
||||||
|
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-4 mb-3 align-self-end d-flex flex-column" ] [
|
||||||
|
div [ _class "form-check form-switch mb-3" ] [
|
||||||
|
input [ _type "checkbox"; _id "is_hidden"; _name "IsHidden"; _class "form-check-input"
|
||||||
|
_value "true"
|
||||||
|
if model.IsHidden then _checked ]
|
||||||
|
label [ _for "is_hidden" ] [ raw "Hidden Chapter" ]
|
||||||
|
]
|
||||||
|
span [ _class "form-text" ] [ raw "Not displayed, but may update image and location" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "row" ] [
|
||||||
|
let hasLoc = model.LocationName <> ""
|
||||||
|
div [ _class "col-12 col-md-4 col-lg-3 offset-lg-1 mb-3 align-self-end" ] [
|
||||||
|
div [ _class "form-check form-switch mb-3" ] [
|
||||||
|
input [ _type "checkbox"; _id "has_location"; _class "form-check-input"; _value "true"
|
||||||
|
if hasLoc then _checked
|
||||||
|
_onclick "Admin.checkChapterLocation()" ]
|
||||||
|
label [ _for "has_location" ] [ raw "Associate Location" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col-12 col-md-8 col-lg-6 offset-lg-1 mb-3" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "text"; _id "location_name"; _name "LocationName"; _class "form-control"
|
||||||
|
_value model.LocationName; _placeholder "Location Name"; _required
|
||||||
|
if not hasLoc then _disabled ]
|
||||||
|
label [ _for "location_name" ] [ raw "Name" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col-6 col-lg-4 offset-lg-2 mb-3" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "text"; _id "location_geo"; _name "LocationGeo"; _class "form-control"
|
||||||
|
_value model.LocationGeo; _placeholder "Location Geo URL"
|
||||||
|
if not hasLoc then _disabled ]
|
||||||
|
label [ _for "location_geo" ] [ raw "Geo URL" ]
|
||||||
|
em [ _class "form-text" ] [
|
||||||
|
raw "Optional; "
|
||||||
|
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended"
|
||||||
|
_target "_blank"; _rel "noopener" ] [
|
||||||
|
raw "see spec"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col-6 col-lg-4 mb-3" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "text"; _id "location_osm"; _name "LocationOsm"; _class "form-control"
|
||||||
|
_value model.LocationOsm; _placeholder "Location OSM Query"
|
||||||
|
if not hasLoc then _disabled ]
|
||||||
|
label [ _for "location_osm" ] [ raw "OpenStreetMap ID" ]
|
||||||
|
em [ _class "form-text" ] [
|
||||||
|
raw "Optional; "
|
||||||
|
a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _rel "noopener" ] [ raw "get ID" ]
|
||||||
|
raw ", "
|
||||||
|
a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#osm-recommended"
|
||||||
|
_target "_blank"; _rel "noopener" ] [
|
||||||
|
raw "see spec"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "row" ] [
|
||||||
|
div [ _class "col" ] [
|
||||||
|
let cancelLink = relUrl app $"admin/post/{model.PostId}/chapters"
|
||||||
|
if model.Index < 0 then
|
||||||
|
div [ _class "form-check form-switch mb-3" ] [
|
||||||
|
input [ _type "checkbox"; _id "add_another"; _name "AddAnother"; _class "form-check-input"
|
||||||
|
_value "true"; _checked ]
|
||||||
|
label [ _for "add_another" ] [ raw "Add Another New Chapter" ]
|
||||||
|
]
|
||||||
|
else
|
||||||
|
input [ _type "hidden"; _name "AddAnother"; _value "false" ]
|
||||||
|
button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save" ]
|
||||||
|
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 "outerHTML" ] [
|
||||||
|
antiCsrf app
|
||||||
|
input [ _type "hidden"; _name "Id"; _value model.Id ]
|
||||||
|
div [ _class "row mwl-table-heading" ] [
|
||||||
|
div [ _class "col" ] [ raw "Start" ]
|
||||||
|
div [ _class "col" ] [ raw "Title" ]
|
||||||
|
div [ _class "col" ] [ raw "Image?" ]
|
||||||
|
div [ _class "col" ] [ raw "Location?" ]
|
||||||
|
]
|
||||||
|
yield! model.Chapters |> List.mapi (fun idx chapter ->
|
||||||
|
div [ _class "row pb-3 mwl-table-detail"; _id $"chapter{idx}" ] [
|
||||||
|
div [ _class "col" ] [ txt (startTimePattern.Format chapter.StartTime) ]
|
||||||
|
div [ _class "col" ] [ txt (defaultArg chapter.Title "") ]
|
||||||
|
div [ _class "col" ] [ raw (if Option.isSome chapter.ImageUrl then "Y" else "N") ]
|
||||||
|
div [ _class "col" ] [ raw (if Option.isSome chapter.Location then "Y" else "N") ]
|
||||||
|
])
|
||||||
|
div [ _class "row pb-3"; _id "chapter-1" ] [
|
||||||
|
if withNew then
|
||||||
|
yield! chapterEdit (EditChapterModel.FromChapter (PostId model.Id) -1 Chapter.Empty) app
|
||||||
|
else
|
||||||
|
let newLink = relUrl app $"admin/post/{model.Id}/chapter/-1"
|
||||||
|
div [ _class "row pb-3 mwl-table-detail" ] [
|
||||||
|
div [ _class "col-12" ] [
|
||||||
|
a [ _class "btn btn-primary"; _href newLink; _hxGet newLink; _hxTarget "#chapter-1" ] [
|
||||||
|
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
|
||||||
|
]
|
||||||
|
]
|
91
src/MyWebLog/AdminViews/User.fs
Normal file
91
src/MyWebLog/AdminViews/User.fs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
module MyWebLog.AdminViews.User
|
||||||
|
|
||||||
|
open Giraffe.ViewEngine
|
||||||
|
open Giraffe.ViewEngine.Htmx
|
||||||
|
open MyWebLog
|
||||||
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
|
/// Page to display the log on page
|
||||||
|
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" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "email"; _id "email"; _name "EmailAddress"; _class "form-control"; _autofocus
|
||||||
|
_required ]
|
||||||
|
label [ _for "email" ] [ rawText "E-mail Address" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [
|
||||||
|
div [ _class "form-floating" ] [
|
||||||
|
input [ _type "password"; _id "password"; _name "Password"; _class "form-control"; _required ]
|
||||||
|
label [ _for "password" ] [ rawText "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 badge = "ms-2 badge bg"
|
||||||
|
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 "this"; _hxSwap "outerHTML show:window:top" ] [
|
||||||
|
antiCsrf app
|
||||||
|
for user in model do
|
||||||
|
div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [
|
||||||
|
div [ _class $"col-12 col-md-4 col-xl-3 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 urlBase = $"admin/settings/user/{user.Id}"
|
||||||
|
small [] [
|
||||||
|
a [ _href (relUrl app $"{urlBase}/edit"); _hxTarget $"#user_{user.Id}"
|
||||||
|
_hxSwap $"innerHTML show:#user_{user.Id}:top" ] [
|
||||||
|
raw "Edit"
|
||||||
|
]
|
||||||
|
if app.UserId.Value <> user.Id then
|
||||||
|
let delLink = relUrl app $"{urlBase}/delete"
|
||||||
|
span [ _class "text-muted" ] [ raw " • " ]
|
||||||
|
a [ _href delLink; _hxPost delLink; _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 "col-12 col-md-4 col-xl-4" ] [
|
||||||
|
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" ] [ longDate user.CreatedOn ]
|
||||||
|
div [ _class "col-12 col-md-4 col-xl-3" ] [
|
||||||
|
match user.LastSeenOn with
|
||||||
|
| Some it -> longDate it; raw " at "; shortTime it
|
||||||
|
| None -> raw "--"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|> List.singleton
|
@ -6,6 +6,7 @@ open System.IO
|
|||||||
open System.Web
|
open System.Web
|
||||||
open DotLiquid
|
open DotLiquid
|
||||||
open Giraffe.ViewEngine
|
open Giraffe.ViewEngine
|
||||||
|
open MyWebLog.AdminViews.Helpers
|
||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
|
||||||
/// Extensions on the DotLiquid Context object
|
/// Extensions on the DotLiquid Context object
|
||||||
@ -227,15 +228,16 @@ let register () =
|
|||||||
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
|
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
|
||||||
typeof<RedirectRule>; typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
|
typeof<RedirectRule>; typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
|
||||||
// View models
|
// View models
|
||||||
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayChapter>
|
typeof<AppViewContext>; typeof<DashboardModel>; typeof<DisplayCategory>
|
||||||
typeof<DisplayCustomFeed>; typeof<DisplayPage>; typeof<DisplayRevision>
|
typeof<DisplayChapter>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
|
||||||
typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>
|
typeof<DisplayRevision>; typeof<DisplayTheme>; typeof<DisplayUpload>
|
||||||
typeof<EditCategoryModel>; typeof<EditChapterModel>; typeof<EditCustomFeedModel>
|
typeof<DisplayUser>; typeof<EditCategoryModel>; typeof<EditChapterModel>
|
||||||
typeof<EditMyInfoModel>; typeof<EditPageModel>; typeof<EditPostModel>
|
typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>
|
||||||
typeof<EditRedirectRuleModel>; typeof<EditRssModel>; typeof<EditTagMapModel>
|
typeof<EditPostModel>; typeof<EditRedirectRuleModel>; typeof<EditRssModel>
|
||||||
typeof<EditUserModel>; typeof<LogOnModel>; typeof<ManageChaptersModel>
|
typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
|
||||||
typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>
|
typeof<ManageChaptersModel>; typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>
|
||||||
typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
|
typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>
|
||||||
|
typeof<UserMessage>
|
||||||
// Framework types
|
// Framework types
|
||||||
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
|
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
|
||||||
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>
|
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>
|
||||||
|
@ -20,17 +20,14 @@ module Dashboard =
|
|||||||
let! listed = getCount data.Page.CountListed
|
let! listed = getCount data.Page.CountListed
|
||||||
let! cats = getCount data.Category.CountAll
|
let! cats = getCount data.Category.CountAll
|
||||||
let! topCats = getCount data.Category.CountTopLevel
|
let! topCats = getCount data.Category.CountTopLevel
|
||||||
return!
|
let model =
|
||||||
hashForPage "Dashboard"
|
{ Posts = posts
|
||||||
|> addToHash ViewContext.Model {
|
|
||||||
Posts = posts
|
|
||||||
Drafts = drafts
|
Drafts = drafts
|
||||||
Pages = pages
|
Pages = pages
|
||||||
ListedPages = listed
|
ListedPages = listed
|
||||||
Categories = cats
|
Categories = cats
|
||||||
TopLevelCategories = topCats
|
TopLevelCategories = topCats }
|
||||||
}
|
return! adminPage "Dashboard" false (AdminViews.Admin.dashboard model) next ctx
|
||||||
|> adminView "dashboard" next ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /admin/administration
|
// GET /admin/administration
|
||||||
|
@ -3,6 +3,8 @@ module private MyWebLog.Handlers.Helpers
|
|||||||
|
|
||||||
open System.Text.Json
|
open System.Text.Json
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
|
open MyWebLog.AdminViews
|
||||||
|
open MyWebLog.AdminViews.Helpers
|
||||||
|
|
||||||
/// Session extensions to get and set objects
|
/// Session extensions to get and set objects
|
||||||
type ISession with
|
type ISession with
|
||||||
@ -25,6 +27,10 @@ module ViewContext =
|
|||||||
[<Literal>]
|
[<Literal>]
|
||||||
let AntiCsrfTokens = "csrf"
|
let AntiCsrfTokens = "csrf"
|
||||||
|
|
||||||
|
/// The unified application view context
|
||||||
|
[<Literal>]
|
||||||
|
let AppViewContext = "app"
|
||||||
|
|
||||||
/// The categories for this web log
|
/// The categories for this web log
|
||||||
[<Literal>]
|
[<Literal>]
|
||||||
let Categories = "categories"
|
let Categories = "categories"
|
||||||
@ -185,32 +191,62 @@ open Giraffe.ViewEngine
|
|||||||
/// htmx script tag
|
/// htmx script tag
|
||||||
let private htmxScript = RenderView.AsString.htmlNode Htmx.Script.minified
|
let private htmxScript = RenderView.AsString.htmlNode Htmx.Script.minified
|
||||||
|
|
||||||
/// Populate the DotLiquid hash with standard information
|
/// Get the current user messages, and commit the session so that they are preserved
|
||||||
let addViewContext ctx (hash: Hash) = task {
|
let private getCurrentMessages ctx = task {
|
||||||
let! messages = messages ctx
|
let! messages = messages ctx
|
||||||
do! commitSession ctx
|
do! commitSession ctx
|
||||||
return
|
return messages
|
||||||
if hash.ContainsKey ViewContext.HtmxScript && hash.ContainsKey ViewContext.Messages then
|
}
|
||||||
// We have already populated everything; just update messages
|
|
||||||
hash[ViewContext.Messages] <- Array.concat [ hash[ViewContext.Messages] :?> UserMessage array; messages ]
|
/// Generate the view context for a response
|
||||||
hash
|
let private generateViewContext pageTitle messages includeCsrf (ctx: HttpContext) =
|
||||||
else
|
{ WebLog = ctx.WebLog
|
||||||
ctx.User.Claims
|
UserId = ctx.User.Claims
|
||||||
|> Seq.tryFind (fun claim -> claim.Type = ClaimTypes.NameIdentifier)
|
|> Seq.tryFind (fun claim -> claim.Type = ClaimTypes.NameIdentifier)
|
||||||
|> Option.map (fun claim -> addToHash ViewContext.UserId claim.Value hash)
|
|> Option.map (fun claim -> WebLogUserId claim.Value)
|
||||||
|> Option.defaultValue hash
|
PageTitle = pageTitle
|
||||||
|> addToHash ViewContext.WebLog ctx.WebLog
|
Csrf = if includeCsrf then Some ctx.CsrfTokenSet else None
|
||||||
|> addToHash ViewContext.PageList (PageListCache.get ctx)
|
PageList = PageListCache.get ctx
|
||||||
|> addToHash ViewContext.Categories (CategoryCache.get ctx)
|
Categories = CategoryCache.get ctx
|
||||||
|> addToHash ViewContext.CurrentPage ctx.Request.Path.Value[1..]
|
CurrentPage = ctx.Request.Path.Value[1..]
|
||||||
|> addToHash ViewContext.Messages messages
|
Messages = messages
|
||||||
|> addToHash ViewContext.Generator ctx.Generator
|
Generator = ctx.Generator
|
||||||
|> addToHash ViewContext.HtmxScript htmxScript
|
HtmxScript = htmxScript
|
||||||
|> addToHash ViewContext.IsLoggedOn ctx.User.Identity.IsAuthenticated
|
IsAuthor = ctx.HasAccessLevel Author
|
||||||
|> addToHash ViewContext.IsAuthor (ctx.HasAccessLevel Author)
|
IsEditor = ctx.HasAccessLevel Editor
|
||||||
|> addToHash ViewContext.IsEditor (ctx.HasAccessLevel Editor)
|
IsWebLogAdmin = ctx.HasAccessLevel WebLogAdmin
|
||||||
|> addToHash ViewContext.IsWebLogAdmin (ctx.HasAccessLevel WebLogAdmin)
|
IsAdministrator = ctx.HasAccessLevel Administrator }
|
||||||
|> addToHash ViewContext.IsAdministrator (ctx.HasAccessLevel Administrator)
|
|
||||||
|
|
||||||
|
/// Populate the DotLiquid hash with standard information
|
||||||
|
let addViewContext ctx (hash: Hash) = task {
|
||||||
|
let! messages = getCurrentMessages ctx
|
||||||
|
if hash.ContainsKey ViewContext.AppViewContext then
|
||||||
|
let oldApp = hash[ViewContext.AppViewContext] :?> AppViewContext
|
||||||
|
let newApp = { oldApp with Messages = Array.concat [ oldApp.Messages; messages ] }
|
||||||
|
return
|
||||||
|
hash
|
||||||
|
|> addToHash ViewContext.AppViewContext newApp
|
||||||
|
|> addToHash ViewContext.Messages newApp.Messages
|
||||||
|
else
|
||||||
|
let app =
|
||||||
|
generateViewContext (string hash[ViewContext.PageTitle]) messages
|
||||||
|
(hash.ContainsKey ViewContext.AntiCsrfTokens) ctx
|
||||||
|
return
|
||||||
|
hash
|
||||||
|
|> addToHash ViewContext.UserId (app.UserId |> Option.map string |> Option.defaultValue "")
|
||||||
|
|> addToHash ViewContext.WebLog app.WebLog
|
||||||
|
|> addToHash ViewContext.PageList app.PageList
|
||||||
|
|> addToHash ViewContext.Categories app.Categories
|
||||||
|
|> addToHash ViewContext.CurrentPage app.CurrentPage
|
||||||
|
|> addToHash ViewContext.Messages app.Messages
|
||||||
|
|> addToHash ViewContext.Generator app.Generator
|
||||||
|
|> addToHash ViewContext.HtmxScript app.HtmxScript
|
||||||
|
|> addToHash ViewContext.IsLoggedOn app.IsLoggedOn
|
||||||
|
|> addToHash ViewContext.IsAuthor app.IsAuthor
|
||||||
|
|> addToHash ViewContext.IsEditor app.IsEditor
|
||||||
|
|> addToHash ViewContext.IsWebLogAdmin app.IsWebLogAdmin
|
||||||
|
|> addToHash ViewContext.IsAdministrator app.IsAdministrator
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is the request from htmx?
|
/// Is the request from htmx?
|
||||||
@ -258,7 +294,7 @@ module Error =
|
|||||||
(messagesToHeaders messages >=> setStatusCode 401) earlyReturn ctx
|
(messagesToHeaders messages >=> setStatusCode 401) earlyReturn ctx
|
||||||
else setStatusCode 401 earlyReturn ctx
|
else setStatusCode 401 earlyReturn ctx
|
||||||
|
|
||||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
/// Handle 404s
|
||||||
let notFound : HttpHandler =
|
let notFound : HttpHandler =
|
||||||
handleContext (fun ctx ->
|
handleContext (fun ctx ->
|
||||||
if isHtmx ctx then
|
if isHtmx ctx then
|
||||||
@ -334,6 +370,21 @@ let adminView template =
|
|||||||
let adminBareView template =
|
let adminBareView template =
|
||||||
bareForTheme adminTheme template
|
bareForTheme adminTheme template
|
||||||
|
|
||||||
|
/// Display a page for an admin endpoint
|
||||||
|
let adminPage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) : HttpHandler = fun next ctx -> task {
|
||||||
|
let! messages = getCurrentMessages ctx
|
||||||
|
let appCtx = generateViewContext pageTitle messages includeCsrf ctx
|
||||||
|
let layout = if isHtmx ctx then Layout.partial else Layout.full
|
||||||
|
return! htmlString (layout content appCtx |> RenderView.AsString.htmlDocument) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display a bare page for an admin endpoint
|
||||||
|
let adminBarePage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) : HttpHandler = fun next ctx -> task {
|
||||||
|
let! messages = getCurrentMessages ctx
|
||||||
|
let appCtx = generateViewContext pageTitle messages includeCsrf ctx
|
||||||
|
return! htmlString (Layout.bare content appCtx |> RenderView.AsString.htmlDocument) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate the anti cross-site request forgery token in the current request
|
/// Validate the anti cross-site request forgery token in the current request
|
||||||
let validateCsrf : HttpHandler = fun next ctx -> task {
|
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||||
match! ctx.AntiForgery.IsRequestValidAsync ctx with
|
match! ctx.AntiForgery.IsRequestValidAsync ctx with
|
||||||
|
@ -379,10 +379,7 @@ let chapters postId : HttpHandler = requireAccess Author >=> fun next ctx -> tas
|
|||||||
&& Option.isSome post.Episode.Value.Chapters
|
&& Option.isSome post.Episode.Value.Chapters
|
||||||
&& canEdit post.AuthorId ctx ->
|
&& canEdit post.AuthorId ctx ->
|
||||||
return!
|
return!
|
||||||
hashForPage "Manage Chapters"
|
adminPage "Manage Chapters" true (AdminViews.Post.chapters false (ManageChaptersModel.Create post)) next ctx
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash ViewContext.Model (ManageChaptersModel.Create post)
|
|
||||||
|> adminView "chapters" next ctx
|
|
||||||
| Some _ | None -> return! Error.notFound next ctx
|
| Some _ | None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -401,10 +398,9 @@ let editChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex
|
|||||||
match chapter with
|
match chapter with
|
||||||
| Some chap ->
|
| Some chap ->
|
||||||
return!
|
return!
|
||||||
hashForPage (if index = -1 then "Add a Chapter" else "Edit Chapter")
|
adminPage
|
||||||
|> withAntiCsrf ctx
|
(if index = -1 then "Add a Chapter" else "Edit Chapter") true
|
||||||
|> addToHash ViewContext.Model (EditChapterModel.FromChapter post.Id index chap)
|
(AdminViews.Post.chapterEdit (EditChapterModel.FromChapter post.Id index chap)) next ctx
|
||||||
|> adminBareView "chapter-edit" next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
| Some _ | None -> return! Error.notFound next ctx
|
| Some _ | None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
@ -419,23 +415,23 @@ let saveChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex
|
|||||||
&& canEdit post.AuthorId ctx ->
|
&& canEdit post.AuthorId ctx ->
|
||||||
let! form = ctx.BindFormAsync<EditChapterModel>()
|
let! form = ctx.BindFormAsync<EditChapterModel>()
|
||||||
let chapters = post.Episode.Value.Chapters.Value
|
let chapters = post.Episode.Value.Chapters.Value
|
||||||
if index = -1 || (index >= 0 && index < List.length chapters) then
|
if index >= -1 && index < List.length chapters then
|
||||||
|
try
|
||||||
|
let chapter = form.ToChapter()
|
||||||
|
let existing = if index = -1 then chapters else chapters |> List.removeAt index
|
||||||
let updatedPost =
|
let updatedPost =
|
||||||
{ post with
|
{ post with
|
||||||
Episode = Some {
|
Episode = Some
|
||||||
post.Episode.Value with
|
{ post.Episode.Value with
|
||||||
Chapters =
|
Chapters = Some (chapter :: existing |> List.sortBy _.StartTime) } }
|
||||||
form.ToChapter() :: (if index = -1 then chapters else chapters |> List.removeAt index)
|
|
||||||
|> List.sortBy _.StartTime
|
|
||||||
|> Some } }
|
|
||||||
do! data.Post.Update updatedPost
|
do! data.Post.Update updatedPost
|
||||||
do! addMessage ctx { UserMessage.Success with Message = "Chapter saved successfully" }
|
do! addMessage ctx { UserMessage.Success with Message = "Chapter saved successfully" }
|
||||||
// TODO: handle "add another", only return chapter list vs. entire page with title
|
|
||||||
return!
|
return!
|
||||||
hashForPage "Manage Chapters"
|
adminPage
|
||||||
|> withAntiCsrf ctx
|
"Manage Chapters" true
|
||||||
|> addToHash ViewContext.Model (ManageChaptersModel.Create updatedPost)
|
(AdminViews.Post.chapterList form.AddAnother (ManageChaptersModel.Create updatedPost)) next ctx
|
||||||
|> adminView "chapters" next ctx
|
with
|
||||||
|
| ex -> return! Error.notFound next ctx // TODO: return error
|
||||||
else return! Error.notFound next ctx
|
else return! Error.notFound next ctx
|
||||||
| Some _ | None -> return! Error.notFound next ctx
|
| Some _ | None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
@ -36,10 +36,7 @@ let logOn returnUrl : HttpHandler = fun next ctx ->
|
|||||||
match returnUrl with
|
match returnUrl with
|
||||||
| Some _ -> returnUrl
|
| Some _ -> returnUrl
|
||||||
| None -> if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None
|
| None -> if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None
|
||||||
hashForPage "Log On"
|
adminPage "Log On" true (AdminViews.User.logOn { LogOnModel.Empty with ReturnTo = returnTo }) next ctx
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash ViewContext.Model { LogOnModel.Empty with ReturnTo = returnTo }
|
|
||||||
|> adminView "log-on" next ctx
|
|
||||||
|
|
||||||
|
|
||||||
open System.Security.Claims
|
open System.Security.Claims
|
||||||
@ -96,11 +93,7 @@ let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?"
|
|||||||
// GET /admin/settings/users
|
// GET /admin/settings/users
|
||||||
let all : HttpHandler = fun next ctx -> task {
|
let all : HttpHandler = fun next ctx -> task {
|
||||||
let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id
|
let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id
|
||||||
return!
|
return! adminBarePage "User Administration" true (AdminViews.User.userList users) next ctx
|
||||||
hashForPage "User Administration"
|
|
||||||
|> withAntiCsrf ctx
|
|
||||||
|> addToHash "users" (users |> List.map (DisplayUser.FromUser ctx.WebLog) |> Array.ofList)
|
|
||||||
|> adminBareView "user-list-body" next ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show the edit user page
|
/// Show the edit user page
|
||||||
|
@ -9,6 +9,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="appsettings*.json" CopyToOutputDirectory="Always" />
|
<Content Include="appsettings*.json" CopyToOutputDirectory="Always" />
|
||||||
<Compile Include="Caches.fs" />
|
<Compile Include="Caches.fs" />
|
||||||
|
<Compile Include="AdminViews\Helpers.fs" />
|
||||||
|
<Compile Include="AdminViews\Admin.fs" />
|
||||||
|
<Compile Include="AdminViews\Post.fs" />
|
||||||
|
<Compile Include="AdminViews\User.fs" />
|
||||||
<Compile Include="Handlers\Helpers.fs" />
|
<Compile Include="Handlers\Helpers.fs" />
|
||||||
<Compile Include="Handlers\Admin.fs" />
|
<Compile Include="Handlers\Admin.fs" />
|
||||||
<Compile Include="Handlers\Feed.fs" />
|
<Compile Include="Handlers\Feed.fs" />
|
||||||
|
@ -1,105 +0,0 @@
|
|||||||
<h3 class=my-3>{% if model.index < 0 %}Add{% else %}Edit{% endif %} Chapter</h3>
|
|
||||||
<p class=form-text>Times may be entered as seconds; minutes and seconds; or hours, minutes and seconds. Fractional
|
|
||||||
seconds are supported to two decimal places.
|
|
||||||
{% assign post_url = "admin/post/" | append: model.post_id | append: "/chapter/" | append: model.index | relative_link %}
|
|
||||||
<form method=post action="{{ post_url }}" hx-post="{{ post_url }}" hx-target=#chapter_list class=container>
|
|
||||||
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
|
||||||
<input type=hidden name=PostId value="{{ model.post_id }}">
|
|
||||||
<input type=hidden name=Index value={{ model.index }}>
|
|
||||||
<div class=row>
|
|
||||||
<div class="col-6 col-lg-3 mb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=text id=start_time name=StartTime class=form-control required autofocus placeholder="Start Time"
|
|
||||||
{%- unless model.index < 0 %} value="{{ start_time }}"{% endunless %}>
|
|
||||||
<label for=start_time>Start Time</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-lg-3 mb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=text id=end_time name=EndTime class=form-control value="{{ model.end_time }}"
|
|
||||||
placeholder="End Time">
|
|
||||||
<label for=end_time>End Time</label>
|
|
||||||
<span class=form-text>Optional; ends when next starts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-lg-6 mb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=text id=title name=Title class=form-control value="{{ model.title }}" placeholder=Title>
|
|
||||||
<label for=title>Chapter Title</label>
|
|
||||||
<span class=form-text>Optional</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-lg-6 offset-xl-1 mb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=text id=image_url name=ImageUrl class=form-control value="{{ model.image_url }}"
|
|
||||||
placeholder="Image URL">
|
|
||||||
<label for=image_url>Image URL</label>
|
|
||||||
<span class=form-text>Optional; a separate image to display while this chapter is playing</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-lg-6 col-xl-4 mb-3 align-self-end d-flex flex-column">
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input type=checkbox id=is_hidden name=IsHidden class=form-check-input value=true
|
|
||||||
{%- if model.is_hidden %} checked{% endif %}>
|
|
||||||
<label for=is_hidden>Hidden Chapter</label>
|
|
||||||
</div>
|
|
||||||
<span class="form-text">Not displayed, but may update image and location</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=row>
|
|
||||||
{%- if model.location_name != "" -%}{% assign has_loc = true %}{% else %}{% assign has_loc = false %}{% endif -%}
|
|
||||||
<div class="col-12 col-md-4 col-lg-3 offset-lg-1 mb-3 align-self-end">
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input type=checkbox id=has_location class=form-check-input value=true{% if has_loc %} checked{% endif %}
|
|
||||||
onclick="Admin.checkChapterLocation()">
|
|
||||||
<label for=has_location>Associate Location</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-8 col-lg-6 offset-lg-1 mb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=text id=location_name name=LocationName class=form-control value="{{ model.location_name }}"
|
|
||||||
placeholder="Location Name" required{% unless has_loc %} disabled{% endunless %}>
|
|
||||||
<label for=location_name>Name</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-lg-4 offset-lg-2 mb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=text id=location_geo name=LocationGeo class=form-control value="{{ model.location_geo }}"
|
|
||||||
placeholder="Location Geo URL"{% unless has_loc %} disabled{% endunless %}>
|
|
||||||
<label for=location_geo>Geo URL</label>
|
|
||||||
<em class=form-text>
|
|
||||||
Optional;
|
|
||||||
<a href="https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended"
|
|
||||||
target="_blank" rel="noopener">see spec</a>
|
|
||||||
</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-lg-4 mb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=text id=location_osm name=LocationOsm class=form-control value="{{ model.location_osm }}"
|
|
||||||
placeholder="Location OSM Query"{% unless has_loc %} disabled{% endunless %}>
|
|
||||||
<label for=location_osm>OpenStreetMap ID</label>
|
|
||||||
<em class=form-text>
|
|
||||||
Optional; <a href="https://www.openstreetmap.org/" target="_blank" rel="noopener">get ID</a>,
|
|
||||||
<a href="https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#osm-recommended"
|
|
||||||
target="_blank" rel="noopener">see spec</a>
|
|
||||||
</em>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class=row>
|
|
||||||
<div class=col>
|
|
||||||
{% if model.index < 0 -%}
|
|
||||||
<div class="form-check form-switch mb-3">
|
|
||||||
<input type=checkbox id=add_another name=AddAnother class=form-check-input value=true checked>
|
|
||||||
<label for=add_another>Add Another New Chapter</label>
|
|
||||||
</div>
|
|
||||||
{% else -%}
|
|
||||||
<input type=hidden name=AddAnother value=false>
|
|
||||||
{% endif %}
|
|
||||||
<button type=submit class="btn btn-primary">Save</button>
|
|
||||||
{% assign cancel_link = "admin/post/" | append: model.post_id | append: "/chapters" | relative_link %}
|
|
||||||
<a href="{{ cancel_link }}" hx-get="{{ cancel_link }}" class="btn btn-secondary" hx-target=body>Cancel</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
@ -1,38 +0,0 @@
|
|||||||
<h2 class=my-3>{{ page_title }}</h2>
|
|
||||||
<article>
|
|
||||||
<form method=post id=chapter_list hx-target=body>
|
|
||||||
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
|
||||||
<input type=hidden name=Id value="{{ model.id }}">
|
|
||||||
<div class="container mb-3">
|
|
||||||
<div class=row>
|
|
||||||
<div class=col>
|
|
||||||
<p style="line-height:1.2rem;">
|
|
||||||
<strong>{{ model.title }}</strong><br>
|
|
||||||
<small class=text-muted>
|
|
||||||
<a href="{{ "admin/post/" | append: model.id | append: "/edit" | relative_link }}">
|
|
||||||
« Back to Edit Post
|
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mwl-table-heading">
|
|
||||||
<div class=col>Start</div>
|
|
||||||
<div class=col>Title</div>
|
|
||||||
<div class=col>Image?</div>
|
|
||||||
<div class=col>Location?</div>
|
|
||||||
</div>
|
|
||||||
{% for chapter in model.chapters %}
|
|
||||||
<div class="row pb-3 mwl-table-detail">
|
|
||||||
<div class=col>{{ chapter.start_time }}</div>
|
|
||||||
<div class=col>{{ chapter.title }}</div>
|
|
||||||
<div class=col>{% if chapter.image_url == "" %}N{% else %}Y{% endif %}</div>
|
|
||||||
<div class=col>{% if chapter.location %}Y{% else %}N{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
<div class="row pb-3" id=chapter_new>
|
|
||||||
{% assign new_link = "admin/post/" | append: model.id | append: "/chapter/-1" | relative_link %}
|
|
||||||
<a class="btn btn-primary" href="{{ new_link }}" hx-get="{{ new_link }}" hx-target=#chapter_new>Add a New Chapter</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
@ -1,59 +0,0 @@
|
|||||||
<h2 class=my-3>{{ web_log.name }} • Dashboard</h2>
|
|
||||||
<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">Posts</header>
|
|
||||||
<div class=card-body>
|
|
||||||
<h6 class="card-subtitle text-muted pb-3">
|
|
||||||
Published <span class="badge rounded-pill bg-secondary">{{ model.posts }}</span>
|
|
||||||
Drafts <span class="badge rounded-pill bg-secondary">{{ model.drafts }}</span>
|
|
||||||
</h6>
|
|
||||||
{% if is_author %}
|
|
||||||
<a href="{{ "admin/posts" | relative_link }}" class="btn btn-secondary me-2">View All</a>
|
|
||||||
<a href="{{ "admin/post/new/edit" | relative_link }}" class="btn btn-primary">Write a New Post</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="col-lg-5 col-xl-4 pb-3">
|
|
||||||
<div class=card>
|
|
||||||
<header class="card-header text-white bg-primary">Pages</header>
|
|
||||||
<div class=card-body>
|
|
||||||
<h6 class="card-subtitle text-muted pb-3">
|
|
||||||
All <span class="badge rounded-pill bg-secondary">{{ model.pages }}</span>
|
|
||||||
Shown in Page List <span class="badge rounded-pill bg-secondary">{{ model.listed_pages }}</span>
|
|
||||||
</h6>
|
|
||||||
{% if is_author %}
|
|
||||||
<a href="{{ "admin/pages" | relative_link }}" class="btn btn-secondary me-2">View All</a>
|
|
||||||
<a href="{{ "admin/page/new/edit" | relative_link }}" class="btn btn-primary">Create a New Page</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<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">Categories</header>
|
|
||||||
<div class=card-body>
|
|
||||||
<h6 class="card-subtitle text-muted pb-3">
|
|
||||||
All <span class="badge rounded-pill bg-secondary">{{ model.categories }}</span>
|
|
||||||
Top Level <span class="badge rounded-pill bg-secondary">{{ model.top_level_categories }}</span>
|
|
||||||
</h6>
|
|
||||||
{% if is_web_log_admin %}
|
|
||||||
<a href="{{ "admin/categories" | relative_link }}" class="btn btn-secondary me-2">View All</a>
|
|
||||||
<a href="{{ "admin/category/new/edit" | relative_link }}" class="btn btn-secondary">Add a New Category</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{% if is_web_log_admin %}
|
|
||||||
<div class="row pb-3">
|
|
||||||
<div class="col text-end">
|
|
||||||
<a href="{{ "admin/settings" | relative_link }}" class="btn btn-secondary">Modify Settings</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
@ -1,30 +0,0 @@
|
|||||||
<h2 class=my-3>Log On to {{ web_log.name }}</h2>
|
|
||||||
<article class=py-3>
|
|
||||||
<form action="{{ "user/log-on" | relative_link }}" method=post hx-push-url=true>
|
|
||||||
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
|
||||||
{% if model.return_to %}
|
|
||||||
<input type=hidden name=ReturnTo value="{{ model.return_to.value }}">
|
|
||||||
{% endif %}
|
|
||||||
<div class=container>
|
|
||||||
<div class=row>
|
|
||||||
<div class="col-12 col-md-6 col-lg-4 offset-lg-2 pb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=email id=email name=EmailAddress class=form-control autofocus required>
|
|
||||||
<label for=email>E-mail Address</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6 col-lg-4 pb-3">
|
|
||||||
<div class=form-floating>
|
|
||||||
<input type=password id=password name=Password class=form-control required>
|
|
||||||
<label for=password>Password</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row pb-3">
|
|
||||||
<div class="col text-center">
|
|
||||||
<button type=submit class="btn btn-primary">Log On</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
Loading…
Reference in New Issue
Block a user