Complete chapter edit page (#6)

first cut, anyway...
This commit is contained in:
2024-03-10 17:13:15 -04:00
parent 43a700eead
commit 641a7499cc
12 changed files with 72 additions and 26 deletions

View File

@@ -0,0 +1,77 @@
module MyWebLog.Views.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" ]
]
]
]
]

View File

@@ -0,0 +1,222 @@
[<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
/// 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 " &laquo; Admin &laquo; "; 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&hellip;" ] ]
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
]

204
src/MyWebLog/Views/Post.fs Normal file
View File

@@ -0,0 +1,204 @@
module MyWebLog.Views.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"
/// 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" ] [
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" ]
raw " &nbsp; "
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 mwl-table-detail"; _id $"chapter{idx}" ] [
div [ _class "col" ] [ txt (startTimePattern.Format chapter.StartTime) ]
div [ _class "col" ] [
txt (defaultArg chapter.Title ""); br []
small [] [
if withNew then
raw "&nbsp;"
else
let chapterUrl = relUrl app $"admin/post/{model.Id}/chapter/{idx}"
a [ _href chapterUrl; _hxGet chapterUrl; _hxTarget $"#chapter{idx}"
_hxSwap $"innerHTML show:#chapter{idx}:top" ] [
raw "Edit"
]
span [ _class "text-muted" ] [ raw " &bull; " ]
a [ _href chapterUrl; _hxDelete chapterUrl; _class "text-danger" ] [
raw "Delete"
]
]
]
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" ] [
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 "&laquo; Back to Edit Post"
]
]
]
yield! chapterList withNew model app
]
]

View File

@@ -0,0 +1,91 @@
module MyWebLog.Views.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 " &bull; " ]
a [ _href delLink; _hxPost delLink; _class "text-danger"
_hxConfirm $"Are you sure you want to delete the user &ldquo;{user.PreferredName}&rdquo;? 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