Reassign child cats when deleting parent cat (#27)

- Create common page/post edit field template (#25)
- Fix relative URL adjustment throughout
- Fix upload name sanitization regex
- Create modules within Admin handler module
- Enable/disable podcast episode fields on page load
- Fix upload destination casing in templates
- Tweak default theme to show no posts found on index template
- Update Bootstrap to 5.1.3 in default theme
This commit is contained in:
Daniel J. Summers 2022-07-28 20:36:02 -04:00
parent 6b49793fbb
commit 33698bd182
22 changed files with 926 additions and 898 deletions

View File

@ -5,6 +5,16 @@ open System.Threading.Tasks
open MyWebLog open MyWebLog
open MyWebLog.ViewModels open MyWebLog.ViewModels
/// The result of a category deletion attempt
type CategoryDeleteResult =
/// The category was deleted successfully
| CategoryDeleted
/// The category was deleted successfully, and its children were reassigned to its parent
| ReassignedChildCategories
/// The category was not found, so no effort was made to delete it
| CategoryNotFound
/// Data functions to support manipulating categories /// Data functions to support manipulating categories
type ICategoryData = type ICategoryData =
@ -18,7 +28,7 @@ type ICategoryData =
abstract member CountTopLevel : WebLogId -> Task<int> abstract member CountTopLevel : WebLogId -> Task<int>
/// Delete a category (also removes it from posts) /// Delete a category (also removes it from posts)
abstract member Delete : CategoryId -> WebLogId -> Task<bool> abstract member Delete : CategoryId -> WebLogId -> Task<CategoryDeleteResult>
/// Find all categories for a web log, sorted alphabetically and grouped by hierarchy /// Find all categories for a web log, sorted alphabetically and grouped by hierarchy
abstract member FindAllForView : WebLogId -> Task<DisplayCategory[]> abstract member FindAllForView : WebLogId -> Task<DisplayCategory[]>

View File

@ -274,7 +274,21 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
member this.Delete catId webLogId = backgroundTask { member this.Delete catId webLogId = backgroundTask {
match! this.FindById catId webLogId with match! this.FindById catId webLogId with
| Some _ -> | Some cat ->
// Reassign any children to the category's parent category
let! children = rethink<int> {
withTable Table.Category
filter (nameof Category.empty.ParentId) catId
count
result; withRetryDefault conn
}
if children > 0 then
do! rethink {
withTable Table.Category
filter (nameof Category.empty.ParentId) catId
update [ nameof Category.empty.ParentId, cat.ParentId :> obj ]
write; withRetryDefault; ignoreResult conn
}
// Delete the category off all posts where it is assigned // Delete the category off all posts where it is assigned
do! rethink { do! rethink {
withTable Table.Post withTable Table.Post
@ -291,8 +305,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
delete delete
write; withRetryDefault; ignoreResult conn write; withRetryDefault; ignoreResult conn
} }
return true return if children = 0 then CategoryDeleted else ReassignedChildCategories
| None -> return false | None -> return CategoryNotFound
} }
member _.Restore cats = backgroundTask { member _.Restore cats = backgroundTask {

View File

@ -122,13 +122,23 @@ type SQLiteCategoryData (conn : SqliteConnection) =
/// Delete a category /// Delete a category
let delete catId webLogId = backgroundTask { let delete catId webLogId = backgroundTask {
match! findById catId webLogId with match! findById catId webLogId with
| Some _ -> | Some cat ->
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
// Reassign any children to the category's parent category
cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE parent_id = @parentId"
cmd.Parameters.AddWithValue ("@parentId", CategoryId.toString catId) |> ignore
let! children = count cmd
if children > 0 then
cmd.CommandText <- "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId"
cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map CategoryId.toString))
|> ignore
do! write cmd
// Delete the category off all posts where it is assigned // Delete the category off all posts where it is assigned
cmd.CommandText <- """ cmd.CommandText <- """
DELETE FROM post_category DELETE FROM post_category
WHERE category_id = @id WHERE category_id = @id
AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId)""" AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId)"""
cmd.Parameters.Clear ()
let catIdParameter = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId) let catIdParameter = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId)
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
do! write cmd do! write cmd
@ -137,8 +147,8 @@ type SQLiteCategoryData (conn : SqliteConnection) =
cmd.Parameters.Clear () cmd.Parameters.Clear ()
cmd.Parameters.Add catIdParameter |> ignore cmd.Parameters.Add catIdParameter |> ignore
do! write cmd do! write cmd
return true return if children = 0 then CategoryDeleted else ReassignedChildCategories
| None -> return false | None -> return CategoryNotFound
} }
/// Restore categories from a backup /// Restore categories from a backup

View File

@ -12,6 +12,17 @@ module private Helpers =
match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed
/// Helper functions that are needed outside this file
[<AutoOpen>]
module PublicHelpers =
/// If the web log is not being served from the domain root, add the path information to relative URLs in page and
/// post text
let addBaseToRelativeUrls extra (text : string) =
if extra = "" then text
else text.Replace("href=\"/", $"href=\"{extra}/").Replace ("src=\"/", $"src=\"{extra}/")
/// The model used to display the admin dashboard /// The model used to display the admin dashboard
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DashboardModel = type DashboardModel =
@ -147,7 +158,7 @@ type DisplayPage =
UpdatedOn = page.UpdatedOn UpdatedOn = page.UpdatedOn
IsInPageList = page.IsInPageList IsInPageList = page.IsInPageList
IsDefault = pageId = webLog.DefaultPage IsDefault = pageId = webLog.DefaultPage
Text = if extra = "" then page.Text else page.Text.Replace ("href=\"/", $"href=\"{extra}/") Text = addBaseToRelativeUrls extra page.Text
Metadata = page.Metadata Metadata = page.Metadata
} }
@ -1061,7 +1072,7 @@ type PostListItem =
Permalink = Permalink.toString post.Permalink Permalink = Permalink.toString post.Permalink
PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable
UpdatedOn = inTZ post.UpdatedOn UpdatedOn = inTZ post.UpdatedOn
Text = if extra = "" then post.Text else post.Text.Replace ("href=\"/", $"href=\"{extra}/") Text = addBaseToRelativeUrls extra post.Text
CategoryIds = post.CategoryIds |> List.map CategoryId.toString CategoryIds = post.CategoryIds |> List.map CategoryId.toString
Tags = post.Tags Tags = post.Tags
Episode = post.Episode Episode = post.Episode

View File

@ -6,484 +6,513 @@ open Giraffe
open MyWebLog open MyWebLog
open MyWebLog.ViewModels open MyWebLog.ViewModels
// ~~ DASHBOARDS ~~ /// ~~ DASHBOARDS ~~
module Dashboard =
// GET /admin/dashboard // GET /admin/dashboard
let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task { let user : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id
let data = ctx.Data let data = ctx.Data
let posts = getCount (data.Post.CountByStatus Published) let posts = getCount (data.Post.CountByStatus Published)
let drafts = getCount (data.Post.CountByStatus Draft) let drafts = getCount (data.Post.CountByStatus Draft)
let pages = getCount data.Page.CountAll let pages = getCount data.Page.CountAll
let listed = getCount data.Page.CountListed let listed = getCount data.Page.CountListed
let cats = getCount data.Category.CountAll let cats = getCount data.Category.CountAll
let topCats = getCount data.Category.CountTopLevel let topCats = getCount data.Category.CountTopLevel
let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats) let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats)
return!
hashForPage "Dashboard"
|> addToHash ViewContext.Model {
Posts = posts.Result
Drafts = drafts.Result
Pages = pages.Result
ListedPages = listed.Result
Categories = cats.Result
TopLevelCategories = topCats.Result
}
|> adminView "dashboard" next ctx
}
// GET /admin/administration
let adminDashboard : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
match! TemplateCache.get adminTheme "theme-list-body" ctx.Data with
| Ok bodyTemplate ->
let! themes = ctx.Data.Theme.All ()
let cachedTemplates = TemplateCache.allNames ()
let! hash =
hashForPage "myWebLog Administration"
|> withAntiCsrf ctx
|> addToHash "themes" (themes |> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse) |> Array.ofList)
|> addToHash "cached_themes" (
themes
|> Seq.ofList
|> Seq.map (fun it -> [|
ThemeId.toString it.Id
it.Name
cachedTemplates
|> List.filter (fun n -> n.StartsWith (ThemeId.toString it.Id))
|> List.length
|> string
|])
|> Array.ofSeq)
|> addToHash "web_logs" (
WebLogCache.all ()
|> Seq.ofList
|> Seq.sortBy (fun it -> it.Name)
|> Seq.map (fun it -> [| WebLogId.toString it.Id; it.Name; it.UrlBase |])
|> Array.ofSeq)
|> addViewContext ctx
return! return!
addToHash "theme_list" (bodyTemplate.Render hash) hash hashForPage "Dashboard"
|> adminView "admin-dashboard" next ctx |> addToHash ViewContext.Model {
| Error message -> return! Error.server message next ctx Posts = posts.Result
} Drafts = drafts.Result
Pages = pages.Result
ListedPages = listed.Result
Categories = cats.Result
TopLevelCategories = topCats.Result
}
|> adminView "dashboard" next ctx
}
// GET /admin/administration
let admin : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
match! TemplateCache.get adminTheme "theme-list-body" ctx.Data with
| Ok bodyTemplate ->
let! themes = ctx.Data.Theme.All ()
let cachedTemplates = TemplateCache.allNames ()
let! hash =
hashForPage "myWebLog Administration"
|> withAntiCsrf ctx
|> addToHash "themes" (
themes
|> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse)
|> Array.ofList)
|> addToHash "cached_themes" (
themes
|> Seq.ofList
|> Seq.map (fun it -> [|
ThemeId.toString it.Id
it.Name
cachedTemplates
|> List.filter (fun n -> n.StartsWith (ThemeId.toString it.Id))
|> List.length
|> string
|])
|> Array.ofSeq)
|> addToHash "web_logs" (
WebLogCache.all ()
|> Seq.ofList
|> Seq.sortBy (fun it -> it.Name)
|> Seq.map (fun it -> [| WebLogId.toString 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
let toAdminDashboard : HttpHandler = redirectToGet "admin/administration" let toAdminDashboard : HttpHandler = redirectToGet "admin/administration"
// ~~ CACHES ~~
// POST /admin/cache/web-log/{id}/refresh /// ~~ CACHES ~~
let refreshWebLogCache webLogId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { module Cache =
let data = ctx.Data
if webLogId = "all" then // POST /admin/cache/web-log/{id}/refresh
do! WebLogCache.fill data let refreshWebLog webLogId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
for webLog in WebLogCache.all () do let data = ctx.Data
do! PageListCache.refresh webLog data if webLogId = "all" then
do! CategoryCache.refresh webLog.Id data do! WebLogCache.fill data
do! addMessage ctx { UserMessage.success with Message = "Successfully refresh web log cache for all web logs" } for webLog in WebLogCache.all () do
else do! PageListCache.refresh webLog data
match! data.WebLog.FindById (WebLogId webLogId) with do! CategoryCache.refresh webLog.Id data
| Some webLog ->
WebLogCache.set webLog
do! PageListCache.refresh webLog data
do! CategoryCache.refresh webLog.Id data
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with Message = $"Successfully refreshed web log cache for {webLog.Name}" } { UserMessage.success with Message = "Successfully refresh web log cache for all web logs" }
| None -> else
do! addMessage ctx { UserMessage.error with Message = $"No web log exists with ID {webLogId}" } match! data.WebLog.FindById (WebLogId webLogId) with
return! toAdminDashboard next ctx | Some webLog ->
} WebLogCache.set webLog
do! PageListCache.refresh webLog data
do! CategoryCache.refresh webLog.Id data
do! addMessage ctx
{ UserMessage.success with Message = $"Successfully refreshed web log cache for {webLog.Name}" }
| None ->
do! addMessage ctx { UserMessage.error with Message = $"No web log exists with ID {webLogId}" }
return! toAdminDashboard next ctx
}
// POST /admin/cache/theme/{id}/refresh // POST /admin/cache/theme/{id}/refresh
let refreshThemeCache themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let refreshTheme themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
if themeId = "all" then if themeId = "all" then
TemplateCache.empty () TemplateCache.empty ()
do! ThemeAssetCache.fill data do! ThemeAssetCache.fill data
do! addMessage ctx
{ UserMessage.success with
Message = "Successfully cleared template cache and refreshed theme asset cache"
}
else
match! data.Theme.FindById (ThemeId themeId) with
| Some theme ->
TemplateCache.invalidateTheme theme.Id
do! ThemeAssetCache.refreshTheme theme.Id data
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.success with
Message = $"Successfully cleared template cache and refreshed theme asset cache for {theme.Name}" Message = "Successfully cleared template cache and refreshed theme asset cache"
} }
| None -> else
do! addMessage ctx { UserMessage.error with Message = $"No theme exists with ID {themeId}" } match! data.Theme.FindById (ThemeId themeId) with
return! toAdminDashboard next ctx | Some theme ->
} TemplateCache.invalidateTheme theme.Id
do! ThemeAssetCache.refreshTheme theme.Id data
// ~~ CATEGORIES ~~
// GET /admin/categories
let listCategories : HttpHandler = requireAccess WebLogAdmin >=> 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 listCategoriesBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
hashForPage "Categories"
|> withAntiCsrf ctx
|> adminBareView "category-list-body" next ctx
// GET /admin/category/{id}/edit
let editCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! result = task {
match catId with
| "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)
| 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
| None -> return! Error.notFound next ctx
}
// POST /admin/category/save
let saveCategory : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data
let! model = ctx.BindFormAsync<EditCategoryModel> ()
let category =
if model.IsNew then someTask { Category.empty with Id = CategoryId.create (); WebLogId = ctx.WebLog.Id }
else data.Category.FindById (CategoryId model.CategoryId) ctx.WebLog.Id
match! category with
| Some cat ->
let updatedCat =
{ cat with
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)
}
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! listCategoriesBare next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/category/{id}/delete
let deleteCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
match! ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id with
| true ->
do! CategoryCache.update ctx
do! addMessage ctx { UserMessage.success with Message = "Category deleted successfully" }
| false -> do! addMessage ctx { UserMessage.error with Message = "Category not found; cannot delete" }
return! listCategoriesBare next ctx
}
open Microsoft.AspNetCore.Http
// ~~ TAG MAPPINGS ~~
/// Add tag mappings to the given hash
let private withTagMappings (ctx : HttpContext) hash = task {
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
return
addToHash "mappings" mappings hash
|> addToHash "mapping_ids" (mappings |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
}
// GET /admin/settings/tag-mappings
let tagMappings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! hash =
hashForPage ""
|> withAntiCsrf ctx
|> withTagMappings ctx
return! adminBareView "tag-mapping-list-body" next ctx hash
}
// GET /admin/settings/tag-mapping/{id}/edit
let editMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let isNew = tagMapId = "new"
let tagMap =
if isNew then someTask { TagMap.empty with Id = TagMapId "new" }
else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id
match! tagMap with
| Some tm ->
return!
hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag")
|> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm)
|> adminBareView "tag-mapping-edit" next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/settings/tag-mapping/save
let saveMapping : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data
let! model = ctx.BindFormAsync<EditTagMapModel> ()
let tagMap =
if model.IsNew then someTask { TagMap.empty with Id = TagMapId.create (); WebLogId = ctx.WebLog.Id }
else data.TagMap.FindById (TagMapId model.Id) ctx.WebLog.Id
match! tagMap with
| Some tm ->
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower (); UrlValue = model.UrlValue.ToLower () }
do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" }
return! tagMappings next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/settings/tag-mapping/{id}/delete
let deleteMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" }
| false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" }
return! tagMappings next ctx
}
// ~~ THEMES ~~
open System
open System.IO
open System.IO.Compression
open System.Text.RegularExpressions
open MyWebLog.Data
// GET /admin/theme/list
let listThemes : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let! themes = ctx.Data.Theme.All ()
return!
hashForPage "Themes"
|> withAntiCsrf ctx
|> addToHash "themes" (themes |> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse) |> Array.ofList)
|> adminBareView "theme-list-body" next ctx
}
// GET /admin/theme/new
let addTheme : HttpHandler = requireAccess Administrator >=> fun next ctx ->
hashForPage "Upload a Theme File"
|> withAntiCsrf ctx
|> adminBareView "theme-upload" next ctx
/// 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 now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
| Some versionItem ->
use versionFile = new StreamReader(versionItem.Open ())
let! versionText = versionFile.ReadToEndAsync ()
let parts = versionText.Trim().Replace("\r", "").Split "\n"
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.Id
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
return { theme with Name = displayName; Version = version }
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
}
/// Update the theme with all templates from the ZIP archive
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
let tasks =
zip.Entries
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|> Seq.map (fun templateItem -> backgroundTask {
use templateFile = new StreamReader (templateItem.Open ())
let! template = templateFile.ReadToEndAsync ()
return { Name = templateItem.Name.Replace (".liquid", ""); Text = template }
})
let! templates = Task.WhenAll tasks
return
templates
|> Array.fold (fun t template ->
{ t with Templates = template :: (t.Templates |> List.filter (fun it -> it.Name <> template.Name)) })
theme
}
/// Update theme assets from the ZIP archive
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
let assetName = asset.FullName.Replace ("wwwroot/", "")
if assetName <> "" && not (assetName.EndsWith "/") then
use stream = new MemoryStream ()
do! asset.Open().CopyToAsync stream
do! data.ThemeAsset.Save
{ Id = ThemeAssetId (themeId, assetName)
UpdatedOn = asset.LastWriteTime.DateTime
Data = stream.ToArray ()
}
}
/// Get the theme name from the file name given
let getThemeIdFromFileName (fileName : string) =
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
if themeName.EndsWith "-theme" then
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then
Ok (ThemeId (themeName.Substring (0, themeName.Length - 6)))
else Error $"Theme ID {fileName} is invalid"
else Error "Theme .zip file name must end in \"-theme.zip\""
/// Load a theme from the given stream, which should contain a ZIP archive
let loadThemeFromZip themeId file (data : IData) = backgroundTask {
let! isNew, theme = backgroundTask {
match! data.Theme.FindById themeId with
| Some t -> return false, t
| None -> return true, { Theme.empty with Id = themeId }
}
use zip = new ZipArchive (file, ZipArchiveMode.Read)
let! theme = updateNameAndVersion theme zip
if not isNew then do! data.ThemeAsset.DeleteByTheme theme.Id
let! theme = updateTemplates { theme with Templates = [] } zip
do! data.Theme.Save theme
do! updateAssets themeId zip data
return theme
}
// POST /admin/theme/new
let saveTheme : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
let themeFile = Seq.head ctx.Request.Form.Files
match getThemeIdFromFileName themeFile.FileName with
| Ok themeId when themeId <> adminTheme ->
let data = ctx.Data
let! exists = data.Theme.Exists themeId
let isNew = not exists
let! model = ctx.BindFormAsync<UploadThemeModel> ()
if isNew || model.DoOverwrite then
// Load the theme to the database
use stream = new MemoryStream ()
do! themeFile.CopyToAsync stream
let! _ = loadThemeFromZip themeId stream data
do! ThemeAssetCache.refreshTheme themeId data
TemplateCache.invalidateTheme themeId
// Save the .zip file
use file = new FileStream ($"{ThemeId.toString themeId}-theme.zip", FileMode.Create)
do! themeFile.CopyToAsync file
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.success with
Message = $"""Theme {if isNew then "add" else "updat"}ed successfully""" Message = $"Successfully cleared template cache and refreshed theme asset cache for {theme.Name}"
} }
return! toAdminDashboard next ctx | None ->
else do! addMessage ctx { UserMessage.error with Message = $"No theme exists with ID {themeId}" }
do! addMessage ctx return! toAdminDashboard next ctx
{ UserMessage.error with }
Message = "Theme exists and overwriting was not requested; nothing saved"
}
return! toAdminDashboard next ctx
| Ok _ ->
do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" }
return! toAdminDashboard next ctx
| Error message ->
do! addMessage ctx { UserMessage.error with Message = message }
return! toAdminDashboard next ctx
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
}
// POST /admin/theme/{id}/delete
let deleteTheme themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let data = ctx.Data
match themeId with
| "admin" | "default" ->
do! addMessage ctx { UserMessage.error with Message = $"You may not delete the {themeId} theme" }
return! listThemes next ctx
| it when WebLogCache.isThemeInUse (ThemeId it) ->
do! addMessage ctx
{ UserMessage.error with
Message = $"You may not delete the {themeId} theme, as it is currently in use"
}
return! listThemes next ctx
| _ ->
match! data.Theme.Delete (ThemeId themeId) with
| true ->
let zippedTheme = $"{themeId}-theme.zip"
if File.Exists zippedTheme then File.Delete zippedTheme
do! addMessage ctx { UserMessage.success with Message = $"Theme ID {themeId} deleted successfully" }
return! listThemes next ctx
| false -> return! Error.notFound next ctx
}
// ~~ WEB LOG SETTINGS ~~ /// ~~ CATEGORIES ~~
module Category =
open System.Collections.Generic open MyWebLog.Data
// GET /admin/settings // GET /admin/categories
let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data match! TemplateCache.get adminTheme "category-list-body" ctx.Data with
match! TemplateCache.get adminTheme "user-list-body" data with | Ok catListTemplate ->
| Ok userTemplate -> let! hash =
match! TemplateCache.get adminTheme "tag-mapping-list-body" ctx.Data with hashForPage "Categories"
| Ok tagMapTemplate ->
let! allPages = data.Page.All ctx.WebLog.Id
let! themes = data.Theme.All ()
let! users = data.WebLogUser.FindByWebLog ctx.WebLog.Id
let! hash =
hashForPage "Web Log Settings"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
|> addToHash "pages" (
seq {
KeyValuePair.Create ("posts", "- First Page of Posts -")
yield! allPages
|> List.sortBy (fun p -> p.Title.ToLower ())
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title))
}
|> Array.ofSeq)
|> addToHash "themes" (
themes
|> Seq.ofList
|> Seq.map (fun it -> KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|> Array.ofSeq)
|> addToHash "upload_values" [|
KeyValuePair.Create (UploadDestination.toString Database, "Database")
KeyValuePair.Create (UploadDestination.toString Disk, "Disk")
|]
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|> addToHash "rss_model" (EditRssModel.fromRssOptions ctx.WebLog.Rss)
|> addToHash "custom_feeds" (
ctx.WebLog.Rss.CustomFeeds
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|> Array.ofList)
|> addViewContext ctx |> addViewContext ctx
let! hash' = withTagMappings ctx hash
return! return!
addToHash "user_list" (userTemplate.Render hash') hash' addToHash "category_list" (catListTemplate.Render hash) hash
|> addToHash "tag_mapping_list" (tagMapTemplate.Render hash') |> adminView "category-list" next ctx
|> adminView "settings" next ctx
| Error message -> return! Error.server message next ctx | Error message -> return! Error.server message next ctx
| Error message -> return! Error.server message next ctx }
}
// POST /admin/settings // GET /admin/categories/bare
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
let data = ctx.Data hashForPage "Categories"
let! model = ctx.BindFormAsync<SettingsModel> () |> withAntiCsrf ctx
match! data.WebLog.FindById ctx.WebLog.Id with |> adminBareView "category-list-body" next ctx
| Some webLog ->
let oldSlug = webLog.Slug
let webLog = model.update webLog
do! data.WebLog.UpdateSettings webLog
// Update cache
WebLogCache.set webLog
if oldSlug <> webLog.Slug then // GET /admin/category/{id}/edit
// Rename disk directory if it exists let edit catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let uploadRoot = Path.Combine ("wwwroot", "upload") let! result = task {
let oldDir = Path.Combine (uploadRoot, oldSlug) match catId with
if Directory.Exists oldDir then Directory.Move (oldDir, Path.Combine (uploadRoot, webLog.Slug)) | "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)
| 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
| None -> return! Error.notFound next ctx
}
do! addMessage ctx { UserMessage.success with Message = "Web log settings saved successfully" } // POST /admin/category/save
return! redirectToGet "admin/settings" next ctx let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
| None -> return! Error.notFound next ctx let data = ctx.Data
} let! model = ctx.BindFormAsync<EditCategoryModel> ()
let category =
if model.IsNew then someTask { Category.empty with Id = CategoryId.create (); WebLogId = ctx.WebLog.Id }
else data.Category.FindById (CategoryId model.CategoryId) ctx.WebLog.Id
match! category with
| Some cat ->
let updatedCat =
{ cat with
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)
}
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
| None -> return! Error.notFound next ctx
}
// POST /admin/category/{id}/delete
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
| ReassignedChildCategories ->
do! CategoryCache.update ctx
let detail =
match result with
| ReassignedChildCategories ->
Some "<em>(Its child categories were reassigned to its parent category)</em>"
| _ -> None
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
}
/// ~~ TAG MAPPINGS ~~
module TagMapping =
open Microsoft.AspNetCore.Http
/// Add tag mappings to the given hash
let withTagMappings (ctx : HttpContext) hash = task {
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
return
addToHash "mappings" mappings hash
|> addToHash "mapping_ids" (
mappings
|> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
}
// GET /admin/settings/tag-mappings
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! hash =
hashForPage ""
|> withAntiCsrf ctx
|> withTagMappings ctx
return! adminBareView "tag-mapping-list-body" next ctx hash
}
// GET /admin/settings/tag-mapping/{id}/edit
let edit tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let isNew = tagMapId = "new"
let tagMap =
if isNew then someTask { TagMap.empty with Id = TagMapId "new" }
else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id
match! tagMap with
| Some tm ->
return!
hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag")
|> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm)
|> adminBareView "tag-mapping-edit" next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/settings/tag-mapping/save
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data
let! model = ctx.BindFormAsync<EditTagMapModel> ()
let tagMap =
if model.IsNew then someTask { TagMap.empty with Id = TagMapId.create (); WebLogId = ctx.WebLog.Id }
else data.TagMap.FindById (TagMapId model.Id) ctx.WebLog.Id
match! tagMap with
| Some tm ->
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower (); UrlValue = model.UrlValue.ToLower () }
do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" }
return! all next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/settings/tag-mapping/{id}/delete
let delete tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" }
| false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" }
return! all next ctx
}
/// ~~ THEMES ~~
module Theme =
open System
open System.IO
open System.IO.Compression
open System.Text.RegularExpressions
open MyWebLog.Data
// GET /admin/theme/list
let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let! themes = ctx.Data.Theme.All ()
return!
hashForPage "Themes"
|> withAntiCsrf ctx
|> addToHash "themes" (themes |> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse) |> Array.ofList)
|> adminBareView "theme-list-body" next ctx
}
// GET /admin/theme/new
let add : HttpHandler = requireAccess Administrator >=> fun next ctx ->
hashForPage "Upload a Theme File"
|> withAntiCsrf ctx
|> adminBareView "theme-upload" next ctx
/// 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 now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
| Some versionItem ->
use versionFile = new StreamReader(versionItem.Open ())
let! versionText = versionFile.ReadToEndAsync ()
let parts = versionText.Trim().Replace("\r", "").Split "\n"
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.Id
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
return { theme with Name = displayName; Version = version }
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
}
/// Update the theme with all templates from the ZIP archive
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
let tasks =
zip.Entries
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|> Seq.map (fun templateItem -> backgroundTask {
use templateFile = new StreamReader (templateItem.Open ())
let! template = templateFile.ReadToEndAsync ()
return { Name = templateItem.Name.Replace (".liquid", ""); Text = template }
})
let! templates = Task.WhenAll tasks
return
templates
|> Array.fold (fun t template ->
{ t with Templates = template :: (t.Templates |> List.filter (fun it -> it.Name <> template.Name)) })
theme
}
/// Update theme assets from the ZIP archive
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
let assetName = asset.FullName.Replace ("wwwroot/", "")
if assetName <> "" && not (assetName.EndsWith "/") then
use stream = new MemoryStream ()
do! asset.Open().CopyToAsync stream
do! data.ThemeAsset.Save
{ Id = ThemeAssetId (themeId, assetName)
UpdatedOn = asset.LastWriteTime.DateTime
Data = stream.ToArray ()
}
}
/// Derive the theme ID from the file name given
let deriveIdFromFileName (fileName : string) =
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
if themeName.EndsWith "-theme" then
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then
Ok (ThemeId (themeName.Substring (0, themeName.Length - 6)))
else Error $"Theme ID {fileName} is invalid"
else Error "Theme .zip file name must end in \"-theme.zip\""
/// Load a theme from the given stream, which should contain a ZIP archive
let loadFromZip themeId file (data : IData) = backgroundTask {
let! isNew, theme = backgroundTask {
match! data.Theme.FindById themeId with
| Some t -> return false, t
| None -> return true, { Theme.empty with Id = themeId }
}
use zip = new ZipArchive (file, ZipArchiveMode.Read)
let! theme = updateNameAndVersion theme zip
if not isNew then do! data.ThemeAsset.DeleteByTheme theme.Id
let! theme = updateTemplates { theme with Templates = [] } zip
do! data.Theme.Save theme
do! updateAssets themeId zip data
return theme
}
// POST /admin/theme/new
let save : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
let themeFile = Seq.head ctx.Request.Form.Files
match deriveIdFromFileName themeFile.FileName with
| Ok themeId when themeId <> adminTheme ->
let data = ctx.Data
let! exists = data.Theme.Exists themeId
let isNew = not exists
let! model = ctx.BindFormAsync<UploadThemeModel> ()
if isNew || model.DoOverwrite then
// Load the theme to the database
use stream = new MemoryStream ()
do! themeFile.CopyToAsync stream
let! _ = loadFromZip themeId stream data
do! ThemeAssetCache.refreshTheme themeId data
TemplateCache.invalidateTheme themeId
// Save the .zip file
use file = new FileStream ($"{ThemeId.toString themeId}-theme.zip", FileMode.Create)
do! themeFile.CopyToAsync file
do! addMessage ctx
{ UserMessage.success with
Message = $"""Theme {if isNew then "add" else "updat"}ed successfully"""
}
return! toAdminDashboard next ctx
else
do! addMessage ctx
{ UserMessage.error with
Message = "Theme exists and overwriting was not requested; nothing saved"
}
return! toAdminDashboard next ctx
| Ok _ ->
do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" }
return! toAdminDashboard next ctx
| Error message ->
do! addMessage ctx { UserMessage.error with Message = message }
return! toAdminDashboard next ctx
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
}
// POST /admin/theme/{id}/delete
let delete themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let data = ctx.Data
match themeId with
| "admin" | "default" ->
do! addMessage ctx { UserMessage.error with Message = $"You may not delete the {themeId} theme" }
return! all next ctx
| it when WebLogCache.isThemeInUse (ThemeId it) ->
do! addMessage ctx
{ UserMessage.error with
Message = $"You may not delete the {themeId} theme, as it is currently in use"
}
return! all next ctx
| _ ->
match! data.Theme.Delete (ThemeId themeId) with
| true ->
let zippedTheme = $"{themeId}-theme.zip"
if File.Exists zippedTheme then File.Delete zippedTheme
do! addMessage ctx { UserMessage.success with Message = $"Theme ID {themeId} deleted successfully" }
return! all next ctx
| false -> return! Error.notFound next ctx
}
/// ~~ WEB LOG SETTINGS ~~
module WebLog =
open System.Collections.Generic
open System.IO
// GET /admin/settings
let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data
match! TemplateCache.get adminTheme "user-list-body" data with
| Ok userTemplate ->
match! TemplateCache.get adminTheme "tag-mapping-list-body" ctx.Data with
| Ok tagMapTemplate ->
let! allPages = data.Page.All ctx.WebLog.Id
let! themes = data.Theme.All ()
let! users = data.WebLogUser.FindByWebLog ctx.WebLog.Id
let! hash =
hashForPage "Web Log Settings"
|> withAntiCsrf ctx
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
|> addToHash "pages" (
seq {
KeyValuePair.Create ("posts", "- First Page of Posts -")
yield! allPages
|> List.sortBy (fun p -> p.Title.ToLower ())
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title))
}
|> Array.ofSeq)
|> addToHash "themes" (
themes
|> Seq.ofList
|> Seq.map (fun it ->
KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|> Array.ofSeq)
|> addToHash "upload_values" [|
KeyValuePair.Create (UploadDestination.toString Database, "Database")
KeyValuePair.Create (UploadDestination.toString Disk, "Disk")
|]
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|> addToHash "rss_model" (EditRssModel.fromRssOptions ctx.WebLog.Rss)
|> addToHash "custom_feeds" (
ctx.WebLog.Rss.CustomFeeds
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|> Array.ofList)
|> addViewContext ctx
let! hash' = TagMapping.withTagMappings ctx hash
return!
addToHash "user_list" (userTemplate.Render hash') hash'
|> addToHash "tag_mapping_list" (tagMapTemplate.Render hash')
|> adminView "settings" next ctx
| Error message -> return! Error.server message next ctx
| Error message -> return! Error.server message next ctx
}
// POST /admin/settings
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data
let! model = ctx.BindFormAsync<SettingsModel> ()
match! data.WebLog.FindById ctx.WebLog.Id with
| Some webLog ->
let oldSlug = webLog.Slug
let webLog = model.update webLog
do! data.WebLog.UpdateSettings webLog
// Update cache
WebLogCache.set webLog
if oldSlug <> webLog.Slug then
// Rename disk directory if it exists
let uploadRoot = Path.Combine ("wwwroot", "upload")
let oldDir = Path.Combine (uploadRoot, oldSlug)
if Directory.Exists oldDir then Directory.Move (oldDir, Path.Combine (uploadRoot, webLog.Slug))
do! addMessage ctx { UserMessage.success with Message = "Web log settings saved successfully" }
return! redirectToGet "admin/settings" next ctx
| None -> return! Error.notFound next ctx
}

View File

@ -124,8 +124,14 @@ let private findPageRevision pgId revDate (ctx : HttpContext) = task {
let previewRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { let previewRevision (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 ->
let _, extra = WebLog.hostAndPath ctx.WebLog
return! {| return! {|
content = $"""<div class="mwl-revision-preview mb-3">{MarkupText.toHtml rev.Text}</div>""" content =
[ """<div class="mwl-revision-preview mb-3">"""
(MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text
"</div>"
]
|> String.concat ""
|} |}
|> makeHash |> adminBareView "" next ctx |> makeHash |> adminBareView "" next ctx
| Some _, Some _ -> return! Error.notAuthorized next ctx | Some _, Some _ -> return! Error.notAuthorized next ctx

View File

@ -329,8 +329,14 @@ let private findPostRevision postId revDate (ctx : HttpContext) = task {
let previewRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task { let previewRevision (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 ->
let _, extra = WebLog.hostAndPath ctx.WebLog
return! {| return! {|
content = $"""<div class="mwl-revision-preview mb-3">{MarkupText.toHtml rev.Text}</div>""" content =
[ """<div class="mwl-revision-preview mb-3">"""
(MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text
"</div>"
]
|> String.concat ""
|} |}
|> makeHash |> adminBareView "" next ctx |> makeHash |> adminBareView "" next ctx
| Some _, Some _ -> return! Error.notAuthorized next ctx | Some _, Some _ -> return! Error.notAuthorized next ctx

View File

@ -106,13 +106,13 @@ let router : HttpHandler = choose [
] ]
subRoute "/admin" (requireUser >=> choose [ subRoute "/admin" (requireUser >=> choose [
GET_HEAD >=> choose [ GET_HEAD >=> choose [
route "/administration" >=> Admin.adminDashboard route "/administration" >=> Admin.Dashboard.admin
subRoute "/categor" (choose [ subRoute "/categor" (choose [
route "ies" >=> Admin.listCategories route "ies" >=> Admin.Category.all
route "ies/bare" >=> Admin.listCategoriesBare route "ies/bare" >=> Admin.Category.bare
routef "y/%s/edit" Admin.editCategory routef "y/%s/edit" Admin.Category.edit
]) ])
route "/dashboard" >=> Admin.dashboard route "/dashboard" >=> Admin.Dashboard.user
route "/my-info" >=> User.myInfo route "/my-info" >=> User.myInfo
subRoute "/page" (choose [ subRoute "/page" (choose [
route "s" >=> Page.all 1 route "s" >=> Page.all 1
@ -131,20 +131,20 @@ let router : HttpHandler = choose [
routef "/%s/revisions" Post.editRevisions routef "/%s/revisions" Post.editRevisions
]) ])
subRoute "/settings" (choose [ subRoute "/settings" (choose [
route "" >=> Admin.settings route "" >=> Admin.WebLog.settings
routef "/rss/%s/edit" Feed.editCustomFeed routef "/rss/%s/edit" Feed.editCustomFeed
subRoute "/user" (choose [ subRoute "/user" (choose [
route "s" >=> User.all route "s" >=> User.all
routef "/%s/edit" User.edit routef "/%s/edit" User.edit
]) ])
subRoute "/tag-mapping" (choose [ subRoute "/tag-mapping" (choose [
route "s" >=> Admin.tagMappings route "s" >=> Admin.TagMapping.all
routef "/%s/edit" Admin.editMapping routef "/%s/edit" Admin.TagMapping.edit
]) ])
]) ])
subRoute "/theme" (choose [ subRoute "/theme" (choose [
route "/list" >=> Admin.listThemes route "/list" >=> Admin.Theme.all
route "/new" >=> Admin.addTheme route "/new" >=> Admin.Theme.add
]) ])
subRoute "/upload" (choose [ subRoute "/upload" (choose [
route "s" >=> Upload.list route "s" >=> Upload.list
@ -153,12 +153,12 @@ let router : HttpHandler = choose [
] ]
POST >=> validateCsrf >=> choose [ POST >=> validateCsrf >=> choose [
subRoute "/cache" (choose [ subRoute "/cache" (choose [
routef "/theme/%s/refresh" Admin.refreshThemeCache routef "/theme/%s/refresh" Admin.Cache.refreshTheme
routef "/web-log/%s/refresh" Admin.refreshWebLogCache routef "/web-log/%s/refresh" Admin.Cache.refreshWebLog
]) ])
subRoute "/category" (choose [ subRoute "/category" (choose [
route "/save" >=> Admin.saveCategory route "/save" >=> Admin.Category.save
routef "/%s/delete" Admin.deleteCategory routef "/%s/delete" Admin.Category.delete
]) ])
route "/my-info" >=> User.saveMyInfo route "/my-info" >=> User.saveMyInfo
subRoute "/page" (choose [ subRoute "/page" (choose [
@ -178,15 +178,15 @@ let router : HttpHandler = choose [
routef "/%s/revisions/purge" Post.purgeRevisions routef "/%s/revisions/purge" Post.purgeRevisions
]) ])
subRoute "/settings" (choose [ subRoute "/settings" (choose [
route "" >=> Admin.saveSettings route "" >=> Admin.WebLog.saveSettings
subRoute "/rss" (choose [ subRoute "/rss" (choose [
route "" >=> Feed.saveSettings route "" >=> Feed.saveSettings
route "/save" >=> Feed.saveCustomFeed route "/save" >=> Feed.saveCustomFeed
routef "/%s/delete" Feed.deleteCustomFeed routef "/%s/delete" Feed.deleteCustomFeed
]) ])
subRoute "/tag-mapping" (choose [ subRoute "/tag-mapping" (choose [
route "/save" >=> Admin.saveMapping route "/save" >=> Admin.TagMapping.save
routef "/%s/delete" Admin.deleteMapping routef "/%s/delete" Admin.TagMapping.delete
]) ])
subRoute "/user" (choose [ subRoute "/user" (choose [
route "/save" >=> User.save route "/save" >=> User.save
@ -194,8 +194,8 @@ let router : HttpHandler = choose [
]) ])
]) ])
subRoute "/theme" (choose [ subRoute "/theme" (choose [
route "/new" >=> Admin.saveTheme route "/new" >=> Admin.Theme.save
routef "/%s/delete" Admin.deleteTheme routef "/%s/delete" Admin.Theme.delete
]) ])
subRoute "/upload" (choose [ subRoute "/upload" (choose [
route "/save" >=> Upload.save route "/save" >=> Upload.save

View File

@ -85,7 +85,7 @@ open System.Text.RegularExpressions
open MyWebLog.ViewModels open MyWebLog.ViewModels
/// Turn a string into a lowercase URL-safe slug /// Turn a string into a lowercase URL-safe slug
let makeSlug it = ((Regex """\s+""").Replace ((Regex "[^A-z0-9 ]").Replace (it, ""), "-")).ToLowerInvariant () let makeSlug it = ((Regex """\s+""").Replace ((Regex "[^A-z0-9 -]").Replace (it, ""), "-")).ToLowerInvariant ()
// GET /admin/uploads // GET /admin/uploads
let list : HttpHandler = requireAccess Author >=> fun next ctx -> task { let list : HttpHandler = requireAccess Author >=> fun next ctx -> task {

View File

@ -51,7 +51,10 @@ let doLogOn : HttpHandler = fun next ctx -> task {
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
do! data.WebLogUser.SetLastSeen user.Id user.WebLogId do! data.WebLogUser.SetLastSeen user.Id user.WebLogId
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with Message = $"Logged on successfully | Welcome to {ctx.WebLog.Name}!" } { UserMessage.success with
Message = "Log on successful"
Detail = Some $"Welcome to {ctx.WebLog.Name}!"
}
return! return!
match model.ReturnTo with match model.ReturnTo with
| Some url -> redirectTo false url next ctx | Some url -> redirectTo false url next ctx

View File

@ -137,13 +137,13 @@ let loadTheme (args : string[]) (sp : IServiceProvider) = task {
match args[1].LastIndexOf Path.DirectorySeparatorChar with match args[1].LastIndexOf Path.DirectorySeparatorChar with
| -1 -> args[1] | -1 -> args[1]
| it -> args[1][(it + 1)..] | it -> args[1][(it + 1)..]
match Handlers.Admin.getThemeIdFromFileName fileName with match Handlers.Admin.Theme.deriveIdFromFileName fileName with
| Ok themeId -> | Ok themeId ->
let data = sp.GetRequiredService<IData> () let data = sp.GetRequiredService<IData> ()
use stream = File.Open (args[1], FileMode.Open) use stream = File.Open (args[1], FileMode.Open)
use copy = new MemoryStream () use copy = new MemoryStream ()
do! stream.CopyToAsync copy do! stream.CopyToAsync copy
let! theme = Handlers.Admin.loadThemeFromZip themeId copy data let! theme = Handlers.Admin.Theme.loadFromZip themeId copy data
let fac = sp.GetRequiredService<ILoggerFactory> () let fac = sp.GetRequiredService<ILoggerFactory> ()
let log = fac.CreateLogger "MyWebLog.Themes" let log = fac.CreateLogger "MyWebLog.Themes"
log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded" log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded"

View File

@ -0,0 +1,32 @@
<div class="form-floating pb-3">
<input type="text" name="Title" id="title" class="form-control" placeholder="Title" autofocus required
value="{{ model.title }}">
<label for="title">Title</label>
</div>
<div class="form-floating pb-3">
<input type="text" name="Permalink" id="permalink" class="form-control" placeholder="Permalink" required
value="{{ model.permalink }}">
<label for="permalink">Permalink</label>
{%- unless model.is_new %}
{%- assign entity_url_base = "admin/" | append: entity | append: "/" | append: entity_id -%}
<span class="form-text">
<a href="{{ entity_url_base | append: "/permalinks" | relative_link }}">Manage Permalinks</a>
<span class="text-muted"> &bull; </span>
<a href="{{ entity_url_base | append: "/revisions" | relative_link }}">Manage Revisions</a>
</span>
{%- endunless -%}
</div>
<div class="mb-2">
<label for="text">Text</label> &nbsp; &nbsp;
<div class="btn-group btn-group-sm" role="group" aria-label="Text format button group">
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
</div>
</div>
<div class="pb-3">
<textarea name="Text" id="text" class="form-control" rows="20">{{ model.text }}</textarea>
</div>

View File

@ -2,24 +2,20 @@
<article> <article>
<fieldset class="container mb-3 pb-0"> <fieldset class="container mb-3 pb-0">
<legend>Themes</legend> <legend>Themes</legend>
<div class="row"> <a href="{{ "admin/theme/new" | relative_link }}" class="btn btn-primary btn-sm mb-3"
<div class="col"> hx-target="#theme_new">
<a href="{{ "admin/theme/new" | relative_link }}" class="btn btn-primary btn-sm mb-3" Upload a New Theme
hx-target="#theme_new"> </a>
Upload a New Theme <div class="container g-0">
</a> {% include_template "_theme-list-columns" %}
<div class="container g-0"> <div class="row mwl-table-heading">
{% include_template "_theme-list-columns" %} <div class="{{ theme_col }}">Theme</div>
<div class="row mwl-table-heading"> <div class="{{ slug_col }} d-none d-md-inline-block">Slug</div>
<div class="{{ theme_col }}">Theme</div> <div class="{{ tmpl_col }} d-none d-md-inline-block">Templates</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 }}
</div> </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/" -%}
@ -27,10 +23,9 @@
<div class="row pb-2"> <div class="row pb-2">
<div class="col"> <div class="col">
<p> <p>
myWebLog uses a few caches to ensure that it serves pages as fast as possible. Normal actions taken within the myWebLog uses a few caches to ensure that it serves pages as fast as possible.
admin area will keep these up to date; however, if changes occur outside of the system (creating a new web log (<a href="https://bitbadger.solutions/open-source/myweblog/advanced.html#cache-management"
via CLI, loading an updated theme via CLI, direct data updates, etc.), these options allow for the caches to target="_blank">more information</a>)
be refreshed without requiring you to restart the application.
</p> </p>
</div> </div>
</div> </div>

View File

@ -7,21 +7,21 @@
<div class="row"> <div class="row">
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3"> <div class="col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
<div class="form-floating"> <div class="form-floating">
<input type="text" name="Name" id="name" class="form-control form-control-sm" placeholder="Name" autofocus <input type="text" name="Name" id="name" class="form-control" placeholder="Name" autofocus required
required value="{{ model.name | escape }}"> value="{{ model.name | escape }}">
<label for="name">Name</label> <label for="name">Name</label>
</div> </div>
</div> </div>
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3"> <div class="col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3">
<div class="form-floating"> <div class="form-floating">
<input type="text" name="Slug" id="slug" class="form-control form-control-sm" placeholder="Slug" required <input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
value="{{ model.slug | escape }}"> value="{{ model.slug | escape }}">
<label for="slug">Slug</label> <label for="slug">Slug</label>
</div> </div>
</div> </div>
<div class="col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3"> <div class="col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
<div class="form-floating"> <div class="form-floating">
<select name="ParentId" id="parentId" class="form-control form-control-sm"> <select name="ParentId" id="parentId" class="form-control">
<option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}> <option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}>
&ndash; None &ndash; &ndash; None &ndash;
</option> </option>
@ -38,7 +38,7 @@
</div> </div>
<div class="col-12 col-xl-10 offset-xl-1 mb-3"> <div class="col-12 col-xl-10 offset-xl-1 mb-3">
<div class="form-floating"> <div class="form-floating">
<input name="Description" id="description" class="form-control form-control-sm" <input name="Description" id="description" class="form-control"
placeholder="A short description of this category" value="{{ model.description | escape }}"> placeholder="A short description of this category" value="{{ model.description | escape }}">
<label for="description">Description</label> <label for="description">Description</label>
</div> </div>

View File

@ -14,18 +14,6 @@
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
{{ htmx_script }} {{ htmx_script }}
<script>
const cssLoaded = [...document.styleSheets].filter(it => it.href.indexOf("bootstrap.min.css") > -1).length > 0
if (!cssLoaded) {
const local = document.createElement("link")
local.rel = "stylesheet"
local.href = "{{ "themes/admin/bootstrap.min.css" | relative_link }}"
document.getElementsByTagName("link")[0].prepend(local)
}
setTimeout(function () {
if (!bootstrap) document.write('<script src=\"{{ "script/bootstrap.bundle.min.js" | relative_link }}\"><\/script>')
}, 2000)
</script>
<script src="{{ "themes/admin/admin.js" | relative_link }}"></script> <script src="{{ "themes/admin/admin.js" | relative_link }}"></script>
</body> </body>
</html> </html>

View File

@ -6,39 +6,9 @@
<div class="container"> <div class="container">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-9"> <div class="col-9">
<div class="form-floating pb-3"> {%- assign entity = "page" -%}
<input type="text" name="Title" id="title" class="form-control" autofocus required {%- assign entity_id = model.page_id -%}
value="{{ model.title }}"> {% include_template "_edit-common" %}
<label for="title">Title</label>
</div>
<div class="form-floating pb-3">
<input type="text" name="Permalink" id="permalink" class="form-control" required
value="{{ model.permalink }}">
<label for="permalink">Permalink</label>
{%- unless model.is_new %}
<span class="form-text">
<a href="{{ "admin/page/" | append: model.page_id | append: "/permalinks" | relative_link }}">
Manage Permalinks
</a>
<span class="text-muted"> &bull; </span>
<a href="{{ "admin/page/" | append: model.page_id | append: "/revisions" | relative_link }}">
Manage Revisions
</a>
</span>
{% endunless -%}
</div>
<div class="mb-2">
<label for="text">Text</label> &nbsp; &nbsp;
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
</div>
<div class="mb-3">
<textarea name="Text" id="text" class="form-control">{{ model.text }}</textarea>
</div>
</div> </div>
<div class="col-3"> <div class="col-3">
<div class="form-floating pb-3"> <div class="form-floating pb-3">

View File

@ -6,41 +6,9 @@
<div class="container"> <div class="container">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12 col-lg-9"> <div class="col-12 col-lg-9">
<div class="form-floating pb-3"> {%- assign entity = "post" -%}
<input type="text" name="Title" id="title" class="form-control" placeholder="Title" autofocus required {%- assign entity_id = model.post_id -%}
value="{{ model.title }}"> {% include_template "_edit-common" %}
<label for="title">Title</label>
</div>
<div class="form-floating pb-3">
<input type="text" name="Permalink" id="permalink" class="form-control" placeholder="Permalink" required
value="{{ model.permalink }}">
<label for="permalink">Permalink</label>
{%- unless model.is_new %}
<span class="form-text">
<a href="{{ "admin/post/" | append: model.post_id | append: "/permalinks" | relative_link }}">
Manage Permalinks
</a>
<span class="text-muted"> &bull; </span>
<a href="{{ "admin/post/" | append: model.post_id | append: "/revisions" | relative_link }}">
Manage Revisions
</a>
</span>
{% endunless -%}
</div>
<div class="mb-2">
<label for="text">Text</label> &nbsp; &nbsp;
<div class="btn-group btn-group-sm" role="group" aria-label="Text format button group">
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
</div>
</div>
<div class="pb-3">
<textarea name="Text" id="text" class="form-control" rows="20">{{ model.text }}</textarea>
</div>
<div class="form-floating pb-3"> <div class="form-floating pb-3">
<input type="text" name="Tags" id="tags" class="form-control" placeholder="Tags" <input type="text" name="Tags" id="tags" class="form-control" placeholder="Tags"
value="{{ model.tags }}"> value="{{ model.tags }}">
@ -61,7 +29,7 @@
<small> <small>
<input type="checkbox" name="IsEpisode" id="isEpisode" class="form-check-input" value="true" <input type="checkbox" name="IsEpisode" id="isEpisode" class="form-check-input" value="true"
data-bs-toggle="collapse" data-bs-target="#episodeItems" onclick="Admin.toggleEpisodeFields()" data-bs-toggle="collapse" data-bs-target="#episodeItems" onclick="Admin.toggleEpisodeFields()"
{%- if model.is_episode %}checked="checked"{% endif %}> {%- if model.is_episode %} checked="checked"{% endif %}>
</small> </small>
<label for="isEpisode">Podcast Episode</label> <label for="isEpisode">Podcast Episode</label>
</span> </span>
@ -344,3 +312,4 @@
</div> </div>
</form> </form>
</article> </article>
<script>window.setTimeout(() => Admin.toggleEpisodeFields(), 500)</script>

View File

@ -6,263 +6,241 @@
</p> </p>
<fieldset class="container mb-3"> <fieldset class="container mb-3">
<legend>Web Log Settings</legend> <legend>Web Log Settings</legend>
<div class="row"> <form action="{{ "admin/settings" | relative_link }}" method="post">
<div class="col"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<form action="{{ "admin/settings" | relative_link }}" method="post"> <div class="container">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <div class="row">
<div class="container"> <div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="row"> <div class="form-floating">
<div class="col-12 col-md-6 col-xl-4 pb-3"> <input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus
<div class="form-floating"> value="{{ model.name }}">
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus <label for="name">Name</label>
value="{{ model.name }}">
<label for="name">Name</label>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
value="{{ model.slug }}">
<label for="slug">Slug</label>
<span class="form-text">
<span class="badge rounded-pill bg-warning text-dark">WARNING</span> changing this value may break
links
(<a href="https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
target="_blank">more</a>)
</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
value="{{ model.subtitle }}">
<label for="subtitle">Subtitle</label>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 offset-xl-1 pb-3">
<div class="form-floating">
<select name="ThemeId" id="themeId" class="form-control" required>
{% for theme in themes -%}
<option value="{{ theme[0] }}"{% if model.theme_id == theme[0] %} selected="selected"{% endif %}>
{{ theme[1] }}
</option>
{%- endfor %}
</select>
<label for="themeId">Theme</label>
</div>
</div>
<div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3">
<div class="form-floating">
<select name="DefaultPage" id="defaultPage" class="form-control" required>
{% for pg in pages -%}
<option value="{{ pg[0] }}"
{%- if pg[0] == model.default_page %} selected="selected"{% endif %}>
{{ pg[1] }}
</option>
{%- endfor %}
</select>
<label for="defaultPage">Default Page</label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-2 pb-3">
<div class="form-floating">
<input type="number" name="PostsPerPage" id="postsPerPage" class="form-control" min="0" max="50"
required value="{{ model.posts_per_page }}">
<label for="postsPerPage">Posts per Page</label>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-4 col-xl-3 offset-xl-2 pb-3">
<div class="form-floating">
<input type="text" name="TimeZone" id="timeZone" class="form-control" placeholder="Time Zone" required
value="{{ model.time_zone }}">
<label for="timeZone">Time Zone</label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-2">
<div class="form-check form-switch">
<input type="checkbox" name="AutoHtmx" id="autoHtmx" class="form-check-input" value="true"
{%- if model.auto_htmx %} checked="checked"{% endif %}>
<label for="autoHtmx" class="form-check-label">Auto-Load htmx</label>
</div>
<span class="form-text fst-italic">
<a href="https://htmx.org" target="_blank" rel="noopener">What is this?</a>
</span>
</div>
<div class="col-12 col-md-4 col-xl-3 pb-3">
<div class="form-floating">
<select name="Uploads" id="uploads" class="form-control">
{%- for it in upload_values %}
<option value="{{ it[0] }}"
{%- if model.uploads == it[0] %} selected{% endif %}>{{ it[1] }}</option>
{%- endfor %}
</select>
<label for="uploads">Default Upload Destination</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div> </div>
</div> </div>
</form> <div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
value="{{ model.slug }}">
<label for="slug">Slug</label>
<span class="form-text">
<span class="badge rounded-pill bg-warning text-dark">WARNING</span> changing this value may break
links
(<a href="https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
target="_blank">more</a>)
</span>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
value="{{ model.subtitle }}">
<label for="subtitle">Subtitle</label>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 offset-xl-1 pb-3">
<div class="form-floating">
<select name="ThemeId" id="themeId" class="form-control" required>
{% for theme in themes -%}
<option value="{{ theme[0] }}"{% if model.theme_id == theme[0] %} selected="selected"{% endif %}>
{{ theme[1] }}
</option>
{%- endfor %}
</select>
<label for="themeId">Theme</label>
</div>
</div>
<div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3">
<div class="form-floating">
<select name="DefaultPage" id="defaultPage" class="form-control" required>
{%- for pg in pages %}
<option value="{{ pg[0] }}"{% if pg[0] == model.default_page %} selected="selected"{% endif %}>
{{ pg[1] }}
</option>
{%- endfor %}
</select>
<label for="defaultPage">Default Page</label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-2 pb-3">
<div class="form-floating">
<input type="number" name="PostsPerPage" id="postsPerPage" class="form-control" min="0" max="50" required
value="{{ model.posts_per_page }}">
<label for="postsPerPage">Posts per Page</label>
</div>
</div>
</div>
<div class="row">
<div class="col-12 col-md-4 col-xl-3 offset-xl-2 pb-3">
<div class="form-floating">
<input type="text" name="TimeZone" id="timeZone" class="form-control" placeholder="Time Zone" required
value="{{ model.time_zone }}">
<label for="timeZone">Time Zone</label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-2">
<div class="form-check form-switch">
<input type="checkbox" name="AutoHtmx" id="autoHtmx" class="form-check-input" value="true"
{%- if model.auto_htmx %} checked="checked"{% endif %}>
<label for="autoHtmx" class="form-check-label">Auto-Load htmx</label>
</div>
<span class="form-text fst-italic">
<a href="https://htmx.org" target="_blank" rel="noopener">What is this?</a>
</span>
</div>
<div class="col-12 col-md-4 col-xl-3 pb-3">
<div class="form-floating">
<select name="Uploads" id="uploads" class="form-control">
{%- for it in upload_values %}
<option value="{{ it[0] }}"{% if model.uploads == it[0] %} selected{% endif %}>{{ it[1] }}</option>
{%- endfor %}
</select>
<label for="uploads">Default Upload Destination</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div> </div>
</div> </form>
</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>
<div class="row"> {% include_template "_user-list-columns" %}
<div class="col"> <a href="{{ "admin/settings/user/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
{% include_template "_user-list-columns" %} hx-target="#user_new">
<a href="{{ "admin/settings/user/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3" Add a New User
hx-target="#user_new"> </a>
Add a New User <div class="container g-0">
</a> <div class="row mwl-table-heading">
<div class="container g-0"> <div class="{{ user_col }}">User<span class="d-md-none">; Full Name / E-mail; Last Log On</span></div>
<div class="row mwl-table-heading"> <div class="{{ email_col }} d-none d-md-inline-block">Full Name / E-mail</div>
<div class="{{ user_col }}">User<span class="d-md-none">; Full Name / E-mail; Last Log On</span></div> <div class="{{ cre8_col }}">Created</div>
<div class="{{ email_col }} d-none d-md-inline-block">Full Name / E-mail</div> <div class="{{ last_col }} d-none d-md-block">Last Log On</div>
<div class="{{ cre8_col }}">Created</div>
<div class="{{ last_col }} d-none d-md-block">Last Log On</div>
</div>
</div>
{{ user_list }}
</div> </div>
</div> </div>
{{ user_list }}
</fieldset> </fieldset>
<fieldset id="rss-settings" class="container mb-3 pb-0"> <fieldset id="rss-settings" class="container mb-3 pb-0">
<legend>RSS Settings</legend> <legend>RSS Settings</legend>
<div class="row"> <form action="{{ "admin/settings/rss" | relative_link }}" method="post">
<div class="col"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<form action="{{ "admin/settings/rss" | relative_link }}" method="post"> <div class="container">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <div class="row pb-3">
<div class="container"> <div class="col col-xl-8 offset-xl-2">
<div class="row pb-3"> <fieldset class="d-flex justify-content-evenly flex-row">
<div class="col col-xl-8 offset-xl-2"> <legend>Feeds Enabled</legend>
<fieldset class="d-flex justify-content-evenly flex-row"> <div class="form-check form-switch pb-2">
<legend>Feeds Enabled</legend> <input type="checkbox" name="IsFeedEnabled" id="feedEnabled" class="form-check-input" value="true"
<div class="form-check form-switch pb-2"> {%- if rss_model.is_feed_enabled %} checked="checked"{% endif %}>
<input type="checkbox" name="IsFeedEnabled" id="feedEnabled" class="form-check-input" value="true" <label for="feedEnabled" class="form-check-label">All Posts</label>
{%- if rss_model.is_feed_enabled %} checked="checked"{% endif %}>
<label for="feedEnabled" class="form-check-label">All Posts</label>
</div>
<div class="form-check form-switch pb-2">
<input type="checkbox" name="IsCategoryEnabled" id="categoryEnabled" class="form-check-input"
value="true" {%- if rss_model.is_category_enabled %} checked="checked"{% endif %}>
<label for="categoryEnabled" class="form-check-label">Posts by Category</label>
</div>
<div class="form-check form-switch pb-2">
<input type="checkbox" name="IsTagEnabled" id="tagEnabled" class="form-check-input" value="true"
{%- if rss_model.tag_enabled %} checked="checked"{% endif %}>
<label for="tagEnabled" class="form-check-label">Posts by Tag</label>
</div>
</fieldset>
</div> </div>
</div> <div class="form-check form-switch pb-2">
<div class="row"> <input type="checkbox" name="IsCategoryEnabled" id="categoryEnabled" class="form-check-input"
<div class="col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3"> value="true" {%- if rss_model.is_category_enabled %} checked="checked"{% endif %}>
<div class="form-floating"> <label for="categoryEnabled" class="form-check-label">Posts by Category</label>
<input type="text" name="FeedName" id="feedName" class="form-control" placeholder="Feed File Name"
value="{{ rss_model.feed_name }}">
<label for="feedName">Feed File Name</label>
<span class="form-text">Default is <code>feed.xml</code></span>
</div>
</div> </div>
<div class="col-12 col-sm-6 col-md-4 col-xl-2 pb-3"> <div class="form-check form-switch pb-2">
<div class="form-floating"> <input type="checkbox" name="IsTagEnabled" id="tagEnabled" class="form-check-input" value="true"
<input type="number" name="ItemsInFeed" id="itemsInFeed" class="form-control" min="0" {%- if rss_model.tag_enabled %} checked="checked"{% endif %}>
placeholder="Items in Feed" required value="{{ rss_model.items_in_feed }}"> <label for="tagEnabled" class="form-check-label">Posts by Tag</label>
<label for="itemsInFeed">Items in Feed</label>
<span class="form-text">Set to &ldquo;0&rdquo; to use &ldquo;Posts per Page&rdquo; setting ({{ web_log.posts_per_page }})</span>
</div>
</div>
<div class="col-12 col-md-5 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Copyright" id="copyright" class="form-control" placeholder="Copyright String"
value="{{ rss_model.copyright }}">
<label for="copyright">Copyright String</label>
<span class="form-text">
Can be a
<a href="https://creativecommons.org/share-your-work/" target="_blank" rel="noopener">
Creative Commons license string
</a>
</span>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div> </div>
</fieldset>
</div>
</div>
<div class="row">
<div class="col-12 col-sm-6 col-md-3 col-xl-2 offset-xl-2 pb-3">
<div class="form-floating">
<input type="text" name="FeedName" id="feedName" class="form-control" placeholder="Feed File Name"
value="{{ rss_model.feed_name }}">
<label for="feedName">Feed File Name</label>
<span class="form-text">Default is <code>feed.xml</code></span>
</div> </div>
</div> </div>
</form> <div class="col-12 col-sm-6 col-md-4 col-xl-2 pb-3">
<fieldset class="container mb-3 pb-0"> <div class="form-floating">
<legend>Custom Feeds</legend> <input type="number" name="ItemsInFeed" id="itemsInFeed" class="form-control" min="0"
<div class="row"> placeholder="Items in Feed" required value="{{ rss_model.items_in_feed }}">
<div class="col"> <label for="itemsInFeed">Items in Feed</label>
<a class="btn btn-sm btn-secondary" href="{{ 'admin/settings/rss/new/edit' | relative_link }}"> <span class="form-text">Set to &ldquo;0&rdquo; to use &ldquo;Posts per Page&rdquo; setting ({{ web_log.posts_per_page }})</span>
Add a New Custom Feed
</a>
{%- assign feed_count = custom_feeds | size -%}
{% if feed_count > 0 %}
<form method="post" class="container g-0" hx-target="body">
{%- assign source_col = "col-12 col-md-6" -%}
{%- assign path_col = "col-12 col-md-6" -%}
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="row mwl-table-heading">
<div class="{{ source_col }}">
<span class="d-md-none">Feed</span><span class="d-none d-md-inline">Source</span>
</div>
<div class="{{ path_col }} d-none d-md-inline-block">Relative Path</div>
</div>
{% for feed in custom_feeds %}
<div class="row mwl-table-detail">
<div class="{{ source_col }}">
{{ feed.source }}
{%- if feed.is_podcast %} &nbsp; <span class="badge bg-primary">PODCAST</span>{% endif %}<br>
<small>
{%- assign feed_url = "admin/settings/rss/" | append: feed.id -%}
<a href="{{ feed.path | relative_link }}" target="_blank">View Feed</a>
<span class="text-muted"> &bull; </span>
<a href="{{ feed_url | append: "/edit" | relative_link }}">Edit</a>
<span class="text-muted"> &bull; </span>
{%- assign feed_del_link = feed_url | append: "/delete" | relative_link -%}
<a href="{{ feed_del_link }}" hx-post="{{ feed_del_link }}" class="text-danger"
hx-confirm="Are you sure you want to delete the custom RSS feed based on {{ feed.source | strip_html | escape }}? This action cannot be undone.">
Delete
</a>
</small>
</div>
<div class="{{ path_col }}">
<small class="d-md-none">Served at {{ feed.path }}</small>
<span class="d-none d-md-inline">{{ feed.path }}</span>
</div>
</div>
{% endfor %}
</form>
{% else %}
<p class="text-muted fst-italic text-center">No custom feeds defined</p>
{% endif %}
</div> </div>
</div> </div>
</fieldset> <div class="col-12 col-md-5 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Copyright" id="copyright" class="form-control" placeholder="Copyright String"
value="{{ rss_model.copyright }}">
<label for="copyright">Copyright String</label>
<span class="form-text">
Can be a
<a href="https://creativecommons.org/share-your-work/" target="_blank" rel="noopener">
Creative Commons license string
</a>
</span>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div> </div>
</div> </form>
<fieldset class="container mb-3 pb-0">
<legend>Custom Feeds</legend>
<a class="btn btn-sm btn-secondary" href="{{ 'admin/settings/rss/new/edit' | relative_link }}">
Add a New Custom Feed
</a>
{%- assign feed_count = custom_feeds | size -%}
{%- if feed_count > 0 %}
<form method="post" class="container g-0" hx-target="body">
{%- assign source_col = "col-12 col-md-6" -%}
{%- assign path_col = "col-12 col-md-6" -%}
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="row mwl-table-heading">
<div class="{{ source_col }}">
<span class="d-md-none">Feed</span><span class="d-none d-md-inline">Source</span>
</div>
<div class="{{ path_col }} d-none d-md-inline-block">Relative Path</div>
</div>
{% for feed in custom_feeds %}
<div class="row mwl-table-detail">
<div class="{{ source_col }}">
{{ feed.source }}
{%- if feed.is_podcast %} &nbsp; <span class="badge bg-primary">PODCAST</span>{% endif %}<br>
<small>
{%- assign feed_url = "admin/settings/rss/" | append: feed.id -%}
<a href="{{ feed.path | relative_link }}" target="_blank">View Feed</a>
<span class="text-muted"> &bull; </span>
<a href="{{ feed_url | append: "/edit" | relative_link }}">Edit</a>
<span class="text-muted"> &bull; </span>
{%- assign feed_del_link = feed_url | append: "/delete" | relative_link -%}
<a href="{{ feed_del_link }}" hx-post="{{ feed_del_link }}" class="text-danger"
hx-confirm="Are you sure you want to delete the custom RSS feed based on {{ feed.source | strip_html | escape }}? This action cannot be undone.">
Delete
</a>
</small>
</div>
<div class="{{ path_col }}">
<small class="d-md-none">Served at {{ feed.path }}</small>
<span class="d-none d-md-inline">{{ feed.path }}</span>
</div>
</div>
{%- endfor %}
</form>
{%- else %}
<p class="text-muted fst-italic text-center">No custom feeds defined</p>
{%- endif %}
</fieldset>
</fieldset> </fieldset>
<fieldset id="tag-mappings" class="container mb-3 pb-0"> <fieldset id="tag-mappings" class="container mb-3 pb-0">
<legend>Tag Mappings</legend> <legend>Tag Mappings</legend>
<div class="row"> <a href="{{ "admin/settings/tag-mapping/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
<div class="col"> hx-target="#tag_new">
<a href="{{ "admin/settings/tag-mapping/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3" Add a New Tag Mapping
hx-target="#tag_new"> </a>
Add a New Tag Mapping {{ tag_mapping_list }}
</a>
{{ tag_mapping_list }}
</div>
</div>
</fieldset> </fieldset>
</article> </article>

View File

@ -20,7 +20,7 @@
<div class="row mwl-table-detail"> <div class="row mwl-table-detail">
<div class="col-6"> <div class="col-6">
{%- capture badge_class -%} {%- capture badge_class -%}
{%- if file.source == "disk" %}secondary{% else %}primary{% endif -%} {%- if file.source == "Disk" %}secondary{% else %}primary{% endif -%}
{%- endcapture -%} {%- endcapture -%}
{%- assign path_and_name = file.path | append: file.name -%} {%- assign path_and_name = file.path | append: file.name -%}
{%- assign blog_rel = upload_path | append: path_and_name -%} {%- assign blog_rel = upload_path | append: path_and_name -%}
@ -49,7 +49,7 @@
{% if is_web_log_admin %} {% if is_web_log_admin %}
<span class="text-muted"> &bull; </span> <span class="text-muted"> &bull; </span>
{%- capture delete_url -%} {%- capture delete_url -%}
{%- if file.source == "disk" -%} {%- if file.source == "Disk" -%}
admin/upload/delete/{{ path_and_name }} admin/upload/delete/{{ path_and_name }}
{%- else -%} {%- else -%}
admin/upload/{{ file.id }}/delete admin/upload/{{ file.id }}/delete

View File

@ -13,11 +13,11 @@
<div class="col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around"> <div class="col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around">
Destination<br> Destination<br>
<div class="btn-group" role="group" aria-label="Upload destination button group"> <div class="btn-group" role="group" aria-label="Upload destination button group">
<input type="radio" name="Destination" id="destination_db" class="btn-check" value="database" <input type="radio" name="Destination" id="destination_db" class="btn-check" value="Database"
{%- if destination == "database" %} checked="checked"{% endif %}> {%- if destination == "Database" %} checked="checked"{% endif %}>
<label class="btn btn-outline-primary" for="destination_db">Database</label> <label class="btn btn-outline-primary" for="destination_db">Database</label>
<input type="radio" name="Destination" id="destination_disk" class="btn-check" value="disk" <input type="radio" name="Destination" id="destination_disk" class="btn-check" value="Disk"
{%- if destination == "disk" %} checked="checked"{% endif %}> {%- if destination == "Disk" %} checked="checked"{% endif %}>
<label class="btn btn-outline-secondary" for="destination_disk">Disk</label> <label class="btn btn-outline-secondary" for="destination_disk">Disk</label>
</div> </div>
</div> </div>

View File

@ -2,52 +2,59 @@
<h1 class="index-title">{{ page_title }}</h1> <h1 class="index-title">{{ page_title }}</h1>
{%- if subtitle %}<h4 class="text-muted">{{ subtitle }}</h4>{% endif -%} {%- if subtitle %}<h4 class="text-muted">{{ subtitle }}</h4>{% endif -%}
{% endif %} {% endif %}
<section class="container mt-3" aria-label="The posts for the page"> {%- assign post_count = model.posts | size -%}
{% for post in model.posts %} {%- if post_count > 0 %}
<article> <section class="container mt-3" aria-label="The posts for the page">
<h1> {%- for post in model.posts %}
<a href="{{ post | relative_link }}" title="Permanent link to &quot;{{ post.title | escape }}&quot;"> <article>
{{ post.title }} <h1>
</a> <a href="{{ post | relative_link }}" title="Permanent link to &quot;{{ post.title | escape }}&quot;">
</h1> {{ post.title }}
<p> </a>
Published on {{ post.published_on | date: "MMMM d, yyyy" }} </h1>
at {{ post.published_on | date: "h:mmtt" | downcase }} <p>
by {{ model.authors | value: post.author_id }} Published on {{ post.published_on | date: "MMMM d, yyyy" }}
</p> at {{ post.published_on | date: "h:mmtt" | downcase }}
{{ post.text }} by {{ model.authors | value: post.author_id }}
{%- assign category_count = post.category_ids | size -%} </p>
{%- assign tag_count = post.tags | size -%} {{ post.text }}
{% if category_count > 0 or tag_count > 0 %} {%- assign category_count = post.category_ids | size -%}
<footer> {%- assign tag_count = post.tags | size -%}
<p> {% if category_count > 0 or tag_count > 0 %}
{%- if category_count > 0 -%} <footer>
Categorized under: <p>
{% for cat in post.category_ids -%} {%- if category_count > 0 -%}
{%- assign this_cat = categories | where: "Id", cat | first -%} Categorized under:
{{ this_cat.name }}{% unless forloop.last %}, {% endunless %} {% for cat in post.category_ids -%}
{%- assign cat_names = this_cat.name | concat: cat_names -%} {%- assign this_cat = categories | where: "Id", cat | first -%}
{%- endfor -%} {{ this_cat.name }}{% unless forloop.last %}, {% endunless %}
{%- assign cat_names = "" -%} {%- assign cat_names = this_cat.name | concat: cat_names -%}
<br> {%- endfor -%}
{% endif -%} {%- assign cat_names = "" -%}
{%- if tag_count > 0 %} <br>
Tagged: {{ post.tags | join: ", " }} {% endif -%}
{% endif -%} {%- if tag_count > 0 %}
</p> Tagged: {{ post.tags | join: ", " }}
</footer> {% endif -%}
</p>
</footer>
{% endif %}
<hr>
</article>
{% endfor %}
</section>
<nav aria-label="pagination">
<ul class="pagination justify-content-evenly mt-2">
{% if model.newer_link -%}
<li class="page-item"><a class="page-link" href="{{ model.newer_link.value }}">&laquo; Newer Posts</a></li>
{% endif %} {% endif %}
<hr> {% if model.older_link -%}
</article> <li class="page-item"><a class="page-link" href="{{ model.older_link.value }}">Older Posts &raquo;</a></li>
{% endfor %} {%- endif -%}
</section> </ul>
<nav aria-label="pagination"> </nav>
<ul class="pagination justify-content-evenly mt-2"> {%- else %}
{% if model.newer_link -%} <article>
<li class="page-item"><a class="page-link" href="{{ model.newer_link.value }}">&laquo; Newer Posts</a></li> <p class="text-center mt-3">No posts found</p>
{% endif %} </article>
{% if model.older_link -%} {%- endif %}
<li class="page-item"><a class="page-link" href="{{ model.older_link.value }}">Older Posts &raquo;</a></li>
{%- endif -%}
</ul>
</nav>

View File

@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<title>{{ page_title | strip_html }}{% if page_title %} &laquo; {% endif %}{{ web_log.name | strip_html }}</title> <title>{{ page_title | strip_html }}{% if page_title %} &laquo; {% endif %}{{ web_log.name | strip_html }}</title>
{% page_head -%} {% page_head -%}
</head> </head>
@ -55,8 +55,8 @@
<img src="{{ "themes/admin/logo-dark.png" | relative_link }}" alt="myWebLog" width="120" height="34"> <img src="{{ "themes/admin/logo-dark.png" | relative_link }}" alt="myWebLog" width="120" height="34">
</div> </div>
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
</body> </body>
</html> </html>