diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index df5c257..2446197 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -27,35 +27,13 @@ module Dashboard = ListedPages = listed Categories = cats TopLevelCategories = topCats } - return! adminPage "Dashboard" false next ctx (Views.Admin.dashboard model) + return! adminPage "Dashboard" false next ctx (Views.WebLog.dashboard model) } // GET /admin/administration let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { - let! themes = ctx.Data.Theme.All() - let cachedTemplates = TemplateCache.allNames () - return! - hashForPage "myWebLog Administration" - |> withAntiCsrf ctx - |> addToHash "cached_themes" ( - themes - |> Seq.ofList - |> Seq.map (fun it -> [| - string it.Id - it.Name - cachedTemplates - |> List.filter _.StartsWith(string it.Id) - |> List.length - |> string - |]) - |> Array.ofSeq) - |> addToHash "web_logs" ( - WebLogCache.all () - |> Seq.ofList - |> Seq.sortBy _.Name - |> Seq.map (fun it -> [| string it.Id; it.Name; it.UrlBase |]) - |> Array.ofSeq) - |> adminView "admin-dashboard" next ctx + let! themes = ctx.Data.Theme.All() + return! adminPage "myWebLog Administration" true next ctx (Views.Admin.dashboard themes) } /// Redirect the user to the admin dashboard @@ -117,43 +95,24 @@ module Category = open MyWebLog.Data // GET /admin/categories - let all : HttpHandler = fun next ctx -> task { - match! TemplateCache.get adminTheme "category-list-body" ctx.Data with - | Ok catListTemplate -> - let! hash = - hashForPage "Categories" - |> withAntiCsrf ctx - |> addViewContext ctx - return! - addToHash "category_list" (catListTemplate.Render hash) hash - |> adminView "category-list" next ctx - | Error message -> return! Error.server message next ctx - } - - // GET /admin/categories/bare - let bare : HttpHandler = fun next ctx -> - hashForPage "Categories" - |> withAntiCsrf ctx - |> adminBareView "category-list-body" next ctx - + let all : HttpHandler = fun next ctx -> + adminPage "Categories" true next ctx Views.WebLog.categoryList // GET /admin/category/{id}/edit let edit catId : HttpHandler = fun next ctx -> task { let! result = task { match catId with - | "new" -> return Some("Add a New Category", { Category.Empty with Id = CategoryId "new" }) + | "new" -> return Some ("Add a New Category", { Category.Empty with Id = CategoryId "new" }) | _ -> match! ctx.Data.Category.FindById (CategoryId catId) ctx.WebLog.Id with - | Some cat -> return Some("Edit Category", cat) + | Some cat -> return Some ("Edit Category", cat) | None -> return None } match result with | Some (title, cat) -> return! - hashForPage title - |> withAntiCsrf ctx - |> addToHash ViewContext.Model (EditCategoryModel.FromCategory cat) - |> adminBareView "category-edit" next ctx + Views.WebLog.categoryEdit (EditCategoryModel.FromCategory cat) + |> adminBarePage title true next ctx | None -> return! Error.notFound next ctx } @@ -171,16 +130,16 @@ module Category = Name = model.Name Slug = model.Slug Description = if model.Description = "" then None else Some model.Description - ParentId = if model.ParentId = "" then None else Some(CategoryId model.ParentId) } + ParentId = if model.ParentId = "" then None else Some (CategoryId model.ParentId) } do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat do! CategoryCache.update ctx do! addMessage ctx { UserMessage.Success with Message = "Category saved successfully" } - return! bare next ctx + return! all next ctx | None -> return! Error.notFound next ctx } - // POST /admin/category/{id}/delete - let delete catId : HttpHandler = fun next ctx -> task { + // DELETE /admin/category/{id} + let delete catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let! result = ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id match result with | CategoryDeleted @@ -194,7 +153,7 @@ module Category = do! addMessage ctx { UserMessage.Success with Message = "Category deleted successfully"; Detail = detail } | CategoryNotFound -> do! addMessage ctx { UserMessage.Error with Message = "Category not found; cannot delete" } - return! bare next ctx + return! all next ctx } @@ -205,19 +164,20 @@ module RedirectRules = // GET /admin/settings/redirect-rules let all : HttpHandler = fun next ctx -> - adminPage "Redirect Rules" true next ctx (Views.Admin.redirectList ctx.WebLog.RedirectRules) + adminPage "Redirect Rules" true next ctx (Views.WebLog.redirectList ctx.WebLog.RedirectRules) // GET /admin/settings/redirect-rules/[index] let edit idx : HttpHandler = fun next ctx -> let titleAndView = if idx = -1 then - Some ("Add", Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) + Some ("Add", Views.WebLog.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) else let rules = ctx.WebLog.RedirectRules if rules.Length < idx || idx < 0 then None else - Some ("Edit", (Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules)))) + Some + ("Edit", (Views.WebLog.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules)))) match titleAndView with | Some (title, view) -> adminBarePage $"{title} Redirect Rule" true next ctx view | None -> Error.notFound next ctx @@ -284,7 +244,7 @@ module TagMapping = // GET /admin/settings/tag-mappings let all : HttpHandler = fun next ctx -> task { let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id - return! adminBarePage "Tag Mapping List" true next ctx (Views.Admin.tagMapList mappings) + return! adminBarePage "Tag Mapping List" true next ctx (Views.WebLog.tagMapList mappings) } // GET /admin/settings/tag-mapping/{id}/edit @@ -296,7 +256,7 @@ module TagMapping = match! tagMap with | Some tm -> return! - Views.Admin.tagMapEdit (EditTagMapModel.FromMapping tm) + Views.WebLog.tagMapEdit (EditTagMapModel.FromMapping tm) |> adminBarePage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") true next ctx | None -> return! Error.notFound next ctx } @@ -497,7 +457,7 @@ module WebLog = let uploads = [ Database; Disk ] let feeds = ctx.WebLog.Rss.CustomFeeds |> List.map (DisplayCustomFeed.FromFeed (CategoryCache.get ctx)) return! - Views.Admin.webLogSettings + Views.WebLog.webLogSettings (SettingsModel.FromWebLog ctx.WebLog) themes pages uploads (EditRssModel.FromRssOptions ctx.WebLog.Rss) feeds |> adminPage "Web Log Settings" true next ctx diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index c1e8cdd..8c55dbd 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -2,7 +2,6 @@ module MyWebLog.Handlers.Feed open System -open System.Collections.Generic open System.IO open System.Net open System.ServiceModel.Syndication @@ -446,7 +445,7 @@ let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next { Name = string Newsletter; Value = "Newsletter" } { Name = string Blog; Value = "Blog" } ] - Views.Admin.feedEdit (EditCustomFeedModel.FromFeed f) ratings mediums + Views.WebLog.feedEdit (EditCustomFeedModel.FromFeed f) ratings mediums |> adminPage $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" true next ctx | None -> Error.notFound next ctx @@ -474,7 +473,7 @@ let saveCustomFeed : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> | None -> return! Error.notFound next ctx } -// POST /admin/settings/rss/{id}/delete +// DELETE /admin/settings/rss/{id} let deleteCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let data = ctx.Data match! data.WebLog.FindById ctx.WebLog.Id with diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 87fe4e5..733f29b 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -114,7 +114,6 @@ let router : HttpHandler = choose [ route "/administration" >=> Admin.Dashboard.admin subRoute "/categor" (requireAccess WebLogAdmin >=> choose [ route "ies" >=> Admin.Category.all - route "ies/bare" >=> Admin.Category.bare routef "y/%s/edit" Admin.Category.edit ]) route "/dashboard" >=> Admin.Dashboard.user @@ -184,11 +183,10 @@ let router : HttpHandler = choose [ routef "/%s/revision/%s/restore" Post.restoreRevision ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ - route "" >=> Admin.WebLog.saveSettings + route "" >=> Admin.WebLog.saveSettings subRoute "/rss" (choose [ - route "" >=> Feed.saveSettings - route "/save" >=> Feed.saveCustomFeed - routef "/%s/delete" Feed.deleteCustomFeed + route "" >=> Feed.saveSettings + route "/save" >=> Feed.saveCustomFeed ]) subRoute "/redirect-rules" (choose [ routef "/%i" Admin.RedirectRules.save @@ -202,13 +200,10 @@ let router : HttpHandler = choose [ route "/new" >=> Admin.Theme.save routef "/%s/delete" Admin.Theme.delete ]) - subRoute "/upload" (choose [ - route "/save" >=> Upload.save - routexp "/delete/(.*)" Upload.deleteFromDisk - routef "/%s/delete" Upload.deleteFromDb - ]) + route "/upload/save" >=> Upload.save ] DELETE >=> validateCsrf >=> choose [ + routef "/category/%s" Admin.Category.delete subRoute "/page" (choose [ routef "/%s" Page.delete routef "/%s/revision/%s" Page.deleteRevision @@ -221,9 +216,14 @@ let router : HttpHandler = choose [ routef "/%s/revisions" Post.purgeRevisions ]) subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ - routef "/user/%s" User.delete routef "/redirect-rules/%i" Admin.RedirectRules.delete + routef "/rss/%s" Feed.deleteCustomFeed routef "/tag-mapping/%s" Admin.TagMapping.delete + routef "/user/%s" User.delete + ]) + subRoute "/upload" (requireAccess WebLogAdmin >=> choose [ + routexp "/disk/(.*)" Upload.deleteFromDisk + routef "/%s" Upload.deleteFromDb ]) ] ]) diff --git a/src/MyWebLog/Handlers/Upload.fs b/src/MyWebLog/Handlers/Upload.fs index aac82d9..a52b037 100644 --- a/src/MyWebLog/Handlers/Upload.fs +++ b/src/MyWebLog/Handlers/Upload.fs @@ -108,30 +108,24 @@ let list : HttpHandler = requireAccess Author >=> fun next ctx -> task { Path = file.Replace($"{path}{slash}", "").Replace(name, "").Replace(slash, '/') UpdatedOn = create Source = string Disk }) - |> List.ofSeq with | :? DirectoryNotFoundException -> [] // This is fine | ex -> warn "Upload" ctx $"Encountered {ex.GetType().Name} listing uploads for {path}:\n{ex.Message}" [] - let allFiles = - dbUploads - |> List.map (DisplayUpload.FromUpload webLog Database) - |> List.append diskUploads - |> List.sortByDescending (fun file -> file.UpdatedOn, file.Path) return! - hashForPage "Uploaded Files" - |> withAntiCsrf ctx - |> addToHash "files" allFiles - |> adminView "upload-list" next ctx + dbUploads + |> Seq.ofList + |> Seq.map (DisplayUpload.FromUpload webLog Database) + |> Seq.append diskUploads + |> Seq.sortByDescending (fun file -> file.UpdatedOn, file.Path) + |> Views.WebLog.uploadList + |> adminPage "Uploaded Files" true next ctx } // GET /admin/upload/new let showNew : HttpHandler = requireAccess Author >=> fun next ctx -> - hashForPage "Upload a File" - |> withAntiCsrf ctx - |> addToHash "destination" (string ctx.WebLog.Uploads) - |> adminView "upload-new" next ctx + adminPage "Upload a File" true next ctx Views.WebLog.uploadNew /// Redirect to the upload list @@ -173,8 +167,8 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task { return! RequestErrors.BAD_REQUEST "Bad request; no file present" next ctx } -// POST /admin/upload/{id}/delete -let deleteFromDb upId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { +// DELETE /admin/upload/{id} +let deleteFromDb upId : HttpHandler = fun next ctx -> task { match! ctx.Data.Upload.Delete (UploadId upId) ctx.WebLog.Id with | Ok fileName -> do! addMessage ctx { UserMessage.Success with Message = $"{fileName} deleted successfully" } @@ -193,8 +187,8 @@ let removeEmptyDirectories (webLog: WebLog) (filePath: string) = path <- String.Join(slash, path.Split slash |> Array.rev |> Array.skip 1 |> Array.rev) else finished <- true -// POST /admin/upload/delete/{**path} -let deleteFromDisk urlParts : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { +// DELETE /admin/upload/disk/{**path} +let deleteFromDisk urlParts : HttpHandler = fun next ctx -> task { let filePath = urlParts |> Seq.skip 1 |> Seq.head let path = Path.Combine(uploadDir, ctx.WebLog.Slug, filePath) if File.Exists path then diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index 40b0eb6..105c91b 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -14,6 +14,7 @@ + diff --git a/src/MyWebLog/Views/Admin.fs b/src/MyWebLog/Views/Admin.fs index e1e171a..a3933db 100644 --- a/src/MyWebLog/Views/Admin.fs +++ b/src/MyWebLog/Views/Admin.fs @@ -2,493 +2,107 @@ module MyWebLog.Views.Admin open Giraffe.Htmx.Common open Giraffe.ViewEngine -open Giraffe.ViewEngine.Accessibility open Giraffe.ViewEngine.Htmx open MyWebLog 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" - ] - ] +/// The administrator dashboard +let dashboard (themes: Theme list) app = [ + let templates = TemplateCache.allNames () + let cacheBaseUrl = relUrl app "admin/cache/" + let webLogCacheUrl = $"{cacheBaseUrl}web-log/" + let themeCacheUrl = $"{cacheBaseUrl}theme/" + let webLogDetail (webLog: WebLog) = + let refreshUrl = $"{webLogCacheUrl}{webLog.Id}/refresh" + div [ _class "row mwl-table-detail" ] [ + div [ _class "col" ] [ + txt webLog.Name; br [] + small [] [ + span [ _class "text-muted" ] [ raw webLog.UrlBase ]; br [] + a [ _href refreshUrl; _hxPost refreshUrl ] [ raw "Refresh" ] ] ] ] - 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" - ] - ] + let themeDetail (theme: Theme) = + let refreshUrl = $"{themeCacheUrl}{theme.Id}/refresh" + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-8" ] [ + txt theme.Name; br [] + small [] [ + span [ _class "text-muted" ] [ txt (string theme.Id); raw " • " ] + a [ _href refreshUrl; _hxPost refreshUrl ] [ raw "Refresh" ] ] ] + div [ _class "col-4" ] [ + raw (templates |> List.filter _.StartsWith(string theme.Id) |> List.length |> string) + ] ] - if app.IsWebLogAdmin then - div [ _class "row pb-3" ] [ - div [ _class "col text-end" ] [ - a [ _href (relUrl app "admin/settings"); _class "btn btn-secondary" ] [ raw "Modify Settings" ] - ] - ] - ] -] - -/// Custom RSS feed edit form -let feedEdit (model: EditCustomFeedModel) (ratings: MetaItem list) (mediums: MetaItem list) app = [ h2 [ _class "my-3" ] [ raw app.PageTitle ] article [] [ - form [ _action (relUrl app "admin/settings/rss/save"); _method "post"; _class "container" ] [ - antiCsrf app - input [ _type "hidden"; _name "Id"; _value model.Id ] - div [ _class "row pb-3" ] [ - div [ _class "col" ] [ - a [ _href (relUrl app "admin/settings#rss-settings") ] [ raw "« Back to Settings" ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-12 col-lg-6" ] [ - fieldset [ _class "container pb-0" ] [ - legend [] [ raw "Identification" ] - div [ _class "row" ] [ - div [ _class "col" ] [ - textField [ _required ] (nameof model.Path) "Relative Feed Path" model.Path [ - span [ _class "form-text fst-italic" ] [ raw "Appended to "; txt app.WebLog.UrlBase ] - ] - ] - ] - div [ _class "row" ] [ - div [ _class "col py-3 d-flex align-self-center justify-content-center" ] [ - checkboxSwitch [ _onclick "Admin.checkPodcast()"; if model.IsPodcast then _checked ] - (nameof model.IsPodcast) "This Is a Podcast Feed" model.IsPodcast [] - ] - ] - ] - ] - div [ _class "col-12 col-lg-6" ] [ - fieldset [ _class "container pb-0" ] [ - legend [] [ raw "Feed Source" ] - div [ _class "row d-flex align-items-center" ] [ - div [ _class "col-1 d-flex justify-content-end pb-3" ] [ - div [ _class "form-check form-check-inline me-0" ] [ - input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeCat" - _class "form-check-input"; _value "category" - if model.SourceType <> "tag" then _checked - _onclick "Admin.customFeedBy('category')" ] - label [ _for "SourceTypeCat"; _class "form-check-label d-none" ] [ raw "Category" ] - ] - ] - div [ _class "col-11 pb-3" ] [ - let cats = - app.Categories - |> Seq.ofArray - |> Seq.map (fun c -> - let parents = - if c.ParentNames.Length = 0 then "" - else - c.ParentNames - |> Array.map (fun it -> $"{it} ⟩ ") - |> String.concat "" - { Name = c.Id; Value = $"{parents} {c.Name}".Trim() }) - |> Seq.append [ { Name = ""; Value = "– Select Category –" } ] - |> List.ofSeq - selectField [ _id "SourceValueCat"; _required - if model.SourceType = "tag" then _disabled ] - (nameof model.SourceValue) "Category" model.SourceValue cats (_.Name) - (_.Value) [] - ] - div [ _class "col-1 d-flex justify-content-end pb-3" ] [ - div [ _class "form-check form-check-inline me-0" ] [ - input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeTag" - _class "form-check-input"; _value "tag" - if model.SourceType= "tag" then _checked - _onclick "Admin.customFeedBy('tag')" ] - label [ _for "sourceTypeTag"; _class "form-check-label d-none" ] [ raw "Tag" ] - ] - ] - div [ _class "col-11 pb-3" ] [ - textField [ _id "SourceValueTag"; _required - if model.SourceType <> "tag" then _disabled ] - (nameof model.SourceValue) "Tag" - (if model.SourceType = "tag" then model.SourceValue else "") [] - ] - ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col" ] [ - fieldset [ _class "container"; _id "podcastFields"; if not model.IsPodcast then _disabled ] [ - legend [] [ raw "Podcast Settings" ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ - textField [ _required ] (nameof model.Title) "Title" model.Title [] - ] - div [ _class "col-12 col-md-4 col-lg-4 pb-3" ] [ - textField [] (nameof model.Subtitle) "Podcast Subtitle" model.Subtitle [] - ] - div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ - numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes" model.ItemsInFeed [] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ - textField [ _required ] (nameof model.AppleCategory) "iTunes Category" - model.AppleCategory [ - span [ _class "form-text fst-italic" ] [ - a [ _href "https://www.thepodcasthost.com/planning/itunes-podcast-categories/" - _target "_blank"; _rel "noopener" ] [ - raw "iTunes Category / Subcategory List" - ] - ] - ] - ] - div [ _class "col-12 col-md-4 pb-3" ] [ - textField [] (nameof model.AppleSubcategory) "iTunes Subcategory" model.AppleSubcategory - [] - ] - div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ - selectField [ _required ] (nameof model.Explicit) "Explicit Rating" model.Explicit - ratings (_.Name) (_.Value) [] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-6 col-lg-4 offset-xxl-1 pb-3" ] [ - textField [ _required ] (nameof model.DisplayedAuthor) "Displayed Author" - model.DisplayedAuthor [] - ] - div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ - emailField [ _required ] (nameof model.Email) "Author E-mail" model.Email [ - span [ _class "form-text fst-italic" ] [ - raw "For iTunes, must match registered e-mail" - ] - ] - ] - div [ _class "col-12 col-sm-5 col-md-4 col-lg-4 col-xl-3 offset-xl-1 col-xxl-2 offset-xxl-0 pb-3" ] [ - textField [] (nameof model.DefaultMediaType) "Default Media Type" - model.DefaultMediaType [ - span [ _class "form-text fst-italic" ] [ raw "Optional; blank for no default" ] - ] - ] - div [ _class "col-12 col-sm-7 col-md-8 col-lg-10 offset-lg-1 pb-3" ] [ - textField [ _required ] (nameof model.ImageUrl) "Image URL" model.ImageUrl [ - span [ _class "form-text fst-italic"] [ - raw "Relative URL will be appended to "; txt app.WebLog.UrlBase; raw "/" - ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-12 col-lg-10 offset-lg-1" ] [ - textField [ _required ] (nameof model.Summary) "Summary" model.Summary [ - span [ _class "form-text fst-italic" ] [ raw "Displayed in podcast directories" ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-12 col-lg-10 offset-lg-1" ] [ - textField [] (nameof model.MediaBaseUrl) "Media Base URL" model.MediaBaseUrl [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; prepended to episode media file if present" - ] - ] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-lg-5 offset-lg-1 pb-3" ] [ - textField [] (nameof model.FundingUrl) "Funding URL" model.FundingUrl [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; URL describing donation options for this podcast, " - raw "relative URL supported" - ] - ] - ] - div [ _class "col-12 col-lg-5 pb-3" ] [ - textField [ _maxlength "128" ] (nameof model.FundingText) "Funding Text" - model.FundingText [ - span [ _class "form-text fst-italic" ] [ raw "Optional; text for the funding link" ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col-8 col-lg-5 offset-lg-1 pb-3" ] [ - textField [] (nameof model.PodcastGuid) "Podcast GUID" model.PodcastGuid [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; v5 UUID uniquely identifying this podcast; " - raw "once entered, do not change this value (" - a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid" - _target "_blank"; _rel "noopener" ] [ - raw "documentation" - ]; raw ")" - ] - ] - ] - div [ _class "col-4 col-lg-3 offset-lg-2 pb-3" ] [ - selectField [] (nameof model.Medium) "Medium" model.Medium mediums (_.Name) (_.Value) [ - span [ _class "form-text fst-italic" ] [ - raw "Optional; medium of the podcast content (" - a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#medium" - _target "_blank"; _rel "noopener" ] [ - raw "documentation" - ]; raw ")" - ] - ] - ] - ] - ] - ] - ] - div [ _class "row pb-3" ] [ div [ _class "col text-center" ] [ saveButton ] ] + fieldset [ _class "container mb-3 pb-0" ] [ + legend [] [ raw "Themes" ] + span [ _hxGet (relUrl app "admin/theme/list"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] ] - ] -] - - -/// Redirect Rule edit form -let redirectEdit (model: EditRedirectRuleModel) app = [ - let url = relUrl app $"admin/settings/redirect-rules/{model.RuleId}" - h3 [] [ raw (if model.RuleId < 0 then "Add" else "Edit"); raw " Redirect Rule" ] - form [ _action url; _hxPost url; _hxTarget "body"; _method "post"; _class "container" ] [ - antiCsrf app - input [ _type "hidden"; _name "RuleId"; _value (string model.RuleId) ] - div [ _class "row" ] [ - div [ _class "col-12 col-lg-5 mb-3" ] [ - textField [ _autofocus; _required ] (nameof model.From) "From" model.From [ - span [ _class "form-text" ] [ raw "From local URL/pattern" ] - ] - ] - div [ _class "col-12 col-lg-5 mb-3" ] [ - textField [ _required ] (nameof model.To) "To" model.To [ - span [ _class "form-text" ] [ raw "To URL/pattern" ] - ] - ] - div [ _class "col-12 col-lg-2 mb-3" ] [ - checkboxSwitch [] (nameof model.IsRegex) "Use RegEx" model.IsRegex [] - ] - ] - if model.RuleId < 0 then - div [ _class "row mb-3" ] [ - div [ _class "col-12 text-center" ] [ - label [ _class "me-1" ] [ raw "Add Rule" ] - div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "New rule placement button group" ] [ - input [ _type "radio"; _name "InsertAtTop"; _id "at_top"; _class "btn-check"; _value "true" ] - label [ _class "btn btn-sm btn-outline-secondary"; _for "at_top" ] [ raw "Top" ] - input [ _type "radio"; _name "InsertAtTop"; _id "at_bot"; _class "btn-check"; _value "false" - _checked ] - label [ _class "btn btn-sm btn-outline-secondary"; _for "at_bot" ] [ raw "Bottom" ] - ] - ] - ] - div [ _class "row mb-3" ] [ - div [ _class "col text-center" ] [ - saveButton; raw "   " - a [ _href (relUrl app "admin/settings/redirect-rules"); _class "btn btn-sm btn-secondary ms-3" ] [ - raw "Cancel" - ] - ] - ] - ] -] - - -/// The list of current redirect rules -let redirectList (model: RedirectRule list) app = [ - // Generate the detail for a redirect rule - let ruleDetail idx (rule: RedirectRule) = - let ruleId = $"rule_{idx}" - div [ _class "row mwl-table-detail"; _id ruleId ] [ - div [ _class "col-5 no-wrap" ] [ - txt rule.From; br [] - small [] [ - let ruleUrl = relUrl app $"admin/settings/redirect-rules/{idx}" - a [ _href ruleUrl; _hxTarget $"#{ruleId}"; _hxSwap $"{HxSwap.InnerHtml} show:#{ruleId}:top" ] [ - raw "Edit" - ] - if idx > 0 then - span [ _class "text-muted" ] [ raw " • " ] - a [ _href $"{ruleUrl}/up"; _hxPost $"{ruleUrl}/up" ] [ raw "Move Up" ] - if idx <> model.Length - 1 then - span [ _class "text-muted" ] [ raw " • " ] - a [ _href $"{ruleUrl}/down"; _hxPost $"{ruleUrl}/down" ] [ raw "Move Down" ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _class "text-danger"; _href ruleUrl; _hxDelete ruleUrl - _hxConfirm "Are you sure you want to delete this redirect rule?" ] [ - raw "Delete" - ] - ] - ] - div [ _class "col-5" ] [ txt rule.To ] - div [ _class "col-2 text-center" ] [ yesOrNo rule.IsRegex ] - ] - h2 [ _class "my-3" ] [ raw app.PageTitle ] - article [] [ - p [ _class "mb-3" ] [ - a [ _href (relUrl app "admin/settings") ] [ raw "« Back to Settings" ] - ] - div [ _class "container" ] [ - div [ _class "row" ] [ - div [ _class "col" ] [ - a [ _href (relUrl app "admin/settings/redirect-rules/-1"); _class "btn btn-primary btn-sm mb-3" - _hxTarget "#rule_new" ] [ - raw "Add Redirect Rule" - ] - ] + fieldset [ _class "container mb-3 pb-0" ] [ + legend [] [ raw "Caches" ] + p [ _class "pb-2" ] [ + raw "myWebLog uses a few caches to ensure that it serves pages as fast as possible. (" + a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#cache-management" + _target "_blank" ] [ + raw "more information" + ]; raw ")" ] div [ _class "row" ] [ - div [ _class "col" ] [ - if List.isEmpty model then - div [ _id "rule_new" ] [ - p [ _class "text-muted text-center fst-italic" ] [ - raw "This web log has no redirect rules defined" + div [ _class "col-12 col-lg-6 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-secondary" ] [ raw "Web Logs" ] + div [ _class "card-body pb-0" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "These caches include the page list and categories for each web log" + ] + let webLogUrl = $"{cacheBaseUrl}web-log/" + form [ _method "post"; _class "container g-0"; _hxNoBoost; _hxTarget "body" + _hxSwap $"{HxSwap.InnerHtml} show:window:top" ] [ + antiCsrf app + button [ _type "submit"; _class "btn btn-sm btn-primary mb-2" + _hxPost $"{webLogUrl}all/refresh" ] [ + raw "Refresh All" + ] + div [ _class "row mwl-table-heading" ] [ div [ _class "col" ] [ raw "Web Log" ] ] + yield! WebLogCache.all () |> List.sortBy _.Name |> List.map webLogDetail ] ] - else - div [ _class "container g-0" ] [ - div [ _class "row mwl-table-heading" ] [ - div [ _class "col-5" ] [ raw "From" ] - div [ _class "col-5" ] [ raw "To" ] - div [ _class "col-2 text-center" ] [ raw "RegEx?" ] - ] - ] - div [ _class "row mwl-table-detail"; _id "rule_new" ] [] - form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ - antiCsrf app; yield! List.mapi ruleDetail model - ] + ] ] - ] - ] - p [ _class "mt-3 text-muted fst-italic text-center" ] [ - raw "This is an advanced feature; please " - a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#redirect-rules" - _target "_blank" ] [ - raw "read and understand the documentation on this feature" - ] - raw " before adding rules." - ] - ] -] - - -/// Edit a tag mapping -let tagMapEdit (model: EditTagMapModel) app = [ - h5 [ _class "my-3" ] [ txt app.PageTitle ] - form [ _hxPost (relUrl app "admin/settings/tag-mapping/save"); _method "post"; _class "container" - _hxTarget "#tagList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ - antiCsrf app - input [ _type "hidden"; _name "Id"; _value model.Id ] - div [ _class "row mb-3" ] [ - div [ _class "col-6 col-lg-4 offset-lg-2" ] [ - textField [ _autofocus; _required ] (nameof model.Tag) "Tag" model.Tag [] - ] - div [ _class "col-6 col-lg-4" ] [ - textField [ _required ] (nameof model.UrlValue) "URL Value" model.UrlValue [] - ] - ] - div [ _class "row mb-3" ] [ - div [ _class "col text-center" ] [ - saveButton; raw "   " - a [ _href (relUrl app "admin/settings/tag-mappings"); _class "btn btn-sm btn-secondary ms-3" ] [ - raw "Cancel" + div [ _class "col-12 col-lg-6 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-secondary" ] [ raw "Themes" ] + div [ _class "card-body pb-0" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "The theme template cache is filled on demand as pages are displayed; " + raw "refreshing a theme with no cached templates will still refresh its asset cache" + ] + form [ _method "post"; _class "container g-0"; _hxNoBoost; _hxTarget "body" + _hxSwap $"{HxSwap.InnerHtml} show:window:top" ] [ + antiCsrf app + button [ _type "submit"; _class "btn btn-sm btn-primary mb-2" + _hxPost $"{themeCacheUrl}all/refresh" ] [ + raw "Refresh All" + ] + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-8" ] [ raw "Theme" ]; div [ _class "col-4" ] [ raw "Cached" ] + ] + yield! themes |> List.filter (fun t -> t.Id <> ThemeId "admin") |> List.map themeDetail + ] + ] + ] ] ] ] ] ] - -/// Display a list of the web log's current tag mappings -let tagMapList (model: TagMap list) app = - let tagMapDetail (map: TagMap) = - let url = relUrl app $"admin/settings/tag-mapping/{map.Id}" - div [ _class "row mwl-table-detail"; _id $"tag_{map.Id}" ] [ - div [ _class "col no-wrap" ] [ - txt map.Tag; br [] - small [] [ - a [ _href $"{url}/edit"; _hxTarget $"#tag_{map.Id}" - _hxSwap $"{HxSwap.InnerHtml} show:#tag_{map.Id}:top" ] [ - raw "Edit" - ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _href url; _hxDelete url; _class "text-danger" - _hxConfirm $"Are you sure you want to delete the mapping for “{map.Tag}”? This action cannot be undone." ] [ - raw "Delete" - ] - ] - ] - div [ _class "col" ] [ txt map.UrlValue ] - ] - div [ _id "tagList"; _class "container" ] [ - div [ _class "row" ] [ - div [ _class "col" ] [ - if List.isEmpty model then - div [ _id "tag_new" ] [ - p [ _class "text-muted text-center fst-italic" ] [ raw "This web log has no tag mappings" ] - ] - else - div [ _class "container g-0" ] [ - div [ _class "row mwl-table-heading" ] [ - div [ _class "col" ] [ raw "Tag" ] - div [ _class "col" ] [ raw "URL Value" ] - ] - ] - form [ _method "post"; _class "container g-0"; _hxTarget "#tagList"; _hxSwap HxSwap.OuterHtml ] [ - antiCsrf app - div [ _class "row mwl-table-detail"; _id "tag_new" ] [] - yield! List.map tagMapDetail model - ] - ] - ] - ] - |> List.singleton - - /// Display a list of themes let themeList (model: DisplayTheme list) app = let themeCol = "col-12 col-md-6" @@ -574,185 +188,3 @@ let themeUpload app = ] ] |> List.singleton - - -/// Web log settings page -let webLogSettings - (model: SettingsModel) (themes: Theme list) (pages: Page list) (uploads: UploadDestination list) - (rss: EditRssModel) (feeds: DisplayCustomFeed list) app = [ - h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " Settings" ] - article [] [ - p [ _class "text-muted" ] [ - raw "Go to: "; a [ _href "#users" ] [ raw "Users" ]; raw " • " - a [ _href "#rss-settings" ] [ raw "RSS Settings" ]; raw " • " - a [ _href "#tag-mappings" ] [ raw "Tag Mappings" ]; raw " • " - a [ _href (relUrl app "admin/settings/redirect-rules") ] [ raw "Redirect Rules" ] - ] - fieldset [ _class "container mb-3" ] [ - legend [] [ raw "Web Log Settings" ] - form [ _action (relUrl app "admin/settings"); _method "post" ] [ - antiCsrf app - div [ _class "container g-0" ] [ - div [ _class "row" ] [ - div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ - textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name [] - ] - div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ - textField [ _required ] (nameof model.Slug) "Slug" model.Slug [ - span [ _class "form-text" ] [ - span [ _class "badge rounded-pill bg-warning text-dark" ] [ raw "WARNING" ] - raw " changing this value may break links (" - a [ _href "https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings" - _target "_blank" ] [ - raw "more" - ]; raw ")" - ] - ] - ] - div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ - textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle [] - ] - div [ _class "col-12 col-md-6 col-xl-4 offset-xl-1 pb-3" ] [ - selectField [ _required ] (nameof model.ThemeId) "Theme" model.ThemeId themes - (fun t -> string t.Id) (fun t -> $"{t.Name} (v{t.Version})") [] - ] - div [ _class "col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3" ] [ - selectField [ _required ] (nameof model.DefaultPage) "Default Page" model.DefaultPage pages - (fun p -> string p.Id) (_.Title) [] - ] - div [ _class "col-12 col-md-4 col-xl-2 pb-3" ] [ - numberField [ _required; _min "0"; _max "50" ] (nameof model.PostsPerPage) "Posts per Page" - model.PostsPerPage [] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-md-4 col-xl-3 offset-xl-2 pb-3" ] [ - textField [ _required ] (nameof model.TimeZone) "Time Zone" model.TimeZone [] - ] - div [ _class "col-12 col-md-4 col-xl-2" ] [ - checkboxSwitch [] (nameof model.AutoHtmx) "Auto-Load htmx" model.AutoHtmx [] - span [ _class "form-text fst-italic" ] [ - a [ _href "https://htmx.org"; _target "_blank"; _rel "noopener" ] [ - raw "What is this?" - ] - ] - ] - div [ _class "col-12 col-md-4 col-xl-3 pb-3" ] [ - selectField [] (nameof model.Uploads) "Default Upload Destination" model.Uploads uploads - string string [] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col text-center" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] - ] - ] - ] - ] - ] - fieldset [ _id "users"; _class "container mb-3 pb-0" ] [ - legend [] [ raw "Users" ] - span [ _hxGet (relUrl app "admin/settings/users"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] - ] - fieldset [ _id "rss-settings"; _class "container mb-3 pb-0" ] [ - legend [] [ raw "RSS Settings" ] - form [ _action (relUrl app "admin/settings/rss"); _method "post"; _class "container g-0" ] [ - antiCsrf app - div [ _class "row pb-3" ] [ - div [ _class "col col-xl-8 offset-xl-2" ] [ - fieldset [ _class "d-flex justify-content-evenly flex-row" ] [ - legend [] [ raw "Feeds Enabled" ] - checkboxSwitch [] (nameof rss.IsFeedEnabled) "All Posts" rss.IsFeedEnabled [] - checkboxSwitch [] (nameof rss.IsCategoryEnabled) "Posts by Category" rss.IsCategoryEnabled - [] - checkboxSwitch [] (nameof rss.IsTagEnabled) "Posts by Tag" rss.IsTagEnabled [] - ] - ] - ] - div [ _class "row" ] [ - div [ _class "col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3" ] [ - textField [] (nameof rss.FeedName) "Feed File Name" rss.FeedName [ - span [ _class "form-text" ] [ raw "Default is "; code [] [ raw "feed.xml" ] ] - ] - ] - div [ _class "col-12 col-sm-6 col-md-4 col-xl-2 pb-3" ] [ - numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed" rss.ItemsInFeed [ - span [ _class "form-text" ] [ - raw "Set to “0” to use “Posts per Page” setting (" - raw (string app.WebLog.PostsPerPage); raw ")" - ] - ] - ] - div [ _class "col-12 col-md-5 col-xl-4 pb-3" ] [ - textField [] (nameof rss.Copyright) "Copyright String" rss.Copyright [ - span [ _class "form-text" ] [ - raw "Can be a " - a [ _href "https://creativecommons.org/share-your-work/"; _target "_blank" - _rel "noopener" ] [ - raw "Creative Commons license string" - ] - ] - ] - ] - ] - div [ _class "row pb-3" ] [ - div [ _class "col text-center" ] [ - button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] - ] - ] - ] - fieldset [ _class "container mb-3 pb-0" ] [ - legend [] [ raw "Custom Feeds" ] - a [ _class "btn btn-sm btn-secondary"; _href (relUrl app "admin/settings/rss/new/edit") ] [ - raw "Add a New Custom Feed" - ] - if feeds.Length = 0 then - p [ _class "text-muted fst-italic text-center" ] [ raw "No custom feeds defined" ] - else - form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ - antiCsrf app - div [ _class "row mwl-table-heading" ] [ - div [ _class "col-12 col-md-6" ] [ - span [ _class "d-md-none" ] [ raw "Feed" ] - span [ _class "d-none d-md-inline" ] [ raw "Source" ] - ] - div [ _class $"col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ] - ] - for feed in feeds do - div [ _class "row mwl-table-detail" ] [ - div [ _class "col-12 col-md-6" ] [ - txt feed.Source - if feed.IsPodcast then - raw "   "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ] - br [] - small [] [ - let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}" - a [ _href (relUrl app feed.Path); _target "_blank" ] [ raw "View Feed" ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ] - span [ _class "text-muted" ] [ raw " • " ] - a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger" - _hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [ - raw "Delete" - ] - ] - ] - div [ _class "col-12 col-md-6" ] [ - small [ _class "d-md-none" ] [ raw "Served at "; txt feed.Path ] - span [ _class "d-none d-md-inline" ] [ txt feed.Path ] - ] - ] - ] - ] - ] - fieldset [ _id "tag-mappings"; _class "container mb-3 pb-0" ] [ - legend [] [ raw "Tag Mappings" ] - a [ _href (relUrl app "admin/settings/tag-mapping/new/edit"); _class "btn btn-primary btn-sm mb-3" - _hxTarget "#tag_new" ] [ - raw "Add a New Tag Mapping" - ] - span [ _hxGet (relUrl app "admin/settings/tag-mappings"); _hxTrigger HxTrigger.Load - _hxSwap HxSwap.OuterHtml ] [] - ] - ] -] diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index 5372127..a087d2c 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -74,6 +74,9 @@ let txt = encodedText /// Shorthand for raw text in a template let raw = rawText +/// Rel attribute to prevent opener information from being provided to the new window +let _relNoOpener = _rel "noopener" + /// The pattern for a long date let longDatePattern = ZonedDateTimePattern.CreateWithInvariantCulture("MMMM d, yyyy", DateTimeZoneProviders.Tzdb) @@ -136,7 +139,7 @@ let passwordField attrs name labelText value extra = /// Create a select (dropdown) field let selectField<'T, 'a> - attrs name labelText value (values: 'T list) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra = + attrs name labelText value (values: 'T seq) (idFunc: 'T -> 'a) (displayFunc: 'T -> string) extra = div [ _class "form-floating" ] [ select ([ _name name; _id name; _class "form-control" ] |> List.append attrs) [ for item in values do @@ -161,6 +164,10 @@ let checkboxSwitch attrs name labelText (value: bool) extra = let saveButton = button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Save Changes" ] +/// A spacer bullet to use between action links +let actionSpacer = + span [ _class "text-muted" ] [ raw " • " ] + /// Functions for generating content in varying layouts module Layout = diff --git a/src/MyWebLog/Views/Post.fs b/src/MyWebLog/Views/Post.fs index fa0de21..d7b9499 100644 --- a/src/MyWebLog/Views/Post.fs +++ b/src/MyWebLog/Views/Post.fs @@ -66,7 +66,7 @@ let chapterEdit (model: EditChapterModel) app = [ textField (_required :: attrs) (nameof model.LocationGeo) "Geo URL" model.LocationGeo [ em [ _class "form-text" ] [ a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#geo-recommended" - _target "_blank"; _rel "noopener" ] [ + _target "_blank"; _relNoOpener ] [ raw "see spec" ] ] @@ -76,10 +76,10 @@ let chapterEdit (model: EditChapterModel) app = [ textField attrs (nameof model.LocationOsm) "OpenStreetMap ID" model.LocationOsm [ em [ _class "form-text" ] [ raw "Optional; " - a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _rel "noopener" ] [ raw "get ID" ] + a [ _href "https://www.openstreetmap.org/"; _target "_blank"; _relNoOpener ] [ raw "get ID" ] raw ", " a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/location/location.md#osm-recommended" - _target "_blank"; _rel "noopener" ] [ + _target "_blank"; _relNoOpener ] [ raw "see spec" ] ] diff --git a/src/MyWebLog/Views/WebLog.fs b/src/MyWebLog/Views/WebLog.fs new file mode 100644 index 0000000..2aeff71 --- /dev/null +++ b/src/MyWebLog/Views/WebLog.fs @@ -0,0 +1,875 @@ +module MyWebLog.Views.WebLog + +open Giraffe.Htmx.Common +open Giraffe.ViewEngine +open Giraffe.ViewEngine.Accessibility +open Giraffe.ViewEngine.Htmx +open MyWebLog +open MyWebLog.ViewModels + +/// Form to add or edit a category +let categoryEdit (model: EditCategoryModel) app = + div [ _class "col-12" ] [ + h5 [ _class "my-3" ] [ raw app.PageTitle ] + form [ _action (relUrl app "admin/category/save"); _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name (nameof model.CategoryId); _value model.CategoryId ] + div [ _class "row" ] [ + div [ _class "col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3" ] [ + textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name [] + ] + div [ _class "col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3" ] [ + textField [ _required ] (nameof model.Slug) "Slug" model.Slug [] + ] + div [ _class "col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3" ] [ + let cats = + app.Categories + |> Seq.ofArray + |> Seq.filter (fun c -> c.Id <> model.CategoryId) + |> Seq.map (fun c -> + let parents = + c.ParentNames + |> Array.map (fun it -> $"{it}   » ") + |> String.concat "" + { Name = c.Id; Value = $"{parents}{c.Name}" }) + |> Seq.append [ { Name = ""; Value = "– None –" } ] + selectField [] (nameof model.ParentId) "Parent Category" model.ParentId cats (_.Name) (_.Value) [] + ] + div [ _class "col-12 col-xl-10 offset-xl-1 mb-3" ] [ + textField [] (nameof model.Description) "Description" model.Description [] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + saveButton + a [ _href (relUrl app "admin/categories"); _class "btn btn-sm btn-secondary ms-3" ] [ raw "Cancel" ] + ] + ] + ] + ] + |> List.singleton + + +/// Category list page +let categoryList app = [ + let catCol = "col-12 col-md-6 col-xl-5 col-xxl-4" + let descCol = "col-12 col-md-6 col-xl-7 col-xxl-8" + let categoryDetail (cat: DisplayCategory) = + div [ _class "row mwl-table-detail"; _id $"cat_{cat.Id}" ] [ + div [ _class $"{catCol} no-wrap" ] [ + if cat.ParentNames.Length > 0 then + cat.ParentNames + |> Seq.ofArray + |> Seq.map (fun it -> raw $"{it} ⟩ ") + |> List.ofSeq + |> small [ _class "text-muted" ] + raw cat.Name; br [] + small [] [ + let catUrl = relUrl app $"admin/category/{cat.Id}" + if cat.PostCount > 0 then + a [ _href (relUrl app $"category/{cat.Slug}"); _target "_blank" ] [ + raw $"View { cat.PostCount} Post"; if cat.PostCount <> 1 then raw "s" + ]; actionSpacer + a [ _href $"{catUrl}/edit"; _hxTarget $"#cat_{cat.Id}" + _hxSwap $"{HxSwap.InnerHtml} show:#cat_{cat.Id}:top" ] [ + raw "Edit" + ]; actionSpacer + a [ _href catUrl; _hxDelete catUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the category “{cat.Name}”? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class descCol ] [ + match cat.Description with Some value -> raw value | None -> em [ _class "text-muted" ] [ raw "none" ] + ] + ] + + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + a [ _href (relUrl app "admin/category/new/edit"); _class "btn btn-primary btn-sm mb-3"; _hxTarget "#cat_new" ] [ + raw "Add a New Category" + ] + div [ _id "catList"; _class "container" ] [ + if app.Categories.Length = 0 then + div [ _id "cat_new" ] [ + p [ _class "text-muted fst-italic text-center" ] [ + raw "This web log has no categories defined" + ] + ] + else + div [ _class "container" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class catCol ] [ raw "Category"; span [ _class "d-md-none" ] [ raw "; Description" ] ] + div [ _class $"{descCol} d-none d-md-inline-block" ] [ raw "Description" ] + ] + ] + form [ _method "post"; _class "container" ] [ + // don't think we need this... + // _hxTarget "#catList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" + antiCsrf app + div [ _class "row mwl-table-detail"; _id "cat_new" ] [] + yield! app.Categories |> Seq.ofArray |> Seq.map categoryDetail |> List.ofSeq + ] + ] + ] +] + + +/// The main dashboard +let dashboard (model: DashboardModel) app = [ + h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " • Dashboard" ] + article [ _class "container" ] [ + div [ _class "row" ] [ + section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-primary" ] [ raw "Posts" ] + div [ _class "card-body" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "Published " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Posts) ] + raw "  Drafts " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Drafts) ] + ] + if app.IsAuthor then + a [ _href (relUrl app "admin/posts"); _class "btn btn-secondary me-2" ] [ raw "View All" ] + a [ _href (relUrl app "admin/post/new/edit"); _class "btn btn-primary" ] [ + raw "Write a New Post" + ] + ] + ] + ] + section [ _class "col-lg-5 col-xl-4 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-primary" ] [ raw "Pages" ] + div [ _class "card-body" ] [ + h6 [ _class "card-subtitle text-muted pb-3" ] [ + raw "All " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Pages) ] + raw "  Shown in Page List " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.ListedPages) ] + ] + if app.IsAuthor then + a [ _href (relUrl app "admin/pages"); _class "btn btn-secondary me-2" ] [ raw "View All" ] + a [ _href (relUrl app "admin/page/new/edit"); _class "btn btn-primary" ] [ + raw "Create a New Page" + ] + ] + ] + ] + ] + div [ _class "row" ] [ + section [ _class "col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3" ] [ + div [ _class "card" ] [ + header [ _class "card-header text-white bg-secondary" ] [ raw "Categories" ] + div [ _class "card-body" ] [ + h6 [ _class "card-subtitle text-muted pb-3"] [ + raw "All " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.Categories) ] + raw "  Top Level " + span [ _class "badge rounded-pill bg-secondary" ] [ raw (string model.TopLevelCategories) ] + ] + if app.IsWebLogAdmin then + a [ _href (relUrl app "admin/categories"); _class "btn btn-secondary me-2" ] [ + raw "View All" + ] + a [ _href (relUrl app "admin/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" ] + ] + ] + ] +] + + +/// Custom RSS feed edit form +let feedEdit (model: EditCustomFeedModel) (ratings: MetaItem list) (mediums: MetaItem list) app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action (relUrl app "admin/settings/rss/save"); _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + a [ _href (relUrl app "admin/settings#rss-settings") ] [ raw "« Back to Settings" ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-6" ] [ + fieldset [ _class "container pb-0" ] [ + legend [] [ raw "Identification" ] + div [ _class "row" ] [ + div [ _class "col" ] [ + textField [ _required ] (nameof model.Path) "Relative Feed Path" model.Path [ + span [ _class "form-text fst-italic" ] [ raw "Appended to "; txt app.WebLog.UrlBase ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col py-3 d-flex align-self-center justify-content-center" ] [ + checkboxSwitch [ _onclick "Admin.checkPodcast()"; if model.IsPodcast then _checked ] + (nameof model.IsPodcast) "This Is a Podcast Feed" model.IsPodcast [] + ] + ] + ] + ] + div [ _class "col-12 col-lg-6" ] [ + fieldset [ _class "container pb-0" ] [ + legend [] [ raw "Feed Source" ] + div [ _class "row d-flex align-items-center" ] [ + div [ _class "col-1 d-flex justify-content-end pb-3" ] [ + div [ _class "form-check form-check-inline me-0" ] [ + input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeCat" + _class "form-check-input"; _value "category" + if model.SourceType <> "tag" then _checked + _onclick "Admin.customFeedBy('category')" ] + label [ _for "SourceTypeCat"; _class "form-check-label d-none" ] [ raw "Category" ] + ] + ] + div [ _class "col-11 pb-3" ] [ + let cats = + app.Categories + |> Seq.ofArray + |> Seq.map (fun c -> + let parents = + c.ParentNames + |> Array.map (fun it -> $"{it} ⟩ ") + |> String.concat "" + { Name = c.Id; Value = $"{parents}{c.Name}" }) + |> Seq.append [ { Name = ""; Value = "– Select Category –" } ] + selectField [ _id "SourceValueCat"; _required + if model.SourceType = "tag" then _disabled ] + (nameof model.SourceValue) "Category" model.SourceValue cats (_.Name) + (_.Value) [] + ] + div [ _class "col-1 d-flex justify-content-end pb-3" ] [ + div [ _class "form-check form-check-inline me-0" ] [ + input [ _type "radio"; _name (nameof model.SourceType); _id "SourceTypeTag" + _class "form-check-input"; _value "tag" + if model.SourceType= "tag" then _checked + _onclick "Admin.customFeedBy('tag')" ] + label [ _for "sourceTypeTag"; _class "form-check-label d-none" ] [ raw "Tag" ] + ] + ] + div [ _class "col-11 pb-3" ] [ + textField [ _id "SourceValueTag"; _required + if model.SourceType <> "tag" then _disabled ] + (nameof model.SourceValue) "Tag" + (if model.SourceType = "tag" then model.SourceValue else "") [] + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col" ] [ + fieldset [ _class "container"; _id "podcastFields"; if not model.IsPodcast then _disabled ] [ + legend [] [ raw "Podcast Settings" ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.Title) "Title" model.Title [] + ] + div [ _class "col-12 col-md-4 col-lg-4 pb-3" ] [ + textField [] (nameof model.Subtitle) "Podcast Subtitle" model.Subtitle [] + ] + div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ + numberField [ _required ] (nameof model.ItemsInFeed) "# Episodes" model.ItemsInFeed [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-5 col-lg-4 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.AppleCategory) "iTunes Category" + model.AppleCategory [ + span [ _class "form-text fst-italic" ] [ + a [ _href "https://www.thepodcasthost.com/planning/itunes-podcast-categories/" + _target "_blank"; _relNoOpener ] [ + raw "iTunes Category / Subcategory List" + ] + ] + ] + ] + div [ _class "col-12 col-md-4 pb-3" ] [ + textField [] (nameof model.AppleSubcategory) "iTunes Subcategory" model.AppleSubcategory + [] + ] + div [ _class "col-12 col-md-3 col-lg-2 pb-3" ] [ + selectField [ _required ] (nameof model.Explicit) "Explicit Rating" model.Explicit + ratings (_.Name) (_.Value) [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 col-lg-4 offset-xxl-1 pb-3" ] [ + textField [ _required ] (nameof model.DisplayedAuthor) "Displayed Author" + model.DisplayedAuthor [] + ] + div [ _class "col-12 col-md-6 col-lg-4 pb-3" ] [ + emailField [ _required ] (nameof model.Email) "Author E-mail" model.Email [ + span [ _class "form-text fst-italic" ] [ + raw "For iTunes, must match registered e-mail" + ] + ] + ] + div [ _class "col-12 col-sm-5 col-md-4 col-lg-4 col-xl-3 offset-xl-1 col-xxl-2 offset-xxl-0 pb-3" ] [ + textField [] (nameof model.DefaultMediaType) "Default Media Type" + model.DefaultMediaType [ + span [ _class "form-text fst-italic" ] [ raw "Optional; blank for no default" ] + ] + ] + div [ _class "col-12 col-sm-7 col-md-8 col-lg-10 offset-lg-1 pb-3" ] [ + textField [ _required ] (nameof model.ImageUrl) "Image URL" model.ImageUrl [ + span [ _class "form-text fst-italic"] [ + raw "Relative URL will be appended to "; txt app.WebLog.UrlBase; raw "/" + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-10 offset-lg-1" ] [ + textField [ _required ] (nameof model.Summary) "Summary" model.Summary [ + span [ _class "form-text fst-italic" ] [ raw "Displayed in podcast directories" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-12 col-lg-10 offset-lg-1" ] [ + textField [] (nameof model.MediaBaseUrl) "Media Base URL" model.MediaBaseUrl [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; prepended to episode media file if present" + ] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-lg-5 offset-lg-1 pb-3" ] [ + textField [] (nameof model.FundingUrl) "Funding URL" model.FundingUrl [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; URL describing donation options for this podcast, " + raw "relative URL supported" + ] + ] + ] + div [ _class "col-12 col-lg-5 pb-3" ] [ + textField [ _maxlength "128" ] (nameof model.FundingText) "Funding Text" + model.FundingText [ + span [ _class "form-text fst-italic" ] [ raw "Optional; text for the funding link" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col-8 col-lg-5 offset-lg-1 pb-3" ] [ + textField [] (nameof model.PodcastGuid) "Podcast GUID" model.PodcastGuid [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; v5 UUID uniquely identifying this podcast; " + raw "once entered, do not change this value (" + a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#guid" + _target "_blank"; _relNoOpener ] [ + raw "documentation" + ]; raw ")" + ] + ] + ] + div [ _class "col-4 col-lg-3 offset-lg-2 pb-3" ] [ + selectField [] (nameof model.Medium) "Medium" model.Medium mediums (_.Name) (_.Value) [ + span [ _class "form-text fst-italic" ] [ + raw "Optional; medium of the podcast content (" + a [ _href "https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md#medium" + _target "_blank"; _relNoOpener ] [ + raw "documentation" + ]; raw ")" + ] + ] + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ div [ _class "col text-center" ] [ saveButton ] ] + ] + ] +] + + +/// Redirect Rule edit form +let redirectEdit (model: EditRedirectRuleModel) app = [ + let url = relUrl app $"admin/settings/redirect-rules/{model.RuleId}" + h3 [] [ raw (if model.RuleId < 0 then "Add" else "Edit"); raw " Redirect Rule" ] + form [ _action url; _hxPost url; _hxTarget "body"; _method "post"; _class "container" ] [ + antiCsrf app + input [ _type "hidden"; _name "RuleId"; _value (string model.RuleId) ] + div [ _class "row" ] [ + div [ _class "col-12 col-lg-5 mb-3" ] [ + textField [ _autofocus; _required ] (nameof model.From) "From" model.From [ + span [ _class "form-text" ] [ raw "From local URL/pattern" ] + ] + ] + div [ _class "col-12 col-lg-5 mb-3" ] [ + textField [ _required ] (nameof model.To) "To" model.To [ + span [ _class "form-text" ] [ raw "To URL/pattern" ] + ] + ] + div [ _class "col-12 col-lg-2 mb-3" ] [ + checkboxSwitch [] (nameof model.IsRegex) "Use RegEx" model.IsRegex [] + ] + ] + if model.RuleId < 0 then + div [ _class "row mb-3" ] [ + div [ _class "col-12 text-center" ] [ + label [ _class "me-1" ] [ raw "Add Rule" ] + div [ _class "btn-group btn-group-sm"; _roleGroup; _ariaLabel "New rule placement button group" ] [ + input [ _type "radio"; _name "InsertAtTop"; _id "at_top"; _class "btn-check"; _value "true" ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "at_top" ] [ raw "Top" ] + input [ _type "radio"; _name "InsertAtTop"; _id "at_bot"; _class "btn-check"; _value "false" + _checked ] + label [ _class "btn btn-sm btn-outline-secondary"; _for "at_bot" ] [ raw "Bottom" ] + ] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + saveButton; raw "   " + a [ _href (relUrl app "admin/settings/redirect-rules"); _class "btn btn-sm btn-secondary ms-3" ] [ + raw "Cancel" + ] + ] + ] + ] +] + + +/// The list of current redirect rules +let redirectList (model: RedirectRule list) app = [ + // Generate the detail for a redirect rule + let ruleDetail idx (rule: RedirectRule) = + let ruleId = $"rule_{idx}" + div [ _class "row mwl-table-detail"; _id ruleId ] [ + div [ _class "col-5 no-wrap" ] [ + txt rule.From; br [] + small [] [ + let ruleUrl = relUrl app $"admin/settings/redirect-rules/{idx}" + a [ _href ruleUrl; _hxTarget $"#{ruleId}"; _hxSwap $"{HxSwap.InnerHtml} show:#{ruleId}:top" ] [ + raw "Edit" + ] + if idx > 0 then + actionSpacer; a [ _href $"{ruleUrl}/up"; _hxPost $"{ruleUrl}/up" ] [ raw "Move Up" ] + if idx <> model.Length - 1 then + actionSpacer; a [ _href $"{ruleUrl}/down"; _hxPost $"{ruleUrl}/down" ] [ raw "Move Down" ] + actionSpacer + a [ _class "text-danger"; _href ruleUrl; _hxDelete ruleUrl + _hxConfirm "Are you sure you want to delete this redirect rule?" ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-5" ] [ txt rule.To ] + div [ _class "col-2 text-center" ] [ yesOrNo rule.IsRegex ] + ] + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + p [ _class "mb-3" ] [ + a [ _href (relUrl app "admin/settings") ] [ raw "« Back to Settings" ] + ] + div [ _class "container" ] [ + p [] [ + a [ _href (relUrl app "admin/settings/redirect-rules/-1"); _class "btn btn-primary btn-sm mb-3" + _hxTarget "#rule_new" ] [ + raw "Add Redirect Rule" + ] + ] + if List.isEmpty model then + div [ _id "rule_new" ] [ + p [ _class "text-muted text-center fst-italic" ] [ + raw "This web log has no redirect rules defined" + ] + ] + else + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-5" ] [ raw "From" ] + div [ _class "col-5" ] [ raw "To" ] + div [ _class "col-2 text-center" ] [ raw "RegEx?" ] + ] + ] + div [ _class "row mwl-table-detail"; _id "rule_new" ] [] + form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ + antiCsrf app; yield! List.mapi ruleDetail model + ] + ] + p [ _class "mt-3 text-muted fst-italic text-center" ] [ + raw "This is an advanced feature; please " + a [ _href "https://bitbadger.solutions/open-source/myweblog/advanced.html#redirect-rules" + _target "_blank" ] [ + raw "read and understand the documentation on this feature" + ] + raw " before adding rules." + ] + ] +] + + +/// Edit a tag mapping +let tagMapEdit (model: EditTagMapModel) app = [ + h5 [ _class "my-3" ] [ txt app.PageTitle ] + form [ _hxPost (relUrl app "admin/settings/tag-mapping/save"); _method "post"; _class "container" + _hxTarget "#tagList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ + antiCsrf app + input [ _type "hidden"; _name "Id"; _value model.Id ] + div [ _class "row mb-3" ] [ + div [ _class "col-6 col-lg-4 offset-lg-2" ] [ + textField [ _autofocus; _required ] (nameof model.Tag) "Tag" model.Tag [] + ] + div [ _class "col-6 col-lg-4" ] [ + textField [ _required ] (nameof model.UrlValue) "URL Value" model.UrlValue [] + ] + ] + div [ _class "row mb-3" ] [ + div [ _class "col text-center" ] [ + saveButton; raw "   " + a [ _href (relUrl app "admin/settings/tag-mappings"); _class "btn btn-sm btn-secondary ms-3" ] [ + raw "Cancel" + ] + ] + ] + ] +] + + +/// Display a list of the web log's current tag mappings +let tagMapList (model: TagMap list) app = + let tagMapDetail (map: TagMap) = + let url = relUrl app $"admin/settings/tag-mapping/{map.Id}" + div [ _class "row mwl-table-detail"; _id $"tag_{map.Id}" ] [ + div [ _class "col no-wrap" ] [ + txt map.Tag; br [] + small [] [ + a [ _href $"{url}/edit"; _hxTarget $"#tag_{map.Id}" + _hxSwap $"{HxSwap.InnerHtml} show:#tag_{map.Id}:top" ] [ + raw "Edit" + ]; actionSpacer + a [ _href url; _hxDelete url; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the mapping for “{map.Tag}”? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col" ] [ txt map.UrlValue ] + ] + div [ _id "tagList"; _class "container" ] [ + if List.isEmpty model then + div [ _id "tag_new" ] [ + p [ _class "text-muted text-center fst-italic" ] [ raw "This web log has no tag mappings" ] + ] + else + div [ _class "container g-0" ] [ + div [ _class "row mwl-table-heading" ] [ + div [ _class "col" ] [ raw "Tag" ] + div [ _class "col" ] [ raw "URL Value" ] + ] + ] + form [ _method "post"; _class "container g-0"; _hxTarget "#tagList"; _hxSwap HxSwap.OuterHtml ] [ + antiCsrf app + div [ _class "row mwl-table-detail"; _id "tag_new" ] [] + yield! List.map tagMapDetail model + ] + ] + |> List.singleton + + +/// The list of uploaded files for a web log +let uploadList (model: DisplayUpload seq) app = [ + let webLogBase = $"upload/{app.WebLog.Slug}/" + let relativeBase = relUrl app $"upload/{app.WebLog.Slug}/" + let absoluteBase = app.WebLog.AbsoluteUrl(Permalink webLogBase) + let uploadDetail (upload: DisplayUpload) = + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-6" ] [ + let badgeClass = if upload.Source = string Disk then "secondary" else "primary" + let pathAndName = $"{upload.Path}{upload.Name}" + span [ _class $"badge bg-{badgeClass} text-uppercase float-end mt-1" ] [ raw upload.Source ] + raw upload.Name; br [] + small [] [ + a [ _href $"{relativeBase}{pathAndName}"; _target "_blank" ] [ raw "View File" ] + actionSpacer; span [ _class "text-muted" ] [ raw "Copy " ] + a [ _href $"{absoluteBase}{pathAndName}"; _hxNoBoost + _onclick $"return Admin.copyText('{absoluteBase}{pathAndName}', this)" ] [ + raw "Absolute" + ] + span [ _class "text-muted" ] [ raw " | " ] + a [ _href $"{relativeBase}{pathAndName}"; _hxNoBoost + _onclick $"return Admin.copyText('{relativeBase}{pathAndName}', this)" ] [ + raw "Relative" + ] + if app.WebLog.ExtraPath <> "" then + span [ _class "text-muted" ] [ raw " | " ] + a [ _href $"{webLogBase}{pathAndName}"; _hxNoBoost + _onclick $"return Admin.copyText('/{webLogBase}{pathAndName}', this)" ] [ + raw "For Post" + ] + span [ _class "text-muted" ] [ raw " Link" ] + if app.IsWebLogAdmin then + actionSpacer + let deleteUrl = + if upload.Source = string "Disk" then $"admin/upload/disk/{pathAndName}" + else $"admin/upload/{upload.Id}" + |> relUrl app + a [ _href deleteUrl; _hxDelete deleteUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete {upload.Name}? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-3" ] [ raw upload.Path ] + div [ _class "col-3" ] [ + raw (match upload.UpdatedOn with Some updated -> updated.ToString "yyyy-MM-dd/HH:mm" | None -> "--") + ] + ] + + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + a [ _href (relUrl app "admin/upload/new"); _class "btn btn-primary btn-sm mb-3" ] [ raw "Upload a New File" ] + form [ _method "post"; _class "container"; _hxTarget "body" ] [ + antiCsrf app + div [ _class "row" ] [ + div [ _class "col text-center" ] [ + em [ _class "text-muted" ] [ raw "Uploaded files served from" ]; br []; raw relativeBase + ] + ] + if Seq.isEmpty model then + div [ _class "row" ] [ + div [ _class "col text-muted fst-italic text-center" ] [ + br []; raw "This web log has uploaded files" + ] + ] + else + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-6" ] [ raw "File Name" ] + div [ _class "col-3" ] [ raw "Path" ] + div [ _class "col-3" ] [ raw "File Date/Time" ] + ] + yield! model |> Seq.map uploadDetail + ] + ] +] + + +/// Form to upload a new file +let uploadNew app = [ + h2 [ _class "my-3" ] [ raw app.PageTitle ] + article [] [ + form [ _action (relUrl app "admin/upload/save"); _method "post"; _class "container" + _enctype "multipart/form-data"; _hxNoBoost ] [ + antiCsrf app + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 pb-3" ] [ + div [ _class "form-floating" ] [ + input [ _type "file"; _id "file"; _name "File"; _class "form-control"; _placeholder "File" + _required ] + label [ _for "file" ] [ raw "File to Upload" ] + ] + ] + div [ _class "col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around" ] [ + raw "Destination"; br [] + div [ _class "btn-group"; _roleGroup; _ariaLabel "Upload destination button group" ] [ + input [ _type "radio"; _name "Destination"; _id "destination_db"; _class "btn-check" + _value (string Database); if app.WebLog.Uploads = Database then _checked ] + label [ _class "btn btn-outline-primary"; _for "destination_db" ] [ raw (string Database) ] + input [ _type "radio"; _name "Destination"; _id "destination_disk"; _class "btn-check" + _value (string Disk); if app.WebLog.Uploads= Disk then _checked ] + label [ _class "btn btn-outline-secondary"; _for "destination_disk" ] [ raw "Disk" ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Upload File" ] + ] + ] + ] + ] +] + + +/// Web log settings page +let webLogSettings + (model: SettingsModel) (themes: Theme list) (pages: Page list) (uploads: UploadDestination list) + (rss: EditRssModel) (feeds: DisplayCustomFeed list) app = [ + h2 [ _class "my-3" ] [ txt app.WebLog.Name; raw " Settings" ] + article [] [ + p [ _class "text-muted" ] [ + raw "Go to: "; a [ _href "#users" ] [ raw "Users" ]; raw " • " + a [ _href "#rss-settings" ] [ raw "RSS Settings" ]; raw " • " + a [ _href "#tag-mappings" ] [ raw "Tag Mappings" ]; raw " • " + a [ _href (relUrl app "admin/settings/redirect-rules") ] [ raw "Redirect Rules" ] + ] + fieldset [ _class "container mb-3" ] [ + legend [] [ raw "Web Log Settings" ] + form [ _action (relUrl app "admin/settings"); _method "post" ] [ + antiCsrf app + div [ _class "container g-0" ] [ + div [ _class "row" ] [ + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [ _required; _autofocus ] (nameof model.Name) "Name" model.Name [] + ] + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [ _required ] (nameof model.Slug) "Slug" model.Slug [ + span [ _class "form-text" ] [ + span [ _class "badge rounded-pill bg-warning text-dark" ] [ raw "WARNING" ] + raw " changing this value may break links (" + a [ _href "https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings" + _target "_blank" ] [ + raw "more" + ]; raw ")" + ] + ] + ] + div [ _class "col-12 col-md-6 col-xl-4 pb-3" ] [ + textField [] (nameof model.Subtitle) "Subtitle" model.Subtitle [] + ] + div [ _class "col-12 col-md-6 col-xl-4 offset-xl-1 pb-3" ] [ + selectField [ _required ] (nameof model.ThemeId) "Theme" model.ThemeId themes + (fun t -> string t.Id) (fun t -> $"{t.Name} (v{t.Version})") [] + ] + div [ _class "col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3" ] [ + selectField [ _required ] (nameof model.DefaultPage) "Default Page" model.DefaultPage pages + (fun p -> string p.Id) (_.Title) [] + ] + div [ _class "col-12 col-md-4 col-xl-2 pb-3" ] [ + numberField [ _required; _min "0"; _max "50" ] (nameof model.PostsPerPage) "Posts per Page" + model.PostsPerPage [] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-md-4 col-xl-3 offset-xl-2 pb-3" ] [ + textField [ _required ] (nameof model.TimeZone) "Time Zone" model.TimeZone [] + ] + div [ _class "col-12 col-md-4 col-xl-2" ] [ + checkboxSwitch [] (nameof model.AutoHtmx) "Auto-Load htmx" model.AutoHtmx [] + span [ _class "form-text fst-italic" ] [ + a [ _href "https://htmx.org"; _target "_blank"; _relNoOpener ] [ raw "What is this?" ] + ] + ] + div [ _class "col-12 col-md-4 col-xl-3 pb-3" ] [ + selectField [] (nameof model.Uploads) "Default Upload Destination" model.Uploads uploads + string string [] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] + ] + ] + ] + ] + ] + fieldset [ _id "users"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "Users" ] + span [ _hxGet (relUrl app "admin/settings/users"); _hxTrigger HxTrigger.Load; _hxSwap HxSwap.OuterHtml ] [] + ] + fieldset [ _id "rss-settings"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "RSS Settings" ] + form [ _action (relUrl app "admin/settings/rss"); _method "post"; _class "container g-0" ] [ + antiCsrf app + div [ _class "row pb-3" ] [ + div [ _class "col col-xl-8 offset-xl-2" ] [ + fieldset [ _class "d-flex justify-content-evenly flex-row" ] [ + legend [] [ raw "Feeds Enabled" ] + checkboxSwitch [] (nameof rss.IsFeedEnabled) "All Posts" rss.IsFeedEnabled [] + checkboxSwitch [] (nameof rss.IsCategoryEnabled) "Posts by Category" rss.IsCategoryEnabled + [] + checkboxSwitch [] (nameof rss.IsTagEnabled) "Posts by Tag" rss.IsTagEnabled [] + ] + ] + ] + div [ _class "row" ] [ + div [ _class "col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3" ] [ + textField [] (nameof rss.FeedName) "Feed File Name" rss.FeedName [ + span [ _class "form-text" ] [ raw "Default is "; code [] [ raw "feed.xml" ] ] + ] + ] + div [ _class "col-12 col-sm-6 col-md-4 col-xl-2 pb-3" ] [ + numberField [ _required; _min "0" ] (nameof rss.ItemsInFeed) "Items in Feed" rss.ItemsInFeed [ + span [ _class "form-text" ] [ + raw "Set to “0” to use “Posts per Page” setting (" + raw (string app.WebLog.PostsPerPage); raw ")" + ] + ] + ] + div [ _class "col-12 col-md-5 col-xl-4 pb-3" ] [ + textField [] (nameof rss.Copyright) "Copyright String" rss.Copyright [ + span [ _class "form-text" ] [ + raw "Can be a " + a [ _href "https://creativecommons.org/share-your-work/"; _target "_blank" + _relNoOpener ] [ + raw "Creative Commons license string" + ] + ] + ] + ] + ] + div [ _class "row pb-3" ] [ + div [ _class "col text-center" ] [ + button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ] + ] + ] + ] + fieldset [ _class "container mb-3 pb-0" ] [ + legend [] [ raw "Custom Feeds" ] + a [ _class "btn btn-sm btn-secondary"; _href (relUrl app "admin/settings/rss/new/edit") ] [ + raw "Add a New Custom Feed" + ] + if feeds.Length = 0 then + p [ _class "text-muted fst-italic text-center" ] [ raw "No custom feeds defined" ] + else + form [ _method "post"; _class "container g-0"; _hxTarget "body" ] [ + antiCsrf app + div [ _class "row mwl-table-heading" ] [ + div [ _class "col-12 col-md-6" ] [ + span [ _class "d-md-none" ] [ raw "Feed" ] + span [ _class "d-none d-md-inline" ] [ raw "Source" ] + ] + div [ _class $"col-12 col-md-6 d-none d-md-inline-block" ] [ raw "Relative Path" ] + ] + for feed in feeds do + div [ _class "row mwl-table-detail" ] [ + div [ _class "col-12 col-md-6" ] [ + txt feed.Source + if feed.IsPodcast then + raw "   "; span [ _class "badge bg-primary" ] [ raw "PODCAST" ] + br [] + small [] [ + let feedUrl = relUrl app $"admin/settings/rss/{feed.Id}" + a [ _href (relUrl app feed.Path); _target "_blank" ] [ raw "View Feed" ] + actionSpacer + a [ _href $"{feedUrl}/edit" ] [ raw "Edit" ]; actionSpacer + a [ _href feedUrl; _hxDelete feedUrl; _class "text-danger" + _hxConfirm $"Are you sure you want to delete the custom RSS feed based on {feed.Source}? This action cannot be undone." ] [ + raw "Delete" + ] + ] + ] + div [ _class "col-12 col-md-6" ] [ + small [ _class "d-md-none" ] [ raw "Served at "; txt feed.Path ] + span [ _class "d-none d-md-inline" ] [ txt feed.Path ] + ] + ] + ] + ] + ] + fieldset [ _id "tag-mappings"; _class "container mb-3 pb-0" ] [ + legend [] [ raw "Tag Mappings" ] + a [ _href (relUrl app "admin/settings/tag-mapping/new/edit"); _class "btn btn-primary btn-sm mb-3" + _hxTarget "#tag_new" ] [ + raw "Add a New Tag Mapping" + ] + span [ _hxGet (relUrl app "admin/settings/tag-mappings"); _hxTrigger HxTrigger.Load + _hxSwap HxSwap.OuterHtml ] [] + ] + ] +] diff --git a/src/admin-theme/admin-dashboard.liquid b/src/admin-theme/admin-dashboard.liquid deleted file mode 100644 index 4d8ac30..0000000 --- a/src/admin-theme/admin-dashboard.liquid +++ /dev/null @@ -1,92 +0,0 @@ -

{{ page_title }}

-
-
- Themes - -
-
- {%- assign cache_base_url = "admin/cache/" -%} - Caches -
-
-

- myWebLog uses a few caches to ensure that it serves pages as fast as possible. - (more information) -

-
-
-
-
-
Web Logs
-
-
- These caches include the page list and categories for each web log -
- {%- assign web_log_base_url = cache_base_url | append: "web-log/" -%} -
- - -
-
Web Log
-
- {%- for web_log in web_logs %} -
-
- {{ web_log[1] }}
- - {{ web_log[2] }}
- {%- assign refresh_url = web_log_base_url | append: web_log[0] | append: "/refresh" | relative_link -%} - Refresh -
-
-
- {%- endfor %} -
-
-
-
-
-
-
Themes
-
-
- The theme template cache is filled on demand as pages are displayed; refreshing a theme with no cached - templates will still refresh its asset cache -
- {%- assign theme_base_url = cache_base_url | append: "theme/" -%} -
- - -
-
Theme
-
Cached
-
- {%- for theme in cached_themes %} - {% unless theme[0] == "admin" %} -
-
- {{ theme[1] }}
- - {{ theme[0] }} • - {%- assign refresh_url = theme_base_url | append: theme[0] | append: "/refresh" | relative_link -%} - Refresh - -
-
{{ theme[2] }}
-
- {% endunless %} - {%- endfor %} -
-
-
-
-
-
-
diff --git a/src/admin-theme/category-edit.liquid b/src/admin-theme/category-edit.liquid deleted file mode 100644 index 3cb44ef..0000000 --- a/src/admin-theme/category-edit.liquid +++ /dev/null @@ -1,52 +0,0 @@ -
-
{{ page_title }}
-
- - -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
-
- - Cancel -
-
-
-
diff --git a/src/admin-theme/category-list-body.liquid b/src/admin-theme/category-list-body.liquid deleted file mode 100644 index 83984a9..0000000 --- a/src/admin-theme/category-list-body.liquid +++ /dev/null @@ -1,57 +0,0 @@ -
-
-
- {%- assign cat_count = categories | size -%} - {% if cat_count > 0 %} - {%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%} - {%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%} -
-
-
Category; Description
-
Description
-
-
-
- -
- {% for cat in categories -%} -
-
- {%- if cat.parent_names %} - {% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %} - {%- endif %} - {{ cat.name }}
- - {%- assign cat_url_base = "admin/category/" | append: cat.id -%} - {%- if cat.post_count > 0 %} - - View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%} - - - {%- endif %} - - Edit - - - {%- assign cat_del_link = cat_url_base | append: "/delete" | relative_link -%} - - Delete - - -
-
- {%- if cat.description %}{{ cat.description.value }}{% else %}none{% endif %} -
-
- {%- endfor %} -
- {%- else -%} -
-

This web log has no categories defined

-
- {%- endif %} -
-
-
diff --git a/src/admin-theme/category-list.liquid b/src/admin-theme/category-list.liquid deleted file mode 100644 index 689c41c..0000000 --- a/src/admin-theme/category-list.liquid +++ /dev/null @@ -1,8 +0,0 @@ -

{{ page_title }}

- diff --git a/src/admin-theme/upload-list.liquid b/src/admin-theme/upload-list.liquid deleted file mode 100644 index 24c5a9d..0000000 --- a/src/admin-theme/upload-list.liquid +++ /dev/null @@ -1,75 +0,0 @@ -

{{ page_title }}

-
- {%- capture base_url %}{{ "" | relative_link }}{% endcapture -%} - {%- capture upload_path %}upload/{{ web_log.slug }}/{% endcapture -%} - {%- capture upload_base %}{{ base_url }}{{ upload_path }}{% endcapture -%} - Upload a New File -
- -
-
Uploaded files served from
{{ upload_base }}
-
- {%- assign file_count = files | size -%} - {%- if file_count > 0 %} -
-
File Name
-
Path
-
File Date/Time
-
- {% for file in files %} -
-
- {%- capture badge_class -%} - {%- if file.source == "Disk" %}secondary{% else %}primary{% endif -%} - {%- endcapture -%} - {%- assign path_and_name = file.path | append: file.name -%} - {%- assign blog_rel = upload_path | append: path_and_name -%} - {{ file.source }} - {{ file.name }}
- - View File - • Copy - - Absolute - - | - - Relative - - {%- unless base_url == "/" %} - | - - For Post - - {%- endunless %} - Link - {% if is_web_log_admin %} - - {%- capture delete_url -%} - {%- if file.source == "Disk" -%} - admin/upload/delete/{{ path_and_name }} - {%- else -%} - admin/upload/{{ file.id }}/delete - {%- endif -%} - {%- endcapture -%} - Delete - {% endif %} - -
-
{{ file.path }}
-
- {% if file.updated_on %}{{ file.updated_on.value | date: "yyyy-MM-dd/HH:mm" }}{% else %}--{% endif %} -
-
- {% endfor %} - {%- else -%} -
-

This web log has uploaded files
-
- {%- endif %} -
-
diff --git a/src/admin-theme/upload-new.liquid b/src/admin-theme/upload-new.liquid deleted file mode 100644 index fcd9e8d..0000000 --- a/src/admin-theme/upload-new.liquid +++ /dev/null @@ -1,29 +0,0 @@ -

{{ page_title }}

-
-
- -
-
-
- - -
-
-
- Destination
-
- - - - -
-
-
-
-
-
-
-