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.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
type ICategoryData =
@ -18,7 +28,7 @@ type ICategoryData =
abstract member CountTopLevel : WebLogId -> Task<int>
/// 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
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 {
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
do! rethink {
withTable Table.Post
@ -291,8 +305,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
delete
write; withRetryDefault; ignoreResult conn
}
return true
| None -> return false
return if children = 0 then CategoryDeleted else ReassignedChildCategories
| None -> return CategoryNotFound
}
member _.Restore cats = backgroundTask {

View File

@ -122,13 +122,23 @@ type SQLiteCategoryData (conn : SqliteConnection) =
/// Delete a category
let delete catId webLogId = backgroundTask {
match! findById catId webLogId with
| Some _ ->
| Some cat ->
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
cmd.CommandText <- """
DELETE FROM post_category
WHERE category_id = @id
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)
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
do! write cmd
@ -137,8 +147,8 @@ type SQLiteCategoryData (conn : SqliteConnection) =
cmd.Parameters.Clear ()
cmd.Parameters.Add catIdParameter |> ignore
do! write cmd
return true
| None -> return false
return if children = 0 then CategoryDeleted else ReassignedChildCategories
| None -> return CategoryNotFound
}
/// 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
/// 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
[<NoComparison; NoEquality>]
type DashboardModel =
@ -147,7 +158,7 @@ type DisplayPage =
UpdatedOn = page.UpdatedOn
IsInPageList = page.IsInPageList
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
}
@ -1061,7 +1072,7 @@ type PostListItem =
Permalink = Permalink.toString post.Permalink
PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable
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
Tags = post.Tags
Episode = post.Episode

View File

@ -6,484 +6,513 @@ open Giraffe
open MyWebLog
open MyWebLog.ViewModels
// ~~ DASHBOARDS ~~
// GET /admin/dashboard
let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id
let data = ctx.Data
let posts = getCount (data.Post.CountByStatus Published)
let drafts = getCount (data.Post.CountByStatus Draft)
let pages = getCount data.Page.CountAll
let listed = getCount data.Page.CountListed
let cats = getCount data.Category.CountAll
let topCats = getCount data.Category.CountTopLevel
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
/// ~~ DASHBOARDS ~~
module Dashboard =
// GET /admin/dashboard
let user : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id
let data = ctx.Data
let posts = getCount (data.Post.CountByStatus Published)
let drafts = getCount (data.Post.CountByStatus Draft)
let pages = getCount data.Page.CountAll
let listed = getCount data.Page.CountListed
let cats = getCount data.Category.CountAll
let topCats = getCount data.Category.CountTopLevel
let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats)
return!
addToHash "theme_list" (bodyTemplate.Render hash) hash
|> adminView "admin-dashboard" next ctx
| Error message -> return! Error.server message next ctx
}
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 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
let toAdminDashboard : HttpHandler = redirectToGet "admin/administration"
// ~~ CACHES ~~
// POST /admin/cache/web-log/{id}/refresh
let refreshWebLogCache webLogId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let data = ctx.Data
if webLogId = "all" then
do! WebLogCache.fill data
for webLog in WebLogCache.all () do
do! PageListCache.refresh webLog data
do! CategoryCache.refresh webLog.Id data
do! addMessage ctx { UserMessage.success with Message = "Successfully refresh web log cache for all web logs" }
else
match! data.WebLog.FindById (WebLogId webLogId) with
| Some webLog ->
WebLogCache.set webLog
do! PageListCache.refresh webLog data
do! CategoryCache.refresh webLog.Id data
/// ~~ CACHES ~~
module Cache =
// POST /admin/cache/web-log/{id}/refresh
let refreshWebLog webLogId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let data = ctx.Data
if webLogId = "all" then
do! WebLogCache.fill data
for webLog in WebLogCache.all () do
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
}
{ UserMessage.success with Message = "Successfully refresh web log cache for all web logs" }
else
match! data.WebLog.FindById (WebLogId webLogId) with
| 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
let refreshThemeCache themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let data = ctx.Data
if themeId = "all" then
TemplateCache.empty ()
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
// POST /admin/cache/theme/{id}/refresh
let refreshTheme themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
let data = ctx.Data
if themeId = "all" then
TemplateCache.empty ()
do! ThemeAssetCache.fill data
do! addMessage ctx
{ 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 ->
do! addMessage ctx { UserMessage.error with Message = $"No theme exists with ID {themeId}" }
return! toAdminDashboard next ctx
}
// ~~ 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
else
match! data.Theme.FindById (ThemeId themeId) with
| Some theme ->
TemplateCache.invalidateTheme theme.Id
do! ThemeAssetCache.refreshTheme theme.Id data
do! addMessage ctx
{ 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
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
}
| None ->
do! addMessage ctx { UserMessage.error with Message = $"No theme exists with ID {themeId}" }
return! toAdminDashboard 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 ~~
open System.Collections.Generic
// 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' = 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))
/// ~~ CATEGORIES ~~
module Category =
do! addMessage ctx { UserMessage.success with Message = "Web log settings saved successfully" }
return! redirectToGet "admin/settings" next ctx
| None -> return! Error.notFound next ctx
}
open MyWebLog.Data
// GET /admin/categories
let all : 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 bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
hashForPage "Categories"
|> withAntiCsrf ctx
|> adminBareView "category-list-body" next ctx
// GET /admin/category/{id}/edit
let edit 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 save : 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! 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 {
match! findPageRevision pgId revDate ctx with
| Some pg, Some rev when canEdit pg.AuthorId ctx ->
let _, extra = WebLog.hostAndPath ctx.WebLog
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
| 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 {
match! findPostRevision postId revDate ctx with
| Some post, Some rev when canEdit post.AuthorId ctx ->
let _, extra = WebLog.hostAndPath ctx.WebLog
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
| Some _, Some _ -> return! Error.notAuthorized next ctx

View File

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

View File

@ -85,7 +85,7 @@ open System.Text.RegularExpressions
open MyWebLog.ViewModels
/// 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
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))
do! data.WebLogUser.SetLastSeen user.Id user.WebLogId
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!
match model.ReturnTo with
| 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
| -1 -> args[1]
| it -> args[1][(it + 1)..]
match Handlers.Admin.getThemeIdFromFileName fileName with
match Handlers.Admin.Theme.deriveIdFromFileName fileName with
| Ok themeId ->
let data = sp.GetRequiredService<IData> ()
use stream = File.Open (args[1], FileMode.Open)
use copy = new MemoryStream ()
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 log = fac.CreateLogger "MyWebLog.Themes"
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>
<fieldset class="container mb-3 pb-0">
<legend>Themes</legend>
<div class="row">
<div class="col">
<a href="{{ "admin/theme/new" | relative_link }}" class="btn btn-primary btn-sm mb-3"
hx-target="#theme_new">
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 }}
<a href="{{ "admin/theme/new" | relative_link }}" class="btn btn-primary btn-sm mb-3"
hx-target="#theme_new">
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 class="container mb-3 pb-0">
{%- assign cache_base_url = "admin/cache/" -%}
@ -27,10 +23,9 @@
<div class="row pb-2">
<div class="col">
<p>
myWebLog uses a few caches to ensure that it serves pages as fast as possible. Normal actions taken within the
admin area will keep these up to date; however, if changes occur outside of the system (creating a new web log
via CLI, loading an updated theme via CLI, direct data updates, etc.), these options allow for the caches to
be refreshed without requiring you to restart the application.
myWebLog uses a few caches to ensure that it serves pages as fast as possible.
(<a href="https://bitbadger.solutions/open-source/myweblog/advanced.html#cache-management"
target="_blank">more information</a>)
</p>
</div>
</div>

View File

@ -7,21 +7,21 @@
<div class="row">
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
<div class="form-floating">
<input type="text" name="Name" id="name" class="form-control form-control-sm" placeholder="Name" autofocus
required value="{{ model.name | escape }}">
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" autofocus required
value="{{ model.name | escape }}">
<label for="name">Name</label>
</div>
</div>
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3">
<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 }}">
<label for="slug">Slug</label>
</div>
</div>
<div class="col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
<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 %}>
&ndash; None &ndash;
</option>
@ -38,7 +38,7 @@
</div>
<div class="col-12 col-xl-10 offset-xl-1 mb-3">
<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 }}">
<label for="description">Description</label>
</div>

View File

@ -14,18 +14,6 @@
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
crossorigin="anonymous"></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>
</body>
</html>

View File

@ -6,39 +6,9 @@
<div class="container">
<div class="row mb-3">
<div class="col-9">
<div class="form-floating pb-3">
<input type="text" name="Title" id="title" class="form-control" 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" 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>
{%- assign entity = "page" -%}
{%- assign entity_id = model.page_id -%}
{% include_template "_edit-common" %}
</div>
<div class="col-3">
<div class="form-floating pb-3">

View File

@ -6,41 +6,9 @@
<div class="container">
<div class="row mb-3">
<div class="col-12 col-lg-9">
<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 %}
<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>
{%- assign entity = "post" -%}
{%- assign entity_id = model.post_id -%}
{% include_template "_edit-common" %}
<div class="form-floating pb-3">
<input type="text" name="Tags" id="tags" class="form-control" placeholder="Tags"
value="{{ model.tags }}">
@ -61,7 +29,7 @@
<small>
<input type="checkbox" name="IsEpisode" id="isEpisode" class="form-check-input" value="true"
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>
<label for="isEpisode">Podcast Episode</label>
</span>
@ -344,3 +312,4 @@
</div>
</form>
</article>
<script>window.setTimeout(() => Admin.toggleEpisodeFields(), 500)</script>

View File

@ -6,263 +6,241 @@
</p>
<fieldset class="container mb-3">
<legend>Web Log Settings</legend>
<div class="row">
<div class="col">
<form action="{{ "admin/settings" | relative_link }}" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container">
<div class="row">
<div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus
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>
<form action="{{ "admin/settings" | relative_link }}" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container">
<div class="row">
<div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus
value="{{ model.name }}">
<label for="name">Name</label>
</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>
</form>
</fieldset>
<fieldset id="users" class="container mb-3 pb-0">
<legend>Users</legend>
<div class="row">
<div class="col">
{% 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>
{{ user_list }}
{% 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>
{{ user_list }}
</fieldset>
<fieldset id="rss-settings" class="container mb-3 pb-0">
<legend>RSS Settings</legend>
<div class="row">
<div class="col">
<form action="{{ "admin/settings/rss" | relative_link }}" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container">
<div class="row pb-3">
<div class="col col-xl-8 offset-xl-2">
<fieldset class="d-flex justify-content-evenly flex-row">
<legend>Feeds Enabled</legend>
<div class="form-check form-switch pb-2">
<input type="checkbox" name="IsFeedEnabled" id="feedEnabled" class="form-check-input" value="true"
{%- 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>
<form action="{{ "admin/settings/rss" | relative_link }}" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container">
<div class="row pb-3">
<div class="col col-xl-8 offset-xl-2">
<fieldset class="d-flex justify-content-evenly flex-row">
<legend>Feeds Enabled</legend>
<div class="form-check form-switch pb-2">
<input type="checkbox" name="IsFeedEnabled" id="feedEnabled" class="form-check-input" value="true"
{%- if rss_model.is_feed_enabled %} checked="checked"{% endif %}>
<label for="feedEnabled" class="form-check-label">All Posts</label>
</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 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="col-12 col-sm-6 col-md-4 col-xl-2 pb-3">
<div class="form-floating">
<input type="number" name="ItemsInFeed" id="itemsInFeed" class="form-control" min="0"
placeholder="Items in Feed" required value="{{ rss_model.items_in_feed }}">
<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 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 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>
</form>
<fieldset class="container mb-3 pb-0">
<legend>Custom Feeds</legend>
<div class="row">
<div class="col">
<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 %}
<div class="col-12 col-sm-6 col-md-4 col-xl-2 pb-3">
<div class="form-floating">
<input type="number" name="ItemsInFeed" id="itemsInFeed" class="form-control" min="0"
placeholder="Items in Feed" required value="{{ rss_model.items_in_feed }}">
<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>
</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>
</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 id="tag-mappings" class="container mb-3 pb-0">
<legend>Tag Mappings</legend>
<div class="row">
<div class="col">
<a href="{{ "admin/settings/tag-mapping/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
hx-target="#tag_new">
Add a New Tag Mapping
</a>
{{ tag_mapping_list }}
</div>
</div>
<a href="{{ "admin/settings/tag-mapping/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
hx-target="#tag_new">
Add a New Tag Mapping
</a>
{{ tag_mapping_list }}
</fieldset>
</article>

View File

@ -20,7 +20,7 @@
<div class="row mwl-table-detail">
<div class="col-6">
{%- capture badge_class -%}
{%- if file.source == "disk" %}secondary{% else %}primary{% endif -%}
{%- if file.source == "Disk" %}secondary{% else %}primary{% endif -%}
{%- endcapture -%}
{%- assign path_and_name = file.path | append: file.name -%}
{%- assign blog_rel = upload_path | append: path_and_name -%}
@ -49,7 +49,7 @@
{% if is_web_log_admin %}
<span class="text-muted"> &bull; </span>
{%- capture delete_url -%}
{%- if file.source == "disk" -%}
{%- if file.source == "Disk" -%}
admin/upload/delete/{{ path_and_name }}
{%- else -%}
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">
Destination<br>
<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"
{%- if destination == "database" %} checked="checked"{% endif %}>
<input type="radio" name="Destination" id="destination_db" class="btn-check" value="Database"
{%- if destination == "Database" %} checked="checked"{% endif %}>
<label class="btn btn-outline-primary" for="destination_db">Database</label>
<input type="radio" name="Destination" id="destination_disk" class="btn-check" value="disk"
{%- if destination == "disk" %} checked="checked"{% endif %}>
<input type="radio" name="Destination" id="destination_disk" class="btn-check" value="Disk"
{%- if destination == "Disk" %} checked="checked"{% endif %}>
<label class="btn btn-outline-secondary" for="destination_disk">Disk</label>
</div>
</div>

View File

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

View File

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