Version 2.1 #41

Merged
danieljsummers merged 123 commits from version-2.1 into main 2024-03-27 00:13:28 +00:00
20 changed files with 387 additions and 417 deletions
Showing only changes of commit b99cd5b94b - Show all commits

View File

@ -204,26 +204,6 @@ type DisplayPage = {
} }
/// Information about a revision used for display
[<NoComparison; NoEquality>]
type DisplayRevision = {
/// The as-of date/time for the revision
AsOf: DateTime
/// The as-of date/time for the revision in the web log's local time zone
AsOfLocal: DateTime
/// The format of the text of the revision
Format: string
} with
/// Create a display revision from an actual revision
static member FromRevision (webLog: WebLog) (rev : Revision) =
{ AsOf = rev.AsOf.ToDateTimeUtc()
AsOfLocal = webLog.LocalTime rev.AsOf
Format = rev.Text.SourceType }
open System.IO open System.IO
/// Information about a theme used for display /// Information about a theme used for display
@ -1180,22 +1160,22 @@ type ManageRevisionsModel = {
CurrentTitle: string CurrentTitle: string
/// The revisions for the page or post /// The revisions for the page or post
Revisions: DisplayRevision array Revisions: Revision list
} with } with
/// Create a revision model from a page /// Create a revision model from a page
static member FromPage webLog (page: Page) = static member FromPage (page: Page) =
{ Id = string page.Id { Id = string page.Id
Entity = "page" Entity = "page"
CurrentTitle = page.Title CurrentTitle = page.Title
Revisions = page.Revisions |> List.map (DisplayRevision.FromRevision webLog) |> Array.ofList } Revisions = page.Revisions }
/// Create a revision model from a post /// Create a revision model from a post
static member FromPost webLog (post: Post) = static member FromPost (post: Post) =
{ Id = string post.Id { Id = string post.Id
Entity = "post" Entity = "post"
CurrentTitle = post.Title CurrentTitle = post.Title
Revisions = post.Revisions |> List.map (DisplayRevision.FromRevision webLog) |> Array.ofList } Revisions = post.Revisions }
/// View model for posts in a list /// View model for posts in a list

View File

@ -179,17 +179,6 @@ let displayPageTests = testList "DisplayPage" [
] ]
] ]
/// Unit tests for the DisplayRevision type
let displayRevisionTests = test "DisplayRevision.FromRevision succeeds" {
let model =
DisplayRevision.FromRevision
{ WebLog.Empty with TimeZone = "Etc/GMT+1" }
{ Text = Html "howdy"; AsOf = Noda.epoch }
Expect.equal model.AsOf (Noda.epoch.ToDateTimeUtc()) "AsOf not filled properly"
Expect.equal model.AsOfLocal ((Noda.epoch - Duration.FromHours 1).ToDateTimeUtc()) "AsOfLocal not filled properly"
Expect.equal model.Format "HTML" "Format not filled properly"
}
open System.IO open System.IO
/// Unit tests for the DisplayTheme type /// Unit tests for the DisplayTheme type
@ -1346,7 +1335,6 @@ let all = testList "ViewModels" [
displayChapterTests displayChapterTests
displayCustomFeedTests displayCustomFeedTests
displayPageTests displayPageTests
displayRevisionTests
displayThemeTests displayThemeTests
displayUploadTests displayUploadTests
displayUserTests displayUserTests

View File

@ -228,16 +228,10 @@ let register () =
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page> typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
typeof<RedirectRule>; typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog> typeof<RedirectRule>; typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
// View models // View models
typeof<AppViewContext>; typeof<DashboardModel>; typeof<DisplayCategory> typeof<AppViewContext>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
typeof<DisplayChapter>; typeof<DisplayCustomFeed>; typeof<DisplayPage> typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>; typeof<EditCategoryModel>
typeof<DisplayRevision>; typeof<DisplayTheme>; typeof<DisplayUpload> typeof<EditCustomFeedModel>; typeof<EditPageModel>; typeof<EditPostModel>; typeof<EditRssModel>
typeof<DisplayUser>; typeof<EditCategoryModel>; typeof<EditChapterModel> typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>
typeof<EditPostModel>; typeof<EditRedirectRuleModel>; typeof<EditRssModel>
typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
typeof<ManageChaptersModel>; typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>
typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>
typeof<UserMessage>
// Framework types // Framework types
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair> typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list> typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>

View File

@ -27,45 +27,35 @@ module Dashboard =
ListedPages = listed ListedPages = listed
Categories = cats Categories = cats
TopLevelCategories = topCats } TopLevelCategories = topCats }
return! adminPage "Dashboard" false (Views.Admin.dashboard model) next ctx return! adminPage "Dashboard" false next ctx (Views.Admin.dashboard model)
} }
// GET /admin/administration // GET /admin/administration
let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
match! TemplateCache.get adminTheme "theme-list-body" ctx.Data with let! themes = ctx.Data.Theme.All()
| Ok bodyTemplate -> let cachedTemplates = TemplateCache.allNames ()
let! themes = ctx.Data.Theme.All() return!
let cachedTemplates = TemplateCache.allNames () hashForPage "myWebLog Administration"
let! hash = |> withAntiCsrf ctx
hashForPage "myWebLog Administration" |> addToHash "cached_themes" (
|> withAntiCsrf ctx themes
|> addToHash "themes" ( |> Seq.ofList
themes |> Seq.map (fun it -> [|
|> List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) string it.Id
|> Array.ofList) it.Name
|> addToHash "cached_themes" ( cachedTemplates
themes |> List.filter _.StartsWith(string it.Id)
|> Seq.ofList |> List.length
|> Seq.map (fun it -> [| |> string
string it.Id |])
it.Name |> Array.ofSeq)
cachedTemplates |> addToHash "web_logs" (
|> List.filter _.StartsWith(string it.Id) WebLogCache.all ()
|> List.length |> Seq.ofList
|> string |> Seq.sortBy _.Name
|]) |> Seq.map (fun it -> [| string it.Id; it.Name; it.UrlBase |])
|> Array.ofSeq) |> Array.ofSeq)
|> addToHash "web_logs" ( |> adminView "admin-dashboard" next ctx
WebLogCache.all ()
|> Seq.ofList
|> Seq.sortBy _.Name
|> Seq.map (fun it -> [| string it.Id; it.Name; it.UrlBase |])
|> Array.ofSeq)
|> addViewContext ctx
return!
addToHash "theme_list" (bodyTemplate.Render hash) hash
|> adminView "admin-dashboard" next ctx
| Error message -> return! Error.server message next ctx
} }
/// Redirect the user to the admin dashboard /// Redirect the user to the admin dashboard
@ -215,11 +205,11 @@ module RedirectRules =
// GET /admin/settings/redirect-rules // GET /admin/settings/redirect-rules
let all : HttpHandler = fun next ctx -> let all : HttpHandler = fun next ctx ->
adminPage "Redirect Rules" true (Views.Admin.redirectList ctx.WebLog.RedirectRules) next ctx adminPage "Redirect Rules" true next ctx (Views.Admin.redirectList ctx.WebLog.RedirectRules)
// GET /admin/settings/redirect-rules/[index] // GET /admin/settings/redirect-rules/[index]
let edit idx : HttpHandler = fun next ctx -> let edit idx : HttpHandler = fun next ctx ->
let titleAndModel = let titleAndView =
if idx = -1 then if idx = -1 then
Some ("Add", Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) Some ("Add", Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty))
else else
@ -228,8 +218,8 @@ module RedirectRules =
None None
else else
Some ("Edit", (Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules)))) Some ("Edit", (Views.Admin.redirectEdit (EditRedirectRuleModel.FromRule idx (List.item idx rules))))
match titleAndModel with match titleAndView with
| Some (title, model) -> adminBarePage $"{title} Redirect Rule" true model next ctx | Some (title, view) -> adminBarePage $"{title} Redirect Rule" true next ctx view
| None -> Error.notFound next ctx | None -> Error.notFound next ctx
/// Update the web log's redirect rules in the database, the request web log, and the web log cache /// Update the web log's redirect rules in the database, the request web log, and the web log cache
@ -294,7 +284,7 @@ module TagMapping =
// GET /admin/settings/tag-mappings // GET /admin/settings/tag-mappings
let all : HttpHandler = fun next ctx -> task { let all : HttpHandler = fun next ctx -> task {
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
return! adminBarePage "Tag Mapping List" true (Views.Admin.tagMapList mappings) next ctx return! adminBarePage "Tag Mapping List" true next ctx (Views.Admin.tagMapList mappings)
} }
// GET /admin/settings/tag-mapping/{id}/edit // GET /admin/settings/tag-mapping/{id}/edit
@ -306,9 +296,8 @@ module TagMapping =
match! tagMap with match! tagMap with
| Some tm -> | Some tm ->
return! return!
adminBarePage Views.Admin.tagMapEdit (EditTagMapModel.FromMapping tm)
(if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") true |> adminBarePage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag") true next ctx
(Views.Admin.tagMapEdit (EditTagMapModel.FromMapping tm)) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -349,17 +338,13 @@ module Theme =
let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let! themes = ctx.Data.Theme.All () let! themes = ctx.Data.Theme.All ()
return! return!
hashForPage "Themes" Views.Admin.themeList (List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) themes)
|> withAntiCsrf ctx |> adminBarePage "Themes" true next ctx
|> addToHash "themes" (themes |> List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) |> Array.ofList)
|> adminBareView "theme-list-body" next ctx
} }
// GET /admin/theme/new // GET /admin/theme/new
let add : HttpHandler = requireAccess Administrator >=> fun next ctx -> let add : HttpHandler = requireAccess Administrator >=> fun next ctx ->
hashForPage "Upload a Theme File" adminBarePage "Upload a Theme File" true next ctx Views.Admin.themeUpload
|> withAntiCsrf ctx
|> adminBareView "theme-upload" next ctx
/// Update the name and version for a theme based on the version.txt file, if present /// Update the name and version for a theme based on the version.txt file, if present
let private updateNameAndVersion (theme: Theme) (zip: ZipArchive) = backgroundTask { let private updateNameAndVersion (theme: Theme) (zip: ZipArchive) = backgroundTask {

View File

@ -282,8 +282,9 @@ module Error =
let notAuthorized : HttpHandler = fun next ctx -> let notAuthorized : HttpHandler = fun next ctx ->
if ctx.Request.Method = "GET" then if ctx.Request.Method = "GET" then
let redirectUrl = $"user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" let redirectUrl = $"user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectToGet redirectUrl) next ctx (next, ctx)
else redirectToGet redirectUrl next ctx ||> if isHtmx ctx then withHxRedirect redirectUrl >=> withHxRetarget "body" >=> redirectToGet redirectUrl
else redirectToGet redirectUrl
else else
if isHtmx ctx then if isHtmx ctx then
let messages = [| let messages = [|
@ -370,7 +371,7 @@ let adminBareView template =
bareForTheme adminTheme template bareForTheme adminTheme template
/// Display a page for an admin endpoint /// Display a page for an admin endpoint
let adminPage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) : HttpHandler = fun next ctx -> task { let adminPage pageTitle includeCsrf next ctx (content: AppViewContext -> XmlNode list) = task {
let! messages = getCurrentMessages ctx let! messages = getCurrentMessages ctx
let appCtx = generateViewContext pageTitle messages includeCsrf ctx let appCtx = generateViewContext pageTitle messages includeCsrf ctx
let layout = if isHtmx ctx then Layout.partial else Layout.full let layout = if isHtmx ctx then Layout.partial else Layout.full
@ -378,7 +379,7 @@ let adminPage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) :
} }
/// Display a bare page for an admin endpoint /// Display a bare page for an admin endpoint
let adminBarePage pageTitle includeCsrf (content: AppViewContext -> XmlNode list) : HttpHandler = fun next ctx -> task { let adminBarePage pageTitle includeCsrf next ctx (content: AppViewContext -> XmlNode list) = task {
let! messages = getCurrentMessages ctx let! messages = getCurrentMessages ctx
let appCtx = generateViewContext pageTitle messages includeCsrf ctx let appCtx = generateViewContext pageTitle messages includeCsrf ctx
return! return!
@ -471,13 +472,12 @@ let getCategoryIds slug ctx =
|> Seq.map (fun c -> CategoryId c.Id) |> Seq.map (fun c -> CategoryId c.Id)
|> List.ofSeq |> List.ofSeq
open System
open System.Globalization
open NodaTime open NodaTime
/// Parse a date/time to UTC /// Parse a date/time to UTC
let parseToUtc (date: string) = let parseToUtc (date: string) : Instant =
Instant.FromDateTimeUtc(DateTime.Parse(date, null, DateTimeStyles.AdjustToUniversal)) let result = roundTrip.Parse date
if result.Success then result.Value else raise result.Exception
open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging

View File

@ -66,10 +66,9 @@ let editPermalinks pgId : HttpHandler = requireAccess Author >=> fun next ctx ->
match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with
| Some pg when canEdit pg.AuthorId ctx -> | Some pg when canEdit pg.AuthorId ctx ->
return! return!
hashForPage "Manage Prior Permalinks" ManagePermalinksModel.FromPage pg
|> withAntiCsrf ctx |> Views.Helpers.managePermalinks
|> addToHash ViewContext.Model (ManagePermalinksModel.FromPage pg) |> adminPage "Manage Prior Permalinks" true next ctx
|> adminView "permalinks" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -95,15 +94,14 @@ let editRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx ->
match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with
| Some pg when canEdit pg.AuthorId ctx -> | Some pg when canEdit pg.AuthorId ctx ->
return! return!
hashForPage "Manage Page Revisions" ManageRevisionsModel.FromPage pg
|> withAntiCsrf ctx |> Views.Helpers.manageRevisions
|> addToHash ViewContext.Model (ManageRevisionsModel.FromPage ctx.WebLog pg) |> adminPage "Manage Page Revisions" true next ctx
|> adminView "revisions" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET /admin/page/{id}/revisions/purge // DELETE /admin/page/{id}/revisions
let purgeRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let purgeRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
match! data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with match! data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with
@ -158,7 +156,7 @@ let restoreRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun
| _, None -> return! Error.notFound next ctx | _, None -> return! Error.notFound next ctx
} }
// POST /admin/page/{id}/revision/{revision-date}/delete // DELETE /admin/page/{id}/revision/{revision-date}
let deleteRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { let deleteRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! findPageRevision pgId revDate ctx with match! findPageRevision pgId revDate ctx with
| Some pg, Some rev when canEdit pg.AuthorId ctx -> | Some pg, Some rev when canEdit pg.AuthorId ctx ->

View File

@ -254,7 +254,7 @@ let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
let! posts = data.Post.FindPageOfPosts ctx.WebLog.Id pageNbr 25 let! posts = data.Post.FindPageOfPosts ctx.WebLog.Id pageNbr 25
let! hash = preparePostList ctx.WebLog posts AdminList "" pageNbr 25 data let! hash = preparePostList ctx.WebLog posts AdminList "" pageNbr 25 data
return! adminPage "Posts" true (Views.Post.list (hash[ViewContext.Model] :?> PostDisplay)) next ctx return! adminPage "Posts" true next ctx (Views.Post.list (hash[ViewContext.Model] :?> PostDisplay))
} }
// GET /admin/post/{id}/edit // GET /admin/post/{id}/edit
@ -305,10 +305,9 @@ let editPermalinks postId : HttpHandler = requireAccess Author >=> fun next ctx
match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with
| Some post when canEdit post.AuthorId ctx -> | Some post when canEdit post.AuthorId ctx ->
return! return!
hashForPage "Manage Prior Permalinks" ManagePermalinksModel.FromPost post
|> withAntiCsrf ctx |> Views.Helpers.managePermalinks
|> addToHash ViewContext.Model (ManagePermalinksModel.FromPost post) |> adminPage "Manage Prior Permalinks" true next ctx
|> adminView "permalinks" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -334,15 +333,14 @@ let editRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx -
match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with
| Some post when canEdit post.AuthorId ctx -> | Some post when canEdit post.AuthorId ctx ->
return! return!
hashForPage "Manage Post Revisions" ManageRevisionsModel.FromPost post
|> withAntiCsrf ctx |> Views.Helpers.manageRevisions
|> addToHash ViewContext.Model (ManageRevisionsModel.FromPost ctx.WebLog post) |> adminPage "Manage Post Revisions" true next ctx
|> adminView "revisions" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET /admin/post/{id}/revisions/purge // DELETE /admin/post/{id}/revisions
let purgeRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let purgeRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
match! data.Post.FindFullById (PostId postId) ctx.WebLog.Id with match! data.Post.FindFullById (PostId postId) ctx.WebLog.Id with
@ -398,7 +396,7 @@ let restoreRevision (postId, revDate) : HttpHandler = requireAccess Author >=> f
| _, None -> return! Error.notFound next ctx | _, None -> return! Error.notFound next ctx
} }
// POST /admin/post/{id}/revision/{revision-date}/delete // DELETE /admin/post/{id}/revision/{revision-date}
let deleteRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { let deleteRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! findPostRevision postId revDate ctx with match! findPostRevision postId revDate ctx with
| Some post, Some rev when canEdit post.AuthorId ctx -> | Some post, Some rev when canEdit post.AuthorId ctx ->
@ -418,7 +416,8 @@ let manageChapters postId : HttpHandler = requireAccess Author >=> fun next ctx
&& Option.isSome post.Episode.Value.Chapters && Option.isSome post.Episode.Value.Chapters
&& canEdit post.AuthorId ctx -> && canEdit post.AuthorId ctx ->
return! return!
adminPage "Manage Chapters" true (Views.Post.chapters false (ManageChaptersModel.Create post)) next ctx Views.Post.chapters false (ManageChaptersModel.Create post)
|> adminPage "Manage Chapters" true next ctx
| Some _ | None -> return! Error.notFound next ctx | Some _ | None -> return! Error.notFound next ctx
} }
@ -437,9 +436,8 @@ let editChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex
match chapter with match chapter with
| Some chap -> | Some chap ->
return! return!
adminBarePage Views.Post.chapterEdit (EditChapterModel.FromChapter post.Id index chap)
(if index = -1 then "Add a Chapter" else "Edit Chapter") true |> adminBarePage (if index = -1 then "Add a Chapter" else "Edit Chapter") true next ctx
(Views.Post.chapterEdit (EditChapterModel.FromChapter post.Id index chap)) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
| Some _ | None -> return! Error.notFound next ctx | Some _ | None -> return! Error.notFound next ctx
} }
@ -466,9 +464,8 @@ let saveChapter (postId, index) : HttpHandler = requireAccess Author >=> fun nex
do! data.Post.Update updatedPost do! data.Post.Update updatedPost
do! addMessage ctx { UserMessage.Success with Message = "Chapter saved successfully" } do! addMessage ctx { UserMessage.Success with Message = "Chapter saved successfully" }
return! return!
adminPage Views.Post.chapterList form.AddAnother (ManageChaptersModel.Create updatedPost)
"Manage Chapters" true |> adminPage "Manage Chapters" true next ctx
(Views.Post.chapterList form.AddAnother (ManageChaptersModel.Create updatedPost)) next ctx
with with
| ex -> return! Error.server ex.Message next ctx | ex -> return! Error.server ex.Message next ctx
else return! Error.notFound next ctx else return! Error.notFound next ctx
@ -491,9 +488,8 @@ let deleteChapter (postId, index) : HttpHandler = requireAccess Author >=> fun n
do! data.Post.Update updatedPost do! data.Post.Update updatedPost
do! addMessage ctx { UserMessage.Success with Message = "Chapter deleted successfully" } do! addMessage ctx { UserMessage.Success with Message = "Chapter deleted successfully" }
return! return!
adminPage Views.Post.chapterList false (ManageChaptersModel.Create updatedPost)
"Manage Chapters" true (Views.Post.chapterList false (ManageChaptersModel.Create updatedPost)) next |> adminPage "Manage Chapters" true next ctx
ctx
else return! Error.notFound next ctx else return! Error.notFound next ctx
| Some _ | None -> return! Error.notFound next ctx | Some _ | None -> return! Error.notFound next ctx
} }

View File

@ -176,17 +176,13 @@ let router : HttpHandler = choose [
route "/save" >=> Page.save route "/save" >=> Page.save
route "/permalinks" >=> Page.savePermalinks route "/permalinks" >=> Page.savePermalinks
routef "/%s/delete" Page.delete routef "/%s/delete" Page.delete
routef "/%s/revision/%s/delete" Page.deleteRevision
routef "/%s/revision/%s/restore" Page.restoreRevision routef "/%s/revision/%s/restore" Page.restoreRevision
routef "/%s/revisions/purge" Page.purgeRevisions
]) ])
subRoute "/post" (choose [ subRoute "/post" (choose [
route "/save" >=> Post.save route "/save" >=> Post.save
route "/permalinks" >=> Post.savePermalinks route "/permalinks" >=> Post.savePermalinks
routef "/%s/chapter/%i" Post.saveChapter routef "/%s/chapter/%i" Post.saveChapter
routef "/%s/revision/%s/delete" Post.deleteRevision
routef "/%s/revision/%s/restore" Post.restoreRevision routef "/%s/revision/%s/restore" Post.restoreRevision
routef "/%s/revisions/purge" Post.purgeRevisions
]) ])
subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ subRoute "/settings" (requireAccess WebLogAdmin >=> choose [
route "" >=> Admin.WebLog.saveSettings route "" >=> Admin.WebLog.saveSettings
@ -214,9 +210,15 @@ let router : HttpHandler = choose [
]) ])
] ]
DELETE >=> validateCsrf >=> choose [ DELETE >=> validateCsrf >=> choose [
subRoute "/page" (choose [
routef "/%s/revision/%s" Page.deleteRevision
routef "/%s/revisions" Page.purgeRevisions
])
subRoute "/post" (choose [ subRoute "/post" (choose [
routef "/%s" Post.delete routef "/%s" Post.delete
routef "/%s/chapter/%i" Post.deleteChapter routef "/%s/chapter/%i" Post.deleteChapter
routef "/%s/revision/%s" Post.deleteRevision
routef "/%s/revisions" Post.purgeRevisions
]) ])
subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ subRoute "/settings" (requireAccess WebLogAdmin >=> choose [
routef "/user/%s" User.delete routef "/user/%s" User.delete

View File

@ -35,7 +35,7 @@ let logOn returnUrl : HttpHandler = fun next ctx ->
match returnUrl with match returnUrl with
| Some _ -> returnUrl | Some _ -> returnUrl
| None -> if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None | None -> if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None
adminPage "Log On" true (Views.User.logOn { LogOnModel.Empty with ReturnTo = returnTo }) next ctx adminPage "Log On" true next ctx (Views.User.logOn { LogOnModel.Empty with ReturnTo = returnTo })
open System.Security.Claims open System.Security.Claims
@ -91,12 +91,12 @@ let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?"
// GET /admin/settings/users // GET /admin/settings/users
let all : HttpHandler = fun next ctx -> task { let all : HttpHandler = fun next ctx -> task {
let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id
return! adminBarePage "User Administration" true (Views.User.userList users) next ctx return! adminBarePage "User Administration" true next ctx (Views.User.userList users)
} }
/// Show the edit user page /// Show the edit user page
let private showEdit (model: EditUserModel) : HttpHandler = fun next ctx -> let private showEdit (model: EditUserModel) : HttpHandler = fun next ctx ->
adminBarePage (if model.IsNew then "Add a New User" else "Edit User") true (Views.User.edit model) next ctx adminBarePage (if model.IsNew then "Add a New User" else "Edit User") true next ctx (Views.User.edit model)
// GET /admin/settings/user/{id}/edit // GET /admin/settings/user/{id}/edit
let edit usrId : HttpHandler = fun next ctx -> task { let edit usrId : HttpHandler = fun next ctx -> task {
@ -137,7 +137,9 @@ let delete userId : HttpHandler = fun next ctx -> task {
let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task { let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
| Some user -> | Some user ->
return! adminPage "Edit Your Information" true (Views.User.myInfo (EditMyInfoModel.FromUser user) user) next ctx return!
Views.User.myInfo (EditMyInfoModel.FromUser user) user
|> adminPage "Edit Your Information" true next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -161,9 +163,8 @@ let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
| Some user -> | Some user ->
do! addMessage ctx { UserMessage.Error with Message = "Passwords did not match; no updates made" } do! addMessage ctx { UserMessage.Error with Message = "Passwords did not match; no updates made" }
return! return!
adminPage Views.User.myInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user
"Edit Your Information" true |> adminPage "Edit Your Information" true next ctx
(Views.User.myInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -293,3 +293,91 @@ let tagMapList (model: TagMap list) app =
] ]
] ]
|> List.singleton |> List.singleton
/// Display a list of themes
let themeList (model: DisplayTheme list) app =
let themeCol = "col-12 col-md-6"
let slugCol = "d-none d-md-block col-md-3"
let tmplCol = "d-none d-md-block col-md-3"
div [ _id "theme_panel" ] [
a [ _href (relUrl app "admin/theme/new"); _class "btn btn-primary btn-sm mb-3"; _hxTarget "#theme_new" ] [
raw "Upload a New Theme"
]
div [ _class "container g-0" ] [
div [ _class "row mwl-table-heading" ] [
div [ _class themeCol ] [ raw "Theme" ]
div [ _class slugCol ] [ raw "Slug" ]
div [ _class tmplCol ] [ raw "Templates" ]
]
]
div [ _class "row mwl-table-detail"; _id "theme_new" ] []
form [ _method "post"; _id "themeList"; _class "container g-0"; _hxTarget "#theme_panel"
_hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [
antiCsrf app
for theme in model do
let url = relUrl app $"admin/theme/{theme.Id}"
div [ _class "row mwl-table-detail"; _id $"theme_{theme.Id}" ] [
div [ _class $"{themeCol} no-wrap" ] [
txt theme.Name
if theme.IsInUse then span [ _class "badge bg-primary ms-2" ] [ raw "IN USE" ]
if not theme.IsOnDisk then
span [ _class "badge bg-warning text-dark ms-2" ] [ raw "NOT ON DISK" ]
br []
small [] [
span [ _class "text-muted" ] [ txt $"v{theme.Version}" ]
if not (theme.IsInUse || theme.Id = "default") then
span [ _class "text-muted" ] [ raw " &bull; " ]
a [ _href url; _hxDelete url; _class "text-danger"
_hxConfirm $"Are you sure you want to delete the theme “{theme.Name}”? This action cannot be undone." ] [
raw "Delete"
]
span [ _class "d-md-none text-muted" ] [
br []; raw "Slug: "; txt theme.Id; raw $" &bull; {theme.TemplateCount} Templates"
]
]
]
div [ _class slugCol ] [ txt (string theme.Id) ]
div [ _class tmplCol ] [ txt (string theme.TemplateCount) ]
]
]
]
|> List.singleton
/// Form to allow a theme to be uploaded
let themeUpload app =
div [ _class "col" ] [
h5 [ _class "mt-2" ] [ raw app.PageTitle ]
form [ _action (relUrl app "admin/theme/new"); _method "post"; _class "container"
_enctype "multipart/form-data"; _hxNoBoost ] [
antiCsrf app
div [ _class "row " ] [
div [ _class "col-12 col-sm-6 pb-3" ] [
div [ _class "form-floating" ] [
input [ _type "file"; _id "file"; _name "file"; _class "form-control"; _accept ".zip"
_placeholder "Theme File"; _required ]
label [ _for "file" ] [ raw "Theme File" ]
]
]
div [ _class "col-12 col-sm-6 pb-3 d-flex justify-content-center align-items-center" ] [
div [ _class "form-check form-switch pb-2" ] [
input [ _type "checkbox"; _name "DoOverwrite"; _id "doOverwrite"; _class "form-check-input"
_value "true" ]
label [ _for "doOverwrite"; _class "form-check-label" ] [ raw "Overwrite" ]
]
]
]
div [ _class "row pb-3" ] [
div [ _class "col text-center" ] [
button [ _type "submit"; _class "btn btn-sm btn-primary" ] [ raw "Upload Theme" ]; raw " &nbsp; "
button [ _type "button"; _class "btn btn-sm btn-secondary ms-3"
_onclick "document.getElementById('theme_new').innerHTML = ''" ] [
raw "Cancel"
]
]
]
]
]
|> List.singleton

View File

@ -232,3 +232,139 @@ module Layout =
title [] [] title [] []
yield! content app yield! content app
] ]
// ~~ SHARED TEMPLATES BETWEEN POSTS AND PAGES
open Giraffe.Htmx.Common
/// The round-trip instant pattern
let roundTrip = InstantPattern.CreateWithInvariantCulture "uuuu'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff"
/// Capitalize the first letter in the given string
let private capitalize (it: string) =
$"{(string it[0]).ToUpper()}{it[1..]}"
/// Form to manage permalinks for pages or posts
let managePermalinks (model: ManagePermalinksModel) app = [
let baseUrl = relUrl app $"admin/{model.Entity}/"
let linkDetail idx link =
div [ _id $"link_%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.removePermalink({idx})" ] [
raw "&minus;"
]
]
div [ _class "col-11" ] [
div [ _class "form-floating" ] [
input [ _type "text"; _name "Prior"; _id $"prior_{idx}"; _class "form-control"; _placeholder "Link"
_value link ]
label [ _for $"prior_{idx}" ] [ raw "Link" ]
]
]
]
h2 [ _class "my-3" ] [ raw app.PageTitle ]
article [] [
form [ _action $"{baseUrl}permalinks"; _method "post"; _class "container" ] [
antiCsrf app
input [ _type "hidden"; _name "Id"; _value model.Id ]
div [ _class "row" ] [
div [ _class "col" ] [
p [ _style "line-height:1.2rem;" ] [
strong [] [ txt model.CurrentTitle ]; br []
small [ _class "text-muted" ] [
span [ _class "fst-italic" ] [ txt model.CurrentPermalink ]; br []
a [ _href $"{baseUrl}{model.Id}/edit" ] [
raw $"&laquo; Back to Edit {capitalize model.Entity}"
]
]
]
]
]
div [ _class "row mb-3" ] [
div [ _class "col" ] [
button [ _type "button"; _class "btn btn-sm btn-secondary"; _onclick "Admin.addPermalink()" ] [
raw "Add a Permalink"
]
]
]
div [ _class "row mb-3" ] [
div [ _class "col" ] [
div [ _id "permalinks"; _class "container g-0" ] [
yield! Array.mapi linkDetail model.Prior
script [] [
raw """document.addEventListener(\"DOMContentLoaded\", """
raw $"() => Admin.setPermalinkIndex({model.Prior.Length}))"
]
]
]
]
div [ _class "row pb-3" ] [
div [ _class "col " ] [
button [ _type "submit"; _class "btn btn-primary" ] [ raw "Save Changes" ]
]
]
]
]
]
/// Form to manage revisions for pages or posts
let manageRevisions (model: ManageRevisionsModel) app = [
let revUrlBase = relUrl app $"admin/{model.Entity}/{model.Id}/revision"
let revDetail idx (rev: Revision) =
let asOfString = roundTrip.Format rev.AsOf
let asOfId = $"""rev_{asOfString.Replace(".", "_").Replace(":", "-")}"""
div [ _id asOfId; _class "row pb-3 mwl-table-detail" ] [
div [ _class "col-12 mb-1" ] [
longDate app rev.AsOf; raw " at "; shortTime app rev.AsOf; raw " "
span [ _class "badge bg-secondary text-uppercase ms-2" ] [ txt (string rev.Text.SourceType) ]
if idx = 0 then span [ _class "badge bg-primary text-uppercase ms-2" ] [ raw "Current Revision" ]
br []
if idx > 0 then
let revUrlPrefix = $"{revUrlBase}/{asOfString}"
let revRestore = $"{revUrlPrefix}/restore"
small [] [
a [ _href $"{revUrlPrefix}/preview"; _hxTarget $"#{asOfId}_preview" ] [ raw "Preview" ]
span [ _class "text-muted" ] [ raw " &bull; " ]
a [ _href revRestore; _hxPost revRestore ] [ raw "Restore as Current" ]
span [ _class "text-muted" ] [ raw " &bull; " ]
a [ _href revUrlPrefix; _hxDelete revUrlPrefix; _hxTarget $"#{asOfId}"
_hxSwap HxSwap.OuterHtml; _class "text-danger" ] [
raw "Delete"
]
]
]
if idx > 0 then div [ _id $"{asOfId}_preview"; _class "col-12" ] []
]
h2 [ _class "my-3" ] [ raw app.PageTitle ]
article [] [
form [ _method "post"; _hxTarget "body"; _class "container mb-3" ] [
antiCsrf app
input [ _type "hidden"; _name "Id"; _value model.Id ]
div [ _class "row" ] [
div [ _class "col" ] [
p [ _style "line-height:1.2rem;" ] [
strong [] [ txt model.CurrentTitle ]; br []
small [ _class "text-muted" ] [
a [ _href (relUrl app $"admin/{model.Entity}/{model.Id}/edit") ] [
raw $"&laquo; Back to Edit {(string model.Entity[0]).ToUpper()}{model.Entity[1..]}"
]
]
]
]
]
if model.Revisions.Length > 1 then
div [ _class "row mb-3" ] [
div [ _class "col" ] [
button [ _type "button"; _class "btn btn-sm btn-danger"; _hxDelete $"{revUrlBase}s/purge"
_hxConfirm "This will remove all revisions but the current one; are you sure this is what you wish to do?" ] [
raw "Delete All Prior Revisions"
]
]
]
div [ _class "row mwl-table-heading" ] [ div [ _class "col" ] [ raw "Revision" ] ]
yield! List.mapi revDetail model.Revisions
]
]
]

View File

@ -13,7 +13,7 @@ let edit (model: EditUserModel) app =
div [ _class "col-12" ] [ div [ _class "col-12" ] [
h5 [ _class "my-3" ] [ txt app.PageTitle ] h5 [ _class "my-3" ] [ txt app.PageTitle ]
form [ _hxPost (relUrl app "admin/settings/user/save"); _method "post"; _class "container" form [ _hxPost (relUrl app "admin/settings/user/save"); _method "post"; _class "container"
_hxTarget "#userList"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ _hxTarget "#user_panel"; _hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [
antiCsrf app antiCsrf app
input [ _type "hidden"; _name "Id"; _value model.Id ] input [ _type "hidden"; _name "Id"; _value model.Id ]
div [ _class "row" ] [ div [ _class "row" ] [
@ -163,56 +163,77 @@ let logOn (model: LogOnModel) (app: AppViewContext) = [
/// The list of users for a web log (part of web log settings page) /// The list of users for a web log (part of web log settings page)
let userList (model: WebLogUser list) app = let userList (model: WebLogUser list) app =
let badge = "ms-2 badge bg" let userCol = "col-12 col-md-4 col-xl-3"
div [ _id "userList" ] [ let emailCol = "col-12 col-md-4 col-xl-4"
div [ _class "container g-0" ] [ let cre8Col = "d-none d-xl-block col-xl-2"
div [ _class "row mwl-table-detail"; _id "user_new" ] [] let lastCol = "col-12 col-md-4 col-xl-3"
] let badge = "ms-2 badge bg"
form [ _method "post"; _class "container g-0"; _hxTarget "this" let userDetail (user: WebLogUser) =
_hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [ div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [
antiCsrf app div [ _class $"{userCol} no-wrap" ] [
for user in model do txt user.PreferredName; raw " "
div [ _class "row mwl-table-detail"; _id $"user_{user.Id}" ] [ match user.AccessLevel with
div [ _class "col-12 col-md-4 col-xl-3 no-wrap" ] [ | Administrator -> span [ _class $"{badge}-success" ] [ raw "ADMINISTRATOR" ]
txt user.PreferredName; raw " " | WebLogAdmin -> span [ _class $"{badge}-primary" ] [ raw "WEB LOG ADMIN" ]
match user.AccessLevel with | Editor -> span [ _class $"{badge}-secondary" ] [ raw "EDITOR" ]
| Administrator -> span [ _class $"{badge}-success" ] [ raw "ADMINISTRATOR" ] | Author -> span [ _class $"{badge}-dark" ] [ raw "AUTHOR" ]
| WebLogAdmin -> span [ _class $"{badge}-primary" ] [ raw "WEB LOG ADMIN" ] br []
| Editor -> span [ _class $"{badge}-secondary" ] [ raw "EDITOR" ] if app.IsAdministrator || (app.IsWebLogAdmin && not (user.AccessLevel = Administrator)) then
| Author -> span [ _class $"{badge}-dark" ] [ raw "AUTHOR" ] let userUrl = relUrl app $"admin/settings/user/{user.Id}"
br [] small [] [
if app.IsAdministrator || (app.IsWebLogAdmin && not (user.AccessLevel = Administrator)) then a [ _href $"{userUrl}/edit"; _hxTarget $"#user_{user.Id}"
let userUrl = relUrl app $"admin/settings/user/{user.Id}" _hxSwap $"{HxSwap.InnerHtml} show:#user_{user.Id}:top" ] [
small [] [ raw "Edit"
a [ _href $"{userUrl}/edit"; _hxTarget $"#user_{user.Id}" ]
_hxSwap $"{HxSwap.InnerHtml} show:#user_{user.Id}:top" ] [ if app.UserId.Value <> user.Id then
raw "Edit" span [ _class "text-muted" ] [ raw " &bull; " ]
] a [ _href userUrl; _hxDelete userUrl; _class "text-danger"
if app.UserId.Value <> user.Id then _hxConfirm $"Are you sure you want to delete the user “{user.PreferredName}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)" ] [
span [ _class "text-muted" ] [ raw " &bull; " ] raw "Delete"
a [ _href userUrl; _hxDelete userUrl; _class "text-danger"
_hxConfirm $"Are you sure you want to delete the user “{user.PreferredName}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)" ] [
raw "Delete"
]
] ]
] ]
div [ _class "col-12 col-md-4 col-xl-4" ] [ ]
txt $"{user.FirstName} {user.LastName}"; br [] div [ _class emailCol ] [
small [ _class "text-muted" ] [ txt $"{user.FirstName} {user.LastName}"; br []
txt user.Email small [ _class "text-muted" ] [
if Option.isSome user.Url then txt user.Email
br []; txt user.Url.Value if Option.isSome user.Url then
] br []; txt user.Url.Value
]
div [ _class "d-none d-xl-block col-xl-2" ] [
if user.CreatedOn = Noda.epoch then raw "N/A" else longDate app user.CreatedOn
]
div [ _class "col-12 col-md-4 col-xl-3" ] [
match user.LastSeenOn with
| Some it -> longDate app it; raw " at "; shortTime app it
| None -> raw "--"
]
] ]
]
div [ _class "d-none d-xl-block col-xl-2" ] [
if user.CreatedOn = Noda.epoch then raw "N/A" else longDate app user.CreatedOn
]
div [ _class "col-12 col-md-4 col-xl-3" ] [
match user.LastSeenOn with
| Some it -> longDate app it; raw " at "; shortTime app it
| None -> raw "--"
]
]
div [ _id "user_panel" ] [
a [ _href (relUrl app "admin/settings/user/new/edit"); _class "btn btn-primary btn-sm mb-3"
_hxTarget "#user_new" ] [
raw "Add a New User"
]
div [ _class "container g-0" ] [
div [ _class "row mwl-table-heading" ] [
div [ _class userCol ] [
raw "User"; span [ _class "d-md-none" ] [ raw "; Full Name / E-mail; Last Log On" ]
]
div [ _class $"{emailCol} d-none d-md-inline-block" ] [ raw "Full Name / E-mail" ]
div [ _class cre8Col ] [ raw "Created" ]
div [ _class $"{lastCol} d-none d-md-block" ] [ raw "Last Log On" ]
]
]
div [ _id "userList" ] [
div [ _class "container g-0" ] [
div [ _class "row mwl-table-detail"; _id "user_new" ] []
]
form [ _method "post"; _class "container g-0"; _hxTarget "#user_panel"
_hxSwap $"{HxSwap.OuterHtml} show:window:top" ] [
antiCsrf app
yield! List.map userDetail model
]
] ]
] ]
|> List.singleton |> List.singleton

View File

@ -1,3 +0,0 @@
{%- assign theme_col = "col-12 col-md-6" -%}
{%- assign slug_col = "d-none d-md-block col-md-3" -%}
{%- assign tmpl_col = "d-none d-md-block col-md-3" -%}

View File

@ -1,4 +0,0 @@
{%- assign user_col = "col-12 col-md-4 col-xl-3" -%}
{%- assign email_col = "col-12 col-md-4 col-xl-4" -%}
{%- assign cre8_col = "d-none d-xl-block col-xl-2" -%}
{%- assign last_col = "col-12 col-md-4 col-xl-3" -%}

View File

@ -2,19 +2,7 @@
<article> <article>
<fieldset class="container mb-3 pb-0"> <fieldset class="container mb-3 pb-0">
<legend>Themes</legend> <legend>Themes</legend>
<a href="{{ "admin/theme/new" | relative_link }}" class="btn btn-primary btn-sm mb-3" hx-target=#theme_new> <span hx-get="{{ "admin/theme/list" | relative_link }}" hx-trigger="load" hx-swap="outerHTML"></span>
Upload a New Theme
</a>
<div class="container g-0">
{% include_template "_theme-list-columns" %}
<div class="row mwl-table-heading">
<div class="{{ theme_col }}">Theme</div>
<div class="{{ slug_col }} d-none d-md-inline-block">Slug</div>
<div class="{{ tmpl_col }} d-none d-md-inline-block">Templates</div>
</div>
</div>
<div class="row mwl-table-detail" id="theme_new"></div>
{{ theme_list }}
</fieldset> </fieldset>
<fieldset class="container mb-3 pb-0"> <fieldset class="container mb-3 pb-0">
{%- assign cache_base_url = "admin/cache/" -%} {%- assign cache_base_url = "admin/cache/" -%}

View File

@ -1,59 +0,0 @@
<h2 class=my-3>{{ page_title }}</h2>
<article>
{%- assign base_url = "admin/" | append: model.entity | append: "/" -%}
<form action="{{ base_url | append: "permalinks" | relative_link }}" method=post>
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type=hidden name=Id value="{{ model.id }}">
<div class=container>
<div class=row>
<div class=col>
<p style="line-height:1.2rem;">
<strong>{{ model.current_title }}</strong><br>
<small class=text-muted>
<span class=fst-italic>{{ model.current_permalink }}</span><br>
<a href="{{ base_url | append: model.id | append: "/edit" | relative_link }}">
&laquo; Back to Edit {{ model.entity | capitalize }}
</a>
</small>
</div>
</div>
<div class="row mb-3">
<div class=col>
<button type=button class="btn btn-sm btn-secondary" onclick="Admin.addPermalink()">Add a Permalink</button>
</div>
</div>
<div class="row mb-3">
<div class=col>
<div id=permalinks class=container>
{%- assign link_count = 0 -%}
{%- for link in model.prior %}
<div id="link_{{ link_count }}" class="row mb-3">
<div class="col-1 text-center align-self-center">
<button type=button class="btn btn-sm btn-danger" onclick="Admin.removePermalink({{ link_count }})">
&minus;
</button>
</div>
<div class=col-11>
<div class=form-floating>
<input type=text name=Prior id="prior_{{ link_count }}" class=form-control placeholder=Link
value="{{ link }}">
<label for="prior_{{ link_count }}">Link</label>
</div>
</div>
</div>
{%- assign link_count = link_count | plus: 1 -%}
{% endfor -%}
<script>
document.addEventListener("DOMContentLoaded", () => Admin.setPermalinkIndex({{ link_count }}))
</script>
</div>
</div>
</div>
<div class="row pb-3">
<div class=col>
<button type=submit class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</article>

View File

@ -1,65 +0,0 @@
<h2 class=my-3>{{ page_title }}</h2>
<article>
<form method=post hx-target=body>
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type=hidden name=Id value="{{ model.id }}">
<div class="container mb-3">
<div class=row>
<div class=col>
<p style="line-height:1.2rem;">
<strong>{{ model.current_title }}</strong><br>
<small class=text-muted>
<a href="{{ "admin/" | append: model.entity | append: "/" | append: model.id | append: "/edit" | relative_link }}">
&laquo; Back to Edit {{ model.entity | capitalize }}
</a>
</small>
</div>
</div>
{%- assign revision_count = model.revisions | size -%}
{%- assign rev_url_base = "admin/" | append: model.entity | append: "/" | append: model.id | append: "/revision" -%}
{%- if revision_count > 1 %}
<div class="row mb-3">
<div class=col>
<button type=button class="btn btn-sm btn-danger"
hx-post="{{ rev_url_base | append: "s/purge" | relative_link }}"
hx-confirm="This will remove all revisions but the current one; are you sure this is what you wish to do?">
Delete All Prior Revisions
</button>
</div>
</div>
{%- endif %}
<div class="row mwl-table-heading"><div class=col>Revision</div></div>
{% for rev in model.revisions %}
{%- assign as_of_string = rev.as_of | date: "o" -%}
{%- assign as_of_id = "rev_" | append: as_of_string | replace: "\.", "_" | replace: ":", "-" -%}
<div id="{{ as_of_id }}" class="row pb-3 mwl-table-detail">
<div class="col-12 mb-1">
{{ rev.as_of_local | date: "MMMM d, yyyy" }} at {{ rev.as_of_local | date: "h:mmtt" | downcase }}
<span class="badge bg-secondary text-uppercase ms-2">{{ rev.format }}</span>
{%- if forloop.first %}
<span class="badge bg-primary text-uppercase ms-2">Current Revision</span>
{%- endif %}<br>
{% unless forloop.first %}
{%- assign rev_url_prefix = rev_url_base | append: "/" | append: as_of_string -%}
{%- assign rev_restore = rev_url_prefix | append: "/restore" | relative_link -%}
{%- assign rev_delete = rev_url_prefix | append: "/delete" | relative_link -%}
<small>
<a href="{{ rev_url_prefix | append: "/preview" | relative_link }}" hx-target="#{{ as_of_id }}_preview">
Preview
</a>
<span class=text-muted> &bull; </span>
<a href="{{ rev_restore }}" hx-post="{{ rev_restore }}">Restore as Current</a>
<span class=text-muted> &bull; </span>
<a href="{{ rev_delete }}" hx-post="{{ rev_delete }}" hx-target="#{{ as_of_id }}" hx-swap=outerHTML
class=text-danger>
Delete
</a>
</small>
{% endunless %}
</div>
{% unless forloop.first %}<div id="{{ as_of_id }}_preview" class=col-12></div>{% endunless %}
</div>
{% endfor %}
</div>
</form>
</article>

View File

@ -109,19 +109,6 @@
</fieldset> </fieldset>
<fieldset id=users class="container mb-3 pb-0"> <fieldset id=users class="container mb-3 pb-0">
<legend>Users</legend> <legend>Users</legend>
{% include_template "_user-list-columns" %}
<a href="{{ "admin/settings/user/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
hx-target=#user_new>
Add a New User
</a>
<div class="container g-0">
<div class="row mwl-table-heading">
<div class="{{ user_col }}">User<span class=d-md-none>; Full Name / E-mail; Last Log On</span></div>
<div class="{{ email_col }} d-none d-md-inline-block">Full Name / E-mail</div>
<div class="{{ cre8_col }}">Created</div>
<div class="{{ last_col }} d-none d-md-block">Last Log On</div>
</div>
</div>
<span hx-get="{{ "admin/settings/users" | relative_link }}" hx-trigger="load" hx-swap="outerHTML"></span> <span hx-get="{{ "admin/settings/users" | relative_link }}" hx-trigger="load" hx-swap="outerHTML"></span>
</fieldset> </fieldset>
<fieldset id=rss-settings class="container mb-3 pb-0"> <fieldset id=rss-settings class="container mb-3 pb-0">

View File

@ -1,33 +0,0 @@
<form method=post id=themeList class="container g-0" hx-target=this hx-swap="outerHTML show:window:top">
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
{% include_template "_theme-list-columns" %}
{% for theme in themes -%}
<div class="row mwl-table-detail" id="theme_{{ theme.id }}">
<div class="{{ theme_col }} no-wrap">
{{ theme.name }}
{%- if theme.is_in_use %}
<span class="badge bg-primary ms-2">IN USE</span>
{%- endif %}
{%- unless theme.is_on_disk %}
<span class="badge bg-warning text-dark ms-2">NOT ON DISK</span>
{%- endunless %}<br>
<small>
<span class=text-muted>v{{ theme.version }}</span>
{% unless theme.is_in_use or theme.id == "default" %}
<span class=text-muted> &bull; </span>
{%- assign theme_del_link = "admin/theme/" | append: theme.id | append: "/delete" | relative_link -%}
<a href="{{ theme_del_link }}" hx-post="{{ theme_del_link }}" class=text-danger
hx-confirm="Are you sure you want to delete the theme &ldquo;{{ theme.name }}&rdquo;? This action cannot be undone.">
Delete
</a>
{% endunless %}
<span class="d-md-none text-muted">
<br>Slug: {{ theme.id }} &bull; {{ theme.template_count }} Templates
</span>
</small>
</div>
<div class="{{ slug_col }}">{{ theme.id }}</div>
<div class="{{ tmpl_col }}">{{ theme.template_count }}</div>
</div>
{%- endfor %}
</form>

View File

@ -1,30 +0,0 @@
<div class=col>
<h5 class=mt-2>{{ page_title }}</h5>
<form action="{{ "admin/theme/new" | relative_link }}" method=post class=container enctype=multipart/form-data
hx-boost=false>
<input type=hidden name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class=row>
<div class="col-12 col-sm-6 pb-3">
<div class=form-floating>
<input type=file id=file name=file class=form-control accept=.zip placeholder="Theme File" required>
<label for=file>Theme File</label>
</div>
</div>
<div class="col-12 col-sm-6 pb-3 d-flex justify-content-center align-items-center">
<div class="form-check form-switch pb-2">
<input type=checkbox name=DoOverwrite id=doOverwrite class=form-check-input value=true>
<label for=doOverwrite class=form-check-label>Overwrite</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type=submit class="btn btn-sm btn-primary">Upload Theme</button>
<button type=button class="btn btn-sm btn-secondary ms-3"
onclick="document.getElementById('theme_new').innerHTML = ''">
Cancel
</button>
</div>
</div>
</form>
</div>