diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 3632963..8c69c2c 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -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 -[] -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 -[] -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 + member val PubOverride = Nullable() 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 = diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index e78a580..98c7c9a 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -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 diff --git a/src/MyWebLog/Handlers/Page.fs b/src/MyWebLog/Handlers/Page.fs index 9563b94..070976c 100644 --- a/src/MyWebLog/Handlers/Page.fs +++ b/src/MyWebLog/Handlers/Page.fs @@ -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 diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index 8e0cdb4..67e44a4 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -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 diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 23213c0..6ddfdc0 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -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 "     " 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 "−" + ] + ] + 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}/" diff --git a/src/MyWebLog/Views/Page.fs b/src/MyWebLog/Views/Page.fs index 360858d..55b28de 100644 --- a/src/MyWebLog/Views/Page.fs +++ b/src/MyWebLog/Views/Page.fs @@ -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 ] diff --git a/src/admin-theme/wwwroot/admin.js b/src/admin-theme/wwwroot/admin.js index d69e134..7edc1e5 100644 --- a/src/admin-theme/wwwroot/admin.js +++ b/src/admin-theme/wwwroot/admin.js @@ -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++ },