Migrate page edit to GVE

This commit is contained in:
Daniel J. Summers 2024-03-15 22:49:27 -04:00
parent ddab491dfc
commit ec04fea86c
7 changed files with 187 additions and 190 deletions

View File

@ -626,88 +626,65 @@ type EditCommonModel() =
/// Whether to provide a link to manage chapters
member val IncludeChapterLink = false with get, set
/// The template to use to display the page
member val Template = "" with get, set
/// The source type ("HTML" or "Markdown")
member val Source = "" with get, set
/// The text of the page or post
member val Text = "" with get, set
/// Names of metadata items
member val MetaNames: string array = [||] with get, set
/// Values of metadata items
member val MetaValues: string array = [||] with get, set
/// Whether this is a new page or post
member this.IsNew with get () = this.Id = "new"
/// Fill the properties of this object from a page
member this.FromPage (page: Page) =
member this.PopulateFromPage (page: Page) =
let latest = findLatestRevision page.Revisions
this.Id <- string page.Id
this.Title <- page.Title
this.Permalink <- string page.Permalink
this.Entity <- "page"
this.Source <- latest.Text.SourceType
this.Text <- latest.Text.Text
this.Id <- string page.Id
this.Title <- page.Title
this.Permalink <- string page.Permalink
this.Entity <- "page"
this.Template <- defaultArg page.Template ""
this.Source <- latest.Text.SourceType
this.Text <- latest.Text.Text
this.MetaNames <- page.Metadata |> List.map _.Name |> Array.ofList
this.MetaValues <- page.Metadata |> List.map _.Value |> Array.ofList
/// Fill the properties of this object from a post
member this.FromPost (post: Post) =
member this.PopulateFromPost (post: Post) =
let latest = findLatestRevision post.Revisions
this.Id <- string post.Id
this.Title <- post.Title
this.Permalink <- string post.Permalink
this.Entity <- "post"
this.IncludeChapterLink <- Option.isSome post.Episode && Option.isSome post.Episode.Value.Chapters
this.Template <- defaultArg post.Template ""
this.Source <- latest.Text.SourceType
this.Text <- latest.Text.Text
this.MetaNames <- post.Metadata |> List.map _.Name |> Array.ofList
this.MetaValues <- post.Metadata |> List.map _.Value |> Array.ofList
/// View model to edit a page
[<CLIMutable; NoComparison; NoEquality>]
type EditPageModel = {
/// The ID of the page being edited
PageId: string
/// The title of the page
Title: string
/// The permalink for the page
Permalink: string
/// The template to use to display the page
Template: string
type EditPageModel() =
inherit EditCommonModel()
/// Whether this page is shown in the page list
IsShownInPageList: bool
member val IsShownInPageList = false with get, set
/// The source format for the text
Source: string
/// The text of the page
Text: string
/// Names of metadata items
MetaNames: string array
/// Values of metadata items
MetaValues: string array
} with
/// Create an edit model from an existing page
static member FromPage (page: Page) =
let latest =
match page.Revisions |> List.sortByDescending _.AsOf |> List.tryHead with
| Some rev -> rev
| None -> Revision.Empty
let page = if page.Metadata |> List.isEmpty then { page with Metadata = [ MetaItem.Empty ] } else page
{ PageId = string page.Id
Title = page.Title
Permalink = string page.Permalink
Template = defaultArg page.Template ""
IsShownInPageList = page.IsInPageList
Source = latest.Text.SourceType
Text = latest.Text.Text
MetaNames = page.Metadata |> List.map _.Name |> Array.ofList
MetaValues = page.Metadata |> List.map _.Value |> Array.ofList }
/// Whether this is a new page
member this.IsNew =
this.PageId = "new"
static member FromPage(page: Page) =
let model = EditPageModel()
model.PopulateFromPage page
model.IsShownInPageList <- page.IsInPageList
model
/// Update a page with values from this model
member this.UpdatePage (page: Page) now =
@ -737,163 +714,123 @@ type EditPageModel = {
/// View model to edit a post
[<CLIMutable; NoComparison; NoEquality>]
type EditPostModel = {
/// The ID of the post being edited
PostId: string
/// The title of the post
Title: string
/// The permalink for the post
Permalink: string
/// The source format for the text
Source: string
/// The text of the post
Text: string
type EditPostModel() =
inherit EditCommonModel()
/// The tags for the post
Tags: string
/// The template used to display the post
Template: string
member val Tags = "" with get, set
/// The category IDs for the post
CategoryIds: string array
member val CategoryIds: string array = [||] with get, set
/// The post status
Status: string
member val Status = "" with get, set
/// Whether this post should be published
DoPublish: bool
/// Names of metadata items
MetaNames: string array
/// Values of metadata items
MetaValues: string array
member val DoPublish = false with get, set
/// Whether to override the published date/time
SetPublished: bool
member val SetPublished = false with get, set
/// The published date/time to override
PubOverride: Nullable<DateTime>
member val PubOverride = Nullable<DateTime>() with get, set
/// Whether all revisions should be purged and the override date set as the updated date as well
SetUpdated: bool
member val SetUpdated = false with get, set
/// Whether this post has a podcast episode
IsEpisode: bool
member val IsEpisode = false with get, set
/// The URL for the media for this episode (may be permalink)
Media: string
member val Media = "" with get, set
/// The size (in bytes) of the media for this episode
Length: int64
member val Length = 0L with get, set
/// The duration of the media for this episode
Duration: string
member val Duration = "" with get, set
/// The media type (optional, defaults to podcast-defined media type)
MediaType: string
member val MediaType = "" with get, set
/// The URL for the image for this episode (may be permalink; optional, defaults to podcast image)
ImageUrl: string
member val ImageUrl = "" with get, set
/// A subtitle for the episode (optional)
Subtitle: string
member val Subtitle = "" with get, set
/// The explicit rating for this episode (optional, defaults to podcast setting)
Explicit: string
member val Explicit = "" with get, set
/// The chapter source ("internal" for chapters defined here, "external" for a file link, "none" if none defined)
ChapterSource: string
member val ChapterSource = "" with get, set
/// The URL for the chapter file for the episode (may be permalink; optional)
ChapterFile: string
member val ChapterFile = "" with get, set
/// The type of the chapter file (optional; defaults to application/json+chapters if chapterFile is provided)
ChapterType: string
member val ChapterType = "" with get, set
/// Whether the chapter file (or chapters) contains/contain waypoints
ContainsWaypoints: bool
member val ContainsWaypoints = false with get, set
/// The URL for the transcript (may be permalink; optional)
TranscriptUrl: string
member val TranscriptUrl = "" with get, set
/// The MIME type for the transcript (optional, recommended if transcriptUrl is provided)
TranscriptType: string
member val TranscriptType = "" with get, set
/// The language of the transcript (optional)
TranscriptLang: string
member val TranscriptLang = "" with get, set
/// Whether the provided transcript should be presented as captions
TranscriptCaptions: bool
member val TranscriptCaptions = false with get, set
/// The season number (optional)
SeasonNumber: int
member val SeasonNumber = 0 with get, set
/// A description of this season (optional, ignored if season number is not provided)
SeasonDescription: string
member val SeasonDescription = "" with get, set
/// The episode number (decimal; optional)
EpisodeNumber: string
member val EpisodeNumber = "" with get, set
/// A description of this episode (optional, ignored if episode number is not provided)
EpisodeDescription: string
} with
member val EpisodeDescription = "" with get, set
/// Create an edit model from an existing past
static member FromPost (webLog: WebLog) (post: Post) =
let latest =
match post.Revisions |> List.sortByDescending _.AsOf |> List.tryHead with
| Some rev -> rev
| None -> Revision.Empty
let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.Empty ] } else post
let model = EditPostModel()
let post = if post.Metadata |> List.isEmpty then { post with Metadata = [ MetaItem.Empty ] } else post
model.PopulateFromPost post
let episode = defaultArg post.Episode Episode.Empty
{ PostId = string post.Id
Title = post.Title
Permalink = string post.Permalink
Source = latest.Text.SourceType
Text = latest.Text.Text
Tags = String.Join(", ", post.Tags)
Template = defaultArg post.Template ""
CategoryIds = post.CategoryIds |> List.map string |> Array.ofList
Status = string post.Status
DoPublish = false
MetaNames = post.Metadata |> List.map _.Name |> Array.ofList
MetaValues = post.Metadata |> List.map _.Value |> Array.ofList
SetPublished = false
PubOverride = post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable
SetUpdated = false
IsEpisode = Option.isSome post.Episode
Media = episode.Media
Length = episode.Length
Duration = defaultArg (episode.FormatDuration()) ""
MediaType = defaultArg episode.MediaType ""
ImageUrl = defaultArg episode.ImageUrl ""
Subtitle = defaultArg episode.Subtitle ""
Explicit = defaultArg (episode.Explicit |> Option.map string) ""
ChapterSource = if Option.isSome episode.Chapters then "internal"
elif Option.isSome episode.ChapterFile then "external"
else "none"
ChapterFile = defaultArg episode.ChapterFile ""
ChapterType = defaultArg episode.ChapterType ""
ContainsWaypoints = defaultArg episode.ChapterWaypoints false
TranscriptUrl = defaultArg episode.TranscriptUrl ""
TranscriptType = defaultArg episode.TranscriptType ""
TranscriptLang = defaultArg episode.TranscriptLang ""
TranscriptCaptions = defaultArg episode.TranscriptCaptions false
SeasonNumber = defaultArg episode.SeasonNumber 0
SeasonDescription = defaultArg episode.SeasonDescription ""
EpisodeNumber = defaultArg (episode.EpisodeNumber |> Option.map string) ""
EpisodeDescription = defaultArg episode.EpisodeDescription "" }
/// Whether this is a new post
member this.IsNew =
this.PostId = "new"
model.Tags <- post.Tags |> String.concat ", "
model.CategoryIds <- post.CategoryIds |> List.map string |> Array.ofList
model.Status <- string post.Status
model.PubOverride <- post.PublishedOn |> Option.map webLog.LocalTime |> Option.toNullable
model.IsEpisode <- Option.isSome post.Episode
model.Media <- episode.Media
model.Length <- episode.Length
model.Duration <- defaultArg (episode.FormatDuration()) ""
model.MediaType <- defaultArg episode.MediaType ""
model.ImageUrl <- defaultArg episode.ImageUrl ""
model.Subtitle <- defaultArg episode.Subtitle ""
model.Explicit <- defaultArg (episode.Explicit |> Option.map string) ""
model.ChapterSource <- if Option.isSome episode.Chapters then "internal"
elif Option.isSome episode.ChapterFile then "external"
else "none"
model.ChapterFile <- defaultArg episode.ChapterFile ""
model.ChapterType <- defaultArg episode.ChapterType ""
model.ContainsWaypoints <- defaultArg episode.ChapterWaypoints false
model.TranscriptUrl <- defaultArg episode.TranscriptUrl ""
model.TranscriptType <- defaultArg episode.TranscriptType ""
model.TranscriptLang <- defaultArg episode.TranscriptLang ""
model.TranscriptCaptions <- defaultArg episode.TranscriptCaptions false
model.SeasonNumber <- defaultArg episode.SeasonNumber 0
model.SeasonDescription <- defaultArg episode.SeasonDescription ""
model.EpisodeNumber <- defaultArg (episode.EpisodeNumber |> Option.map string) ""
model.EpisodeDescription <- defaultArg episode.EpisodeDescription ""
model
/// Update a post with values from the submitted form
member this.UpdatePost (post: Post) now =

View File

@ -427,23 +427,21 @@ let absoluteUrl (url: string) (ctx: HttpContext) =
if url.StartsWith "http" then url else ctx.WebLog.AbsoluteUrl (Permalink url)
open System.Collections.Generic
open MyWebLog.Data
/// Get the templates available for the current web log's theme (in a key/value pair list)
/// Get the templates available for the current web log's theme (in a meta item list)
let templatesForTheme (ctx: HttpContext) (typ: string) = backgroundTask {
match! ctx.Data.Theme.FindByIdWithoutText ctx.WebLog.ThemeId with
| Some theme ->
return seq {
KeyValuePair.Create("", $"- Default (single-{typ}) -")
{ Name = ""; Value = $"- Default (single-{typ}) -" }
yield!
theme.Templates
|> Seq.ofList
|> Seq.filter (fun it -> it.Name.EndsWith $"-{typ}" && it.Name <> $"single-{typ}")
|> Seq.map (fun it -> KeyValuePair.Create(it.Name, it.Name))
|> Seq.map (fun it -> { Name = it.Name; Value = it.Name })
}
|> Array.ofSeq
| None -> return [| KeyValuePair.Create("", $"- Default (single-{typ}) -") |]
| None -> return seq { { Name = ""; Value = $"- Default (single-{typ}) -" } }
}
/// Get all authors for a list of posts as metadata items

View File

@ -34,15 +34,7 @@ let edit pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
| Some (title, page) when canEdit page.AuthorId ctx ->
let model = EditPageModel.FromPage page
let! templates = templatesForTheme ctx "page"
return!
hashForPage title
|> withAntiCsrf ctx
|> addToHash ViewContext.Model model
|> addToHash "metadata" (
Array.zip model.MetaNames model.MetaValues
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]))
|> addToHash "templates" templates
|> adminView "page-edit" next ctx
return! adminPage title true next ctx (Views.Page.pageEdit model templates)
| Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx
}
@ -177,7 +169,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
AuthorId = ctx.UserId
PublishedOn = now
} |> someTask
else data.Page.FindFullById (PageId model.PageId) ctx.WebLog.Id
else data.Page.FindFullById (PageId model.Id) ctx.WebLog.Id
match! tryPage with
| Some page when canEdit page.AuthorId ctx ->
let updateList = page.IsInPageList <> model.IsShownInPageList

View File

@ -505,7 +505,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
WebLogId = ctx.WebLog.Id
AuthorId = ctx.UserId }
|> someTask
else data.Post.FindFullById (PostId model.PostId) ctx.WebLog.Id
else data.Post.FindFullById (PostId model.Id) ctx.WebLog.Id
match! tryPost with
| Some post when canEdit post.AuthorId ctx ->
let priorCats = post.CategoryIds
@ -522,7 +522,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
Revisions = [ { (List.head post.Revisions) with AsOf = dt } ] }
else { post with PublishedOn = Some dt }
else post
do! (if model.PostId = "new" then data.Post.Add else data.Post.Update) updatedPost
do! (if model.IsNew then data.Post.Add else data.Post.Update) updatedPost
// If the post was published or its categories changed, refresh the category cache
if model.DoPublish
|| not (priorCats

View File

@ -105,19 +105,25 @@ let shortTime app (instant: Instant) =
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, newAttrs =
let passedId = attrs |> List.tryFind (fun x -> match x with KeyValue ("id", _) -> true | _ -> false)
match passedId with
| Some (KeyValue (_, idValue)) ->
idValue, attrs |> List.filter (fun x -> match x with KeyValue ("id", _) -> false | _ -> true)
| Some _ | None -> name, attrs
div [ _class "form-floating" ] [
[ _type fieldType; _name name; _id fieldId; _class "form-control"; _placeholder labelText; _value value ]
|> List.append newAttrs
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 fieldId ] [ raw labelText ]
label [ _for (defaultArg fieldId name) ] [ raw labelText ]
yield! extra
]
@ -140,7 +146,8 @@ let passwordField 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 =
div [ _class "form-floating" ] [
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)
@ -152,7 +159,8 @@ let selectField<'T, 'a>
/// Create a checkbox input styled as a switch
let checkboxSwitch attrs name labelText (value: bool) extra =
div [ _class "form-check form-switch" ] [
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
@ -312,8 +320,8 @@ let private capitalize (it: string) =
/// The common edit form shared by pages and posts
let commonEdit (model: EditCommonModel) app = [
textField [ _required; _autofocus ] (nameof model.Title) "Title" model.Title []
textField [ _required ] (nameof model.Permalink) "Permalink" model.Permalink [
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" ] [
@ -329,14 +337,14 @@ let commonEdit (model: EditCommonModel) app = [
label [ _for "text" ] [ raw "Text" ]; raw " &nbsp; &nbsp; "
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 (string Html); if model.Source = string Html then _checked ]
_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 (string Markdown); if model.Source = string Markdown then _checked ]
_value "Markdown"; if model.Source = "Markdown" then _checked ]
label [ _class "btn btn-sm btn-outline-secondary"; _for "source_md" ] [ raw "Markdown" ]
]
]
div [ _class "pb-3" ] [
div [ _class "mb-3" ] [
textarea [ _name (nameof model.Text); _id (nameof model.Text); _class "form-control"; _rows "20" ] [
raw model.Text
]
@ -344,6 +352,47 @@ let commonEdit (model: EditCommonModel) app = [
]
/// 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 "&minus;"
]
]
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}))"
]
]
]
/// Form to manage permalinks for pages or posts
let managePermalinks (model: ManagePermalinksModel) app = [
let baseUrl = relUrl app $"admin/{model.Entity}/"

View File

@ -5,6 +5,27 @@ 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 ]

View File

@ -146,7 +146,7 @@ this.Admin = {
newRow.appendChild(nameCol)
newRow.appendChild(valueCol)
document.getElementById("metaItems").appendChild(newRow)
document.getElementById("meta_items").appendChild(newRow)
this.nextMetaIndex++
},