11 Commits

Author SHA1 Message Date
33698bd182 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
2022-07-28 20:36:02 -04:00
6b49793fbb Change alerts to toasts (#25)
- Upgrade to Bootstrap 5.1.3
- Move RSS settings and tag mappings to web log settings (#25)
- Fix parameters in 2 SQLite queries
2022-07-27 21:38:46 -04:00
a8386d6c97 Add loading indicator for admin theme (#25) 2022-07-26 22:34:19 -04:00
b1ca48c2c5 Add docs link to admin header (#25)
- Change executable name in release packages
2022-07-26 20:37:18 -04:00
3189681021 Tweak admin UI templates (#25)
- Move user management under web log settings
- Move user self-update to my-info
- Return meaningful error if a template does not exist
- Tweak margins/paddings throughout
- Do not show headings on list pages if lists are empty
- Fix pagination styles for page/post list pages
2022-07-26 16:28:14 -04:00
ff9c08842b First cut at cache management (#23) 2022-07-24 23:55:00 -04:00
e103738d39 Prevent deletion if theme is in use (#20) 2022-07-24 19:26:36 -04:00
d854178255 Upload / delete themes (#20)
- Moved themes to section of installation admin page (will also implement #23 there)
2022-07-24 19:18:20 -04:00
0a32181e65 WIP on theme upload (#20) 2022-07-24 16:32:37 -04:00
81fe03b8f3 WIP on theme admin page (#20) 2022-07-22 21:19:19 -04:00
4514c4864d Load themes at startup (#20)
- Adjust release packaging (#20)
- Fix default theme for beta-5 changes (#24)
- Remove RethinkDB case fix (cleanup from #21)
- Bump versions for next release
2022-07-22 10:33:11 -04:00
62 changed files with 1878 additions and 1451 deletions

View File

@@ -34,9 +34,9 @@ let version =
let zipTheme (name : string) (_ : TargetParameter) = let zipTheme (name : string) (_ : TargetParameter) =
let path = $"src/{name}-theme" let path = $"src/{name}-theme"
!! $"{path}/**/*" !! $"{path}/**/*"
|> Zip.filesAsSpecs path //$"src/{name}-theme" |> Zip.filesAsSpecs path
|> Seq.filter (fun (_, name) -> not (name.EndsWith ".zip")) |> Seq.filter (fun (_, name) -> not (name.EndsWith ".zip"))
|> Zip.zipSpec $"{releasePath}/{name}.zip" |> Zip.zipSpec $"{releasePath}/{name}-theme.zip"
/// Publish the project for the given runtime ID /// Publish the project for the given runtime ID
let publishFor rid (_ : TargetParameter) = let publishFor rid (_ : TargetParameter) =
@@ -45,11 +45,14 @@ let publishFor rid (_ : TargetParameter) =
/// Package published output for the given runtime ID /// Package published output for the given runtime ID
let packageFor (rid : string) (_ : TargetParameter) = let packageFor (rid : string) (_ : TargetParameter) =
let path = $"{projectPath}/bin/Release/net6.0/{rid}/publish" let path = $"{projectPath}/bin/Release/net6.0/{rid}/publish"
let prodSettings = $"{path}/appsettings.Production.json"
if File.exists prodSettings then File.delete prodSettings
[ !! $"{path}/**/*" [ !! $"{path}/**/*"
|> Zip.filesAsSpecs path |> Zip.filesAsSpecs path
|> Zip.moveToFolder "app" |> Seq.map (fun (orig, dest) ->
Seq.singleton ($"{releasePath}/admin.zip", "admin.zip") orig, if dest.StartsWith "MyWebLog" then dest.Replace ("MyWebLog", "myWebLog") else dest)
Seq.singleton ($"{releasePath}/default.zip", "default.zip") Seq.singleton ($"{releasePath}/admin-theme.zip", "admin-theme.zip")
Seq.singleton ($"{releasePath}/default-theme.zip", "default-theme.zip")
] ]
|> Seq.concat |> Seq.concat
|> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{rid}.zip" |> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{rid}.zip"
@@ -86,7 +89,7 @@ Target.create "RepackageLinux" (fun _ ->
Shell.mkdir workDir Shell.mkdir workDir
Zip.unzip workDir zipArchive Zip.unzip workDir zipArchive
Shell.cd workDir Shell.cd workDir
sh "chmod" [ "+x"; "app/MyWebLog" ] sh "chmod" [ "+x"; "./myWebLog" ]
sh "tar" [ "cfj"; $"../myWebLog-{version}.linux-x64.tar.bz2"; "." ] sh "tar" [ "cfj"; $"../myWebLog-{version}.linux-x64.tar.bz2"; "." ]
Shell.cd "../.." Shell.cd "../.."
Shell.rm zipArchive Shell.rm zipArchive
@@ -96,8 +99,8 @@ Target.create "RepackageLinux" (fun _ ->
Target.create "All" ignore Target.create "All" ignore
Target.create "RemoveThemeArchives" (fun _ -> Target.create "RemoveThemeArchives" (fun _ ->
Shell.rm $"{releasePath}/admin.zip" Shell.rm $"{releasePath}/admin-theme.zip"
Shell.rm $"{releasePath}/default.zip" Shell.rm $"{releasePath}/default-theme.zip"
) )
Target.create "CI" ignore Target.create "CI" ignore

View File

@@ -1,171 +0,0 @@
// Category
r.db('myWebLog').table('Category').map({
Description: r.row('description'),
Id: r.row('id'),
Name: r.row('name'),
ParentId: r.row('parentId'),
Slug: r.row('slug'),
WebLogId: r.row('webLogId')
})
// Page
r.db('myWebLog').table('Page').map({
AuthorId: r.row('authorId'),
Id: r.row('id'),
Metadata: r.row('metadata').map(function (meta) {
return { Name: meta('name'), Value: meta('value') }
}),
Permalink: r.row('permalink'),
PriorPermalinks: r.row('priorPermalinks'),
PublishedOn: r.row('publishedOn'),
Revisions: r.row('revisions').map(function (rev) {
return {
AsOf: rev('asOf'),
Text: rev('text')
}
}),
IsInPageList: r.row('showInPageList'),
Template: r.row('template'),
Text: r.row('text'),
Title: r.row('title'),
UpdatedOn: r.row('updatedOn'),
WebLogId: r.row('webLogId')
})
// Post
r.db('myWebLog').table('Post').map({
AuthorId: r.row('authorId'),
CategoryIds: r.row('categoryIds'),
Episode: r.branch(r.row.hasFields('episode'), {
Duration: r.row('episode')('duration'),
Length: r.row('episode')('length'),
Media: r.row('episode')('media'),
MediaType: r.row('episode')('mediaType').default(null),
ImageUrl: r.row('episode')('imageUrl').default(null),
Subtitle: r.row('episode')('subtitle').default(null),
Explicit: r.row('episode')('explicit').default(null),
ChapterFile: r.row('episode')('chapterFile').default(null),
ChapterType: r.row('episode')('chapterType').default(null),
TranscriptUrl: r.row('episode')('transcriptUrl').default(null),
TranscriptType: r.row('episode')('transcriptType').default(null),
TranscriptLang: r.row('episode')('transcriptLang').default(null),
TranscriptCaptions: r.row('episode')('transcriptCaptions').default(null),
SeasonNumber: r.row('episode')('seasonNumber').default(null),
SeasonDescription: r.row('episode')('seasonDescription').default(null),
EpisodeNumber: r.row('episode')('episodeNumber').default(null),
EpisodeDescription: r.row('episode')('episodeDescription').default(null)
}, null),
Id: r.row('id'),
Metadata: r.row('metadata').map(function (meta) {
return { Name: meta('name'), Value: meta('value') }
}),
Permalink: r.row('permalink'),
PriorPermalinks: r.row('priorPermalinks'),
PublishedOn: r.row('publishedOn'),
Revisions: r.row('revisions').map(function (rev) {
return {
AsOf: rev('asOf'),
Text: rev('text')
}
}),
Status: r.row('status'),
Tags: r.row('tags'),
Template: r.row('template').default(null),
Text: r.row('text'),
Title: r.row('title'),
UpdatedOn: r.row('updatedOn'),
WebLogId: r.row('webLogId')
})
// TagMap
r.db('myWebLog').table('TagMap').map({
Id: r.row('id'),
Tag: r.row('tag'),
UrlValue: r.row('urlValue'),
WebLogId: r.row('webLogId')
})
// Theme
r.db('myWebLog').table('Theme').map({
Id: r.row('id'),
Name: r.row('name'),
Templates: r.row('templates').map(function (tmpl) {
return {
Name: tmpl('name'),
Text: tmpl('text')
}
}),
Version: r.row('version')
})
// ThemeAsset
r.db('myWebLog').table('ThemeAsset').map({
Data: r.row('data'),
Id: r.row('id'),
UpdatedOn: r.row('updatedOn')
})
// WebLog
r.db('myWebLog').table('WebLog').map(
{ AutoHtmx: r.row('autoHtmx'),
DefaultPage: r.row('defaultPage'),
Id: r.row('id'),
Name: r.row('name'),
PostsPerPage: r.row('postsPerPage'),
Rss: {
IsCategoryEnabled: r.row('rss')('categoryEnabled'),
Copyright: r.row('rss')('copyright'),
CustomFeeds: r.row('rss')('customFeeds').map(function (feed) {
return {
Id: feed('id'),
Path: feed('path'),
Podcast: {
DefaultMediaType: feed('podcast')('defaultMediaType'),
DisplayedAuthor: feed('podcast')('displayedAuthor'),
Email: feed('podcast')('email'),
Explicit: feed('podcast')('explicit'),
FundingText: feed('podcast')('fundingText'),
FundingUrl: feed('podcast')('fundingUrl'),
PodcastGuid: feed('podcast')('guid'),
AppleCategory: feed('podcast')('iTunesCategory'),
AppleSubcategory: feed('podcast')('iTunesSubcategory'),
ImageUrl: feed('podcast')('imageUrl'),
ItemsInFeed: feed('podcast')('itemsInFeed'),
MediaBaseUrl: feed('podcast')('mediaBaseUrl'),
Medium: feed('podcast')('medium'),
Subtitle: feed('podcast')('subtitle'),
Summary: feed('podcast')('summary'),
Title: feed('podcast')('title')
},
Source: feed('source')
}
}),
IsFeedEnabled: r.row('rss')('feedEnabled'),
FeedName: r.row('rss')('feedName'),
ItemsInFeed: r.row('rss')('itemsInFeed'),
IsTagEnabled: r.row('rss')('tagEnabled')
},
Slug: r.row('slug'),
Subtitle: r.row('subtitle'),
ThemeId: r.row('themePath'),
TimeZone: r.row('timeZone'),
Uploads: r.row('uploads'),
UrlBase: r.row('urlBase')
})
// WebLogUser
r.db('myWebLog').table('WebLogUser').map({
AccessLevel: r.row('authorizationLevel'),
FirstName: r.row('firstName'),
Id: r.row('id'),
LastName: r.row('lastName'),
PasswordHash: r.row('passwordHash'),
PreferredName: r.row('preferredName'),
Salt: r.row('salt'),
Url: r.row('url'),
Email: r.row('userName'),
WebLogId: r.row('webLogId'),
CreatedOn: r.branch(r.row.hasFields('createdOn'), r.row('createdOn'), r.expr(new Date(0))),
LastSeenOn: r.row('lastSeenOn').default(null)
})

10
src/Directory.Build.props Normal file
View File

@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<DebugType>embedded</DebugType>
<AssemblyVersion>2.0.0.0</AssemblyVersion>
<FileVersion>2.0.0.0</FileVersion>
<Version>2.0.0</Version>
<VersionSuffix>rc1</VersionSuffix>
</PropertyGroup>
</Project>

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[]>
@@ -167,9 +177,15 @@ type ITagMapData =
/// Functions to manipulate themes /// Functions to manipulate themes
type IThemeData = type IThemeData =
/// Retrieve all themes (except "admin") /// Retrieve all themes (except "admin") (excluding the text of templates)
abstract member All : unit -> Task<Theme list> abstract member All : unit -> Task<Theme list>
/// Delete a theme
abstract member Delete : ThemeId -> Task<bool>
/// Determine if a theme exists
abstract member Exists : ThemeId -> Task<bool>
/// Find a theme by its ID /// Find a theme by its ID
abstract member FindById : ThemeId -> Task<Theme option> abstract member FindById : ThemeId -> Task<Theme option>

View File

@@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" /> <ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
</ItemGroup> </ItemGroup>

View File

@@ -96,6 +96,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
let keyPrefix = $"^{ThemeId.toString themeId}/" let keyPrefix = $"^{ThemeId.toString themeId}/"
fun (row : Ast.ReqlExpr) -> row[nameof ThemeAsset.empty.Id].Match keyPrefix :> obj fun (row : Ast.ReqlExpr) -> row[nameof ThemeAsset.empty.Id].Match keyPrefix :> obj
/// Function to exclude template text from themes
let withoutTemplateText (row : Ast.ReqlExpr) : obj =
{| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.empty.Text |] |}
/// Ensure field indexes exist, as well as special indexes for selected tables /// Ensure field indexes exist, as well as special indexes for selected tables
let ensureIndexes table fields = backgroundTask { let ensureIndexes table fields = backgroundTask {
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn } let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
@@ -176,6 +180,14 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
/// The batch size for restoration methods /// The batch size for restoration methods
let restoreBatchSize = 100 let restoreBatchSize = 100
/// Delete assets for the given theme ID
let deleteAssetsByTheme themeId = rethink {
withTable Table.ThemeAsset
filter (matchAssetByThemeId themeId)
delete
write; withRetryDefault; ignoreResult conn
}
/// The connection for this instance /// The connection for this instance
member _.Conn = conn member _.Conn = conn
@@ -262,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
@@ -279,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 {
@@ -711,11 +737,21 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
member _.All () = rethink<Theme list> { member _.All () = rethink<Theme list> {
withTable Table.Theme withTable Table.Theme
filter (fun row -> row[nameof Theme.empty.Id].Ne "admin" :> obj) filter (fun row -> row[nameof Theme.empty.Id].Ne "admin" :> obj)
without [ nameof Theme.empty.Templates ] merge withoutTemplateText
orderBy (nameof Theme.empty.Id) orderBy (nameof Theme.empty.Id)
result; withRetryDefault conn result; withRetryDefault conn
} }
member _.Exists themeId = backgroundTask {
let! count = rethink<int> {
withTable Table.Theme
filter (nameof Theme.empty.Id) themeId
count
result; withRetryDefault conn
}
return count > 0
}
member _.FindById themeId = rethink<Theme> { member _.FindById themeId = rethink<Theme> {
withTable Table.Theme withTable Table.Theme
get themeId get themeId
@@ -725,12 +761,24 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
member _.FindByIdWithoutText themeId = rethink<Theme> { member _.FindByIdWithoutText themeId = rethink<Theme> {
withTable Table.Theme withTable Table.Theme
get themeId get themeId
merge (fun row -> merge withoutTemplateText
{| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.empty.Text |]
|})
resultOption; withRetryOptionDefault conn resultOption; withRetryOptionDefault conn
} }
member this.Delete themeId = backgroundTask {
match! this.FindByIdWithoutText themeId with
| Some _ ->
do! deleteAssetsByTheme themeId
do! rethink {
withTable Table.Theme
get themeId
delete
write; withRetryDefault; ignoreResult conn
}
return true
| None -> return false
}
member _.Save theme = rethink { member _.Save theme = rethink {
withTable Table.Theme withTable Table.Theme
get theme.Id get theme.Id
@@ -748,12 +796,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
result; withRetryDefault conn result; withRetryDefault conn
} }
member _.DeleteByTheme themeId = rethink { member _.DeleteByTheme themeId = deleteAssetsByTheme themeId
withTable Table.ThemeAsset
filter (matchAssetByThemeId themeId)
delete
write; withRetryDefault; ignoreResult conn
}
member _.FindById assetId = rethink<ThemeAsset> { member _.FindById assetId = rethink<ThemeAsset> {
withTable Table.ThemeAsset withTable Table.ThemeAsset

View File

@@ -242,9 +242,9 @@ module Map =
} }
/// Create a theme template from the current row in the given data reader /// Create a theme template from the current row in the given data reader
let toThemeTemplate rdr : ThemeTemplate = let toThemeTemplate includeText rdr : ThemeTemplate =
{ Name = getString "name" rdr { Name = getString "name" rdr
Text = getString "template" rdr Text = if includeText then getString "template" rdr else ""
} }
/// Create an uploaded file from the current row in the given data reader /// Create an uploaded file from the current row in the given data reader

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

@@ -328,7 +328,7 @@ type SQLitePageData (conn : SqliteConnection) =
is_in_page_list = @isInPageList, is_in_page_list = @isInPageList,
template = @template, template = @template,
page_text = @text page_text = @text
WHERE id = @pageId WHERE id = @id
AND web_log_id = @webLogId""" AND web_log_id = @webLogId"""
addPageParameters cmd page addPageParameters cmd page
do! write cmd do! write cmd

View File

@@ -8,12 +8,31 @@ open MyWebLog.Data
/// SQLite myWebLog theme data implementation /// SQLite myWebLog theme data implementation
type SQLiteThemeData (conn : SqliteConnection) = type SQLiteThemeData (conn : SqliteConnection) =
/// Retrieve all themes (except 'admin'; excludes templates) /// Retrieve all themes (except 'admin'; excludes template text)
let all () = backgroundTask { let all () = backgroundTask {
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id" cmd.CommandText <- "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id"
use! rdr = cmd.ExecuteReaderAsync () use! rdr = cmd.ExecuteReaderAsync ()
return toList Map.toTheme rdr let themes = toList Map.toTheme rdr
do! rdr.CloseAsync ()
cmd.CommandText <- "SELECT name, theme_id FROM theme_template WHERE theme_id <> 'admin' ORDER BY name"
use! rdr = cmd.ExecuteReaderAsync ()
let mutable templates = []
while rdr.Read () do
templates <- (ThemeId (Map.getString "theme_id" rdr), Map.toThemeTemplate false rdr) :: templates
return
themes
|> List.map (fun t ->
{ t with Templates = templates |> List.filter (fun tt -> fst tt = t.Id) |> List.map snd })
}
/// Does a given theme exist?
let exists themeId = backgroundTask {
use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT COUNT(id) FROM theme WHERE id = @id"
cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore
let! count = count cmd
return count > 0
} }
/// Find a theme by its ID /// Find a theme by its ID
@@ -28,7 +47,7 @@ type SQLiteThemeData (conn : SqliteConnection) =
templateCmd.CommandText <- "SELECT * FROM theme_template WHERE theme_id = @id" templateCmd.CommandText <- "SELECT * FROM theme_template WHERE theme_id = @id"
templateCmd.Parameters.Add cmd.Parameters["@id"] |> ignore templateCmd.Parameters.Add cmd.Parameters["@id"] |> ignore
use! templateRdr = templateCmd.ExecuteReaderAsync () use! templateRdr = templateCmd.ExecuteReaderAsync ()
return Some { theme with Templates = toList Map.toThemeTemplate templateRdr } return Some { theme with Templates = toList (Map.toThemeTemplate true) templateRdr }
else else
return None return None
} }
@@ -43,6 +62,21 @@ type SQLiteThemeData (conn : SqliteConnection) =
| None -> return None | None -> return None
} }
/// Delete a theme by its ID
let delete themeId = backgroundTask {
match! findByIdWithoutText themeId with
| Some _ ->
use cmd = conn.CreateCommand ()
cmd.CommandText <- """
DELETE FROM theme_asset WHERE theme_id = @id;
DELETE FROM theme_template WHERE theme_id = @id;
DELETE FROM theme WHERE id = @id"""
cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore
do! write cmd
return true
| None -> return false
}
/// Save a theme /// Save a theme
let save (theme : Theme) = backgroundTask { let save (theme : Theme) = backgroundTask {
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
@@ -102,6 +136,8 @@ type SQLiteThemeData (conn : SqliteConnection) =
interface IThemeData with interface IThemeData with
member _.All () = all () member _.All () = all ()
member _.Delete themeId = delete themeId
member _.Exists themeId = exists themeId
member _.FindById themeId = findById themeId member _.FindById themeId = findById themeId
member _.FindByIdWithoutText themeId = findByIdWithoutText themeId member _.FindByIdWithoutText themeId = findByIdWithoutText themeId
member _.Save theme = save theme member _.Save theme = save theme

View File

@@ -320,6 +320,7 @@ type SQLiteWebLogData (conn : SqliteConnection) =
copyright = @copyright copyright = @copyright
WHERE id = @id""" WHERE id = @id"""
addWebLogRssParameters cmd webLog addWebLogRssParameters cmd webLog
cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore
do! write cmd do! write cmd
do! updateCustomFeeds webLog do! updateCustomFeeds webLog
} }

View File

@@ -1,11 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<Compile Include="SupportTypes.fs" /> <Compile Include="SupportTypes.fs" />
<Compile Include="DataTypes.fs" /> <Compile Include="DataTypes.fs" />

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
} }
@@ -176,6 +187,40 @@ with
open System.IO open System.IO
/// Information about a theme used for display
[<NoComparison; NoEquality>]
type DisplayTheme =
{ /// The ID / path slug of the theme
Id : string
/// The name of the theme
Name : string
/// The version of the theme
Version : string
/// How many templates are contained in the theme
TemplateCount : int
/// Whether the theme is in use by any web logs
IsInUse : bool
/// Whether the theme .zip file exists on the filesystem
IsOnDisk : bool
}
with
/// Create a display theme from a theme
static member fromTheme inUseFunc (theme : Theme) =
{ Id = ThemeId.toString theme.Id
Name = theme.Name
Version = theme.Version
TemplateCount = List.length theme.Templates
IsInUse = inUseFunc theme.Id
IsOnDisk = File.Exists $"{ThemeId.toString theme.Id}-theme.zip"
}
/// Information about an uploaded file used for display /// Information about an uploaded file used for display
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type DisplayUpload = type DisplayUpload =
@@ -1027,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
@@ -1127,6 +1172,14 @@ type UploadFileModel =
} }
/// View model for uploading a theme
[<CLIMutable; NoComparison; NoEquality>]
type UploadThemeModel =
{ /// Whether the uploaded theme should overwrite an existing theme
DoOverwrite : bool
}
/// A message displayed to the user /// A message displayed to the user
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type UserMessage = type UserMessage =

View File

@@ -80,12 +80,20 @@ module WebLogCache =
let set webLog = let set webLog =
_cache <- webLog :: (_cache |> List.filter (fun wl -> wl.Id <> webLog.Id)) _cache <- webLog :: (_cache |> List.filter (fun wl -> wl.Id <> webLog.Id))
/// Get all cached web logs
let all () =
_cache
/// Fill the web log cache from the database /// Fill the web log cache from the database
let fill (data : IData) = backgroundTask { let fill (data : IData) = backgroundTask {
let! webLogs = data.WebLog.All () let! webLogs = data.WebLog.All ()
_cache <- webLogs _cache <- webLogs
} }
/// Is the given theme in use by any web logs?
let isThemeInUse themeId =
_cache |> List.exists (fun wl -> wl.ThemeId = themeId)
/// A cache of page information needed to display the page list in templates /// A cache of page information needed to display the page list in templates
module PageListCache = module PageListCache =
@@ -93,22 +101,30 @@ module PageListCache =
open MyWebLog.ViewModels open MyWebLog.ViewModels
/// Cache of displayed pages /// Cache of displayed pages
let private _cache = ConcurrentDictionary<string, DisplayPage[]> () let private _cache = ConcurrentDictionary<WebLogId, DisplayPage[]> ()
/// Are there pages cached for this web log? let private fillPages (webLog : WebLog) pages =
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.UrlBase _cache[webLog.Id] <-
/// Get the pages for the web log for this request
let get (ctx : HttpContext) = _cache[ctx.WebLog.UrlBase]
/// Update the pages for the current web log
let update (ctx : HttpContext) = backgroundTask {
let webLog = ctx.WebLog
let! pages = ctx.Data.Page.FindListed webLog.Id
_cache[webLog.UrlBase] <-
pages pages
|> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" }) |> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" })
|> Array.ofList |> Array.ofList
/// Are there pages cached for this web log?
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.Id
/// Get the pages for the web log for this request
let get (ctx : HttpContext) = _cache[ctx.WebLog.Id]
/// Update the pages for the current web log
let update (ctx : HttpContext) = backgroundTask {
let! pages = ctx.Data.Page.FindListed ctx.WebLog.Id
fillPages ctx.WebLog pages
}
/// Refresh the pages for the given web log
let refresh (webLog : WebLog) (data : IData) = backgroundTask {
let! pages = data.Page.FindListed webLog.Id
fillPages webLog pages
} }
@@ -118,18 +134,24 @@ module CategoryCache =
open MyWebLog.ViewModels open MyWebLog.ViewModels
/// The cache itself /// The cache itself
let private _cache = ConcurrentDictionary<string, DisplayCategory[]> () let private _cache = ConcurrentDictionary<WebLogId, DisplayCategory[]> ()
/// Are there categories cached for this web log? /// Are there categories cached for this web log?
let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.UrlBase let exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.Id
/// Get the categories for the web log for this request /// Get the categories for the web log for this request
let get (ctx : HttpContext) = _cache[ctx.WebLog.UrlBase] let get (ctx : HttpContext) = _cache[ctx.WebLog.Id]
/// Update the cache with fresh data /// Update the cache with fresh data
let update (ctx : HttpContext) = backgroundTask { let update (ctx : HttpContext) = backgroundTask {
let! cats = ctx.Data.Category.FindAllForView ctx.WebLog.Id let! cats = ctx.Data.Category.FindAllForView ctx.WebLog.Id
_cache[ctx.WebLog.UrlBase] <- cats _cache[ctx.WebLog.Id] <- cats
}
/// Refresh the category cache for the given web log
let refresh webLogId (data : IData) = backgroundTask {
let! cats = data.Category.FindAllForView webLogId
_cache[webLogId] <- cats
} }
@@ -147,30 +169,55 @@ module TemplateCache =
let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2) let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2)
/// Get a template for the given theme and template name /// Get a template for the given theme and template name
let get (themeId : string) (templateName : string) (data : IData) = backgroundTask { let get (themeId : ThemeId) (templateName : string) (data : IData) = backgroundTask {
let templatePath = $"{themeId}/{templateName}" let templatePath = $"{ThemeId.toString themeId}/{templateName}"
match _cache.ContainsKey templatePath with match _cache.ContainsKey templatePath with
| true -> () | true -> return Ok _cache[templatePath]
| false -> | false ->
match! data.Theme.FindById (ThemeId themeId) with match! data.Theme.FindById themeId with
| Some theme -> | Some theme ->
let mutable text = (theme.Templates |> List.find (fun t -> t.Name = templateName)).Text match theme.Templates |> List.tryFind (fun t -> t.Name = templateName) with
| Some template ->
let mutable text = template.Text
let mutable childNotFound = ""
while hasInclude.IsMatch text do while hasInclude.IsMatch text do
let child = hasInclude.Match text let child = hasInclude.Match text
let childText = (theme.Templates |> List.find (fun t -> t.Name = child.Groups[1].Value)).Text let childText =
match theme.Templates |> List.tryFind (fun t -> t.Name = child.Groups[1].Value) with
| Some childTemplate -> childTemplate.Text
| None ->
childNotFound <-
if childNotFound = "" then child.Groups[1].Value
else $"{childNotFound}; {child.Groups[1].Value}"
""
text <- text.Replace (child.Value, childText) text <- text.Replace (child.Value, childText)
if childNotFound <> "" then
let s = if childNotFound.IndexOf ";" >= 0 then "s" else ""
return Error $"Could not find the child template{s} {childNotFound} required by {templateName}"
else
_cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22) _cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22)
| None -> () return Ok _cache[templatePath]
return _cache[templatePath] | None ->
return Error $"Theme ID {ThemeId.toString themeId} does not have a template named {templateName}"
| None -> return Result.Error $"Theme ID {ThemeId.toString themeId} does not exist"
} }
/// Get all theme/template names currently cached
let allNames () =
_cache.Keys |> Seq.sort |> Seq.toList
/// Invalidate all template cache entries for the given theme ID /// Invalidate all template cache entries for the given theme ID
let invalidateTheme (themeId : string) = let invalidateTheme (themeId : ThemeId) =
let keyPrefix = ThemeId.toString themeId
_cache.Keys _cache.Keys
|> Seq.filter (fun key -> key.StartsWith themeId) |> Seq.filter (fun key -> key.StartsWith keyPrefix)
|> List.ofSeq |> List.ofSeq
|> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ()) |> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ())
/// Remove all entries from the template cache
let empty () =
_cache.Clear ()
/// A cache of asset names by themes /// A cache of asset names by themes
module ThemeAssetCache = module ThemeAssetCache =

View File

@@ -200,7 +200,7 @@ type UserLinksTag () =
|> Seq.iter result.WriteLine |> Seq.iter result.WriteLine
/// A filter to retrieve the value of a meta item from a list /// A filter to retrieve the value of a meta item from a list
// (shorter than `{% assign item = list | where: "name", [name] | first %}{{ item.value }}`) // (shorter than `{% assign item = list | where: "Name", [name] | first %}{{ item.value }}`)
type ValueFilter () = type ValueFilter () =
static member Value (_ : Context, items : MetaItem list, name : string) = static member Value (_ : Context, items : MetaItem list, name : string) =
match items |> List.tryFind (fun it -> it.Name = name) with match items |> List.tryFind (fun it -> it.Name = name) with
@@ -228,11 +228,11 @@ let register () =
typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog> typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
// View models // View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage> typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
typeof<DisplayRevision>; typeof<DisplayUpload>; typeof<DisplayUser>; typeof<EditCategoryModel> typeof<DisplayRevision>; typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>
typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>; typeof<EditPostModel> typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel> typeof<EditPostModel>; typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>
typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>; typeof<PostListItem> typeof<LogOnModel>; typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>
typeof<SettingsModel>; typeof<UserMessage> typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
// Framework types // Framework types
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair> typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list> typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>

View File

@@ -6,8 +6,11 @@ open Giraffe
open MyWebLog open MyWebLog
open MyWebLog.ViewModels open MyWebLog.ViewModels
// GET /admin /// ~~ DASHBOARDS ~~
let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task { 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 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)
@@ -30,11 +33,108 @@ let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|> adminView "dashboard" next ctx |> adminView "dashboard" next ctx
} }
// -- CATEGORIES -- // 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 ~~
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 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 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"
}
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 = $"Successfully cleared template cache and refreshed theme asset cache for {theme.Name}"
}
| None ->
do! addMessage ctx { UserMessage.error with Message = $"No theme exists with ID {themeId}" }
return! toAdminDashboard next ctx
}
/// ~~ CATEGORIES ~~
module Category =
open MyWebLog.Data
// GET /admin/categories // GET /admin/categories
let listCategories : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data match! TemplateCache.get adminTheme "category-list-body" ctx.Data with
| Ok catListTemplate ->
let! hash = let! hash =
hashForPage "Categories" hashForPage "Categories"
|> withAntiCsrf ctx |> withAntiCsrf ctx
@@ -42,17 +142,18 @@ let listCategories : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
return! return!
addToHash "category_list" (catListTemplate.Render hash) hash addToHash "category_list" (catListTemplate.Render hash) hash
|> adminView "category-list" next ctx |> adminView "category-list" next ctx
| Error message -> return! Error.server message next ctx
} }
// GET /admin/categories/bare // GET /admin/categories/bare
let listCategoriesBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
hashForPage "Categories" hashForPage "Categories"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> adminBareView "category-list-body" next ctx |> adminBareView "category-list-body" next ctx
// GET /admin/category/{id}/edit // GET /admin/category/{id}/edit
let editCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let edit catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! result = task { let! result = task {
match catId with match catId with
| "new" -> return Some ("Add a New Category", { Category.empty with Id = CategoryId "new" }) | "new" -> return Some ("Add a New Category", { Category.empty with Id = CategoryId "new" })
@@ -72,7 +173,7 @@ let editCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ct
} }
// POST /admin/category/save // POST /admin/category/save
let saveCategory : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
let! model = ctx.BindFormAsync<EditCategoryModel> () let! model = ctx.BindFormAsync<EditCategoryModel> ()
let category = let category =
@@ -90,52 +191,55 @@ let saveCategory : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> t
do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat
do! CategoryCache.update ctx do! CategoryCache.update ctx
do! addMessage ctx { UserMessage.success with Message = "Category saved successfully" } do! addMessage ctx { UserMessage.success with Message = "Category saved successfully" }
return! listCategoriesBare next ctx return! bare next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// POST /admin/category/{id}/delete // POST /admin/category/{id}/delete
let deleteCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let delete catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
match! ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id with let! result = ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id
| true -> match result with
| CategoryDeleted
| ReassignedChildCategories ->
do! CategoryCache.update ctx do! CategoryCache.update ctx
do! addMessage ctx { UserMessage.success with Message = "Category deleted successfully" } let detail =
| false -> do! addMessage ctx { UserMessage.error with Message = "Category not found; cannot delete" } match result with
return! listCategoriesBare next ctx | 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 open Microsoft.AspNetCore.Http
// -- TAG MAPPINGS -- /// Add tag mappings to the given hash
let withTagMappings (ctx : HttpContext) hash = task {
/// Get the hash necessary to render the tag mapping list
let private tagMappingHash (ctx : HttpContext) = task {
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
return! return
hashForPage "Tag Mappings" addToHash "mappings" mappings hash
|> withAntiCsrf ctx |> addToHash "mapping_ids" (
|> addToHash "mappings" mappings mappings
|> addToHash "mapping_ids" (mappings |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id })) |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
|> addViewContext ctx
} }
// GET /admin/settings/tag-mappings // GET /admin/settings/tag-mappings
let tagMappings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! hash = tagMappingHash ctx let! hash =
let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body" ctx.Data hashForPage ""
return! |> withAntiCsrf ctx
addToHash "tag_mapping_list" (listTemplate.Render hash) hash |> withTagMappings ctx
|> adminView "tag-mapping-list" next ctx
}
// GET /admin/settings/tag-mappings/bare
let tagMappingsBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! hash = tagMappingHash ctx
return! adminBareView "tag-mapping-list-body" next ctx hash return! adminBareView "tag-mapping-list-body" next ctx hash
} }
// GET /admin/settings/tag-mapping/{id}/edit // GET /admin/settings/tag-mapping/{id}/edit
let editMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let edit tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let isNew = tagMapId = "new" let isNew = tagMapId = "new"
let tagMap = let tagMap =
if isNew then someTask { TagMap.empty with Id = TagMapId "new" } if isNew then someTask { TagMap.empty with Id = TagMapId "new" }
@@ -151,7 +255,7 @@ let editMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next
} }
// POST /admin/settings/tag-mapping/save // POST /admin/settings/tag-mapping/save
let saveMapping : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
let! model = ctx.BindFormAsync<EditTagMapModel> () let! model = ctx.BindFormAsync<EditTagMapModel> ()
let tagMap = let tagMap =
@@ -161,19 +265,21 @@ let saveMapping : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> ta
| Some tm -> | Some tm ->
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower (); UrlValue = model.UrlValue.ToLower () } 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" } do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" }
return! tagMappingsBare next ctx return! all next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// POST /admin/settings/tag-mapping/{id}/delete // POST /admin/settings/tag-mapping/{id}/delete
let deleteMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let delete tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" } | 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" } | false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" }
return! tagMappingsBare next ctx return! all next ctx
} }
// -- THEMES --
/// ~~ THEMES ~~
module Theme =
open System open System
open System.IO open System.IO
@@ -181,11 +287,21 @@ open System.IO.Compression
open System.Text.RegularExpressions open System.Text.RegularExpressions
open MyWebLog.Data open MyWebLog.Data
// GET /admin/theme/update // GET /admin/theme/list
let themeUpdatePage : HttpHandler = requireAccess Administrator >=> fun next ctx -> let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
hashForPage "Upload Theme" let! themes = ctx.Data.Theme.All ()
return!
hashForPage "Themes"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> adminView "upload-theme" next 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 /// Update the name and version for a theme based on the version.txt file, if present
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask { let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
@@ -201,14 +317,6 @@ let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = background
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () } | None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
} }
/// Delete all theme assets, and remove templates from theme
let private checkForCleanLoad (theme : Theme) cleanLoad (data : IData) = backgroundTask {
if cleanLoad then
do! data.ThemeAsset.DeleteByTheme theme.Id
return { theme with Templates = [] }
else return theme
}
/// Update the theme with all templates from the ZIP archive /// Update the theme with all templates from the ZIP archive
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask { let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
let tasks = let tasks =
@@ -241,60 +349,113 @@ let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundT
} }
} }
/// Get the theme name from the file name given /// Derive the theme ID from the file name given
let getThemeName (fileName : string) = let deriveIdFromFileName (fileName : string) =
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-") let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok themeName else Error $"Theme name {fileName} is invalid" 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 /// Load a theme from the given stream, which should contain a ZIP archive
let loadThemeFromZip themeName file clean (data : IData) = backgroundTask { let loadFromZip themeId file (data : IData) = backgroundTask {
use zip = new ZipArchive (file, ZipArchiveMode.Read) let! isNew, theme = backgroundTask {
let themeId = ThemeId themeName
let! theme = backgroundTask {
match! data.Theme.FindById themeId with match! data.Theme.FindById themeId with
| Some t -> return t | Some t -> return false, t
| None -> return { Theme.empty with Id = themeId } | None -> return true, { Theme.empty with Id = themeId }
} }
use zip = new ZipArchive (file, ZipArchiveMode.Read)
let! theme = updateNameAndVersion theme zip let! theme = updateNameAndVersion theme zip
let! theme = checkForCleanLoad theme clean data if not isNew then do! data.ThemeAsset.DeleteByTheme theme.Id
let! theme = updateTemplates theme zip let! theme = updateTemplates { theme with Templates = [] } zip
do! data.Theme.Save theme do! data.Theme.Save theme
do! updateAssets themeId zip data do! updateAssets themeId zip data
return theme
} }
// POST /admin/theme/update // POST /admin/theme/new
let updateTheme : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let save : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
let themeFile = Seq.head ctx.Request.Form.Files let themeFile = Seq.head ctx.Request.Form.Files
match getThemeName themeFile.FileName with match deriveIdFromFileName themeFile.FileName with
| Ok themeName when themeName <> "admin" -> | Ok themeId when themeId <> adminTheme ->
let data = ctx.Data 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 () use stream = new MemoryStream ()
do! themeFile.CopyToAsync stream do! themeFile.CopyToAsync stream
do! loadThemeFromZip themeName stream true data let! _ = loadFromZip themeId stream data
do! ThemeAssetCache.refreshTheme (ThemeId themeName) data do! ThemeAssetCache.refreshTheme themeId data
TemplateCache.invalidateTheme themeName TemplateCache.invalidateTheme themeId
do! addMessage ctx { UserMessage.success with Message = "Theme updated successfully" } // Save the .zip file
return! redirectToGet "admin/dashboard" next ctx 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 _ -> | Ok _ ->
do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" } do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" }
return! redirectToGet "admin/theme/update" next ctx return! toAdminDashboard next ctx
| Error message -> | Error message ->
do! addMessage ctx { UserMessage.error with Message = message } do! addMessage ctx { UserMessage.error with Message = message }
return! redirectToGet "admin/theme/update" next ctx return! toAdminDashboard next ctx
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
} }
// -- WEB LOG SETTINGS -- // 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.Collections.Generic
open System.IO
// GET /admin/settings // GET /admin/settings
let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data 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! allPages = data.Page.All ctx.WebLog.Id
let! themes = data.Theme.All () let! themes = data.Theme.All ()
return! let! users = data.WebLogUser.FindByWebLog ctx.WebLog.Id
let! hash =
hashForPage "Web Log Settings" hashForPage "Web Log Settings"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog) |> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
@@ -309,13 +470,27 @@ let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task
|> addToHash "themes" ( |> addToHash "themes" (
themes themes
|> Seq.ofList |> Seq.ofList
|> Seq.map (fun it -> KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})")) |> Seq.map (fun it ->
KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|> Array.ofSeq) |> Array.ofSeq)
|> addToHash "upload_values" [| |> addToHash "upload_values" [|
KeyValuePair.Create (UploadDestination.toString Database, "Database") KeyValuePair.Create (UploadDestination.toString Database, "Database")
KeyValuePair.Create (UploadDestination.toString Disk, "Disk") 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 |> adminView "settings" next ctx
| Error message -> return! Error.server message next ctx
| Error message -> return! Error.server message next ctx
} }
// POST /admin/settings // POST /admin/settings

View File

@@ -414,17 +414,6 @@ let generate (feedType : FeedType) postCount : HttpHandler = fun next ctx -> bac
// ~~ FEED ADMINISTRATION ~~ // ~~ FEED ADMINISTRATION ~~
// GET /admin/settings/rss
let editSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
hashForPage "RSS Settings"
|> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditRssModel.fromRssOptions ctx.WebLog.Rss)
|> addToHash "custom_feeds" (
ctx.WebLog.Rss.CustomFeeds
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|> Array.ofList)
|> adminView "rss-settings" next ctx
// POST /admin/settings/rss // POST /admin/settings/rss
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
@@ -435,7 +424,7 @@ let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> t
do! data.WebLog.UpdateRssOptions webLog do! data.WebLog.UpdateRssOptions webLog
WebLogCache.set webLog WebLogCache.set webLog
do! addMessage ctx { UserMessage.success with Message = "RSS settings updated successfully" } do! addMessage ctx { UserMessage.success with Message = "RSS settings updated successfully" }
return! redirectToGet "admin/settings/rss" next ctx return! redirectToGet "admin/settings#rss-settings" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@@ -507,6 +496,6 @@ let deleteCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun ne
do! addMessage ctx { UserMessage.success with Message = "Custom feed deleted successfully" } do! addMessage ctx { UserMessage.success with Message = "Custom feed deleted successfully" }
else else
do! addMessage ctx { UserMessage.warning with Message = "Custom feed not found; no action taken" } do! addMessage ctx { UserMessage.warning with Message = "Custom feed not found; no action taken" }
return! redirectToGet "admin/settings/rss" next ctx return! redirectToGet "admin/settings#rss-settings" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@@ -218,23 +218,6 @@ let addViewContext ctx (hash : Hash) = task {
let isHtmx (ctx : HttpContext) = let isHtmx (ctx : HttpContext) =
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
/// Render a view for the specified theme, using the specified template, layout, and hash
let viewForTheme themeId template next ctx (hash : Hash) = task {
let! hash = addViewContext ctx hash
let (ThemeId theme) = themeId
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render;
// the net effect is a "layout" capability similar to Razor or Pug
// Render view content...
let! contentTemplate = TemplateCache.get theme template ctx.Data
let _ = addToHash ViewContext.Content (contentTemplate.Render hash) hash
// ...then render that content with its layout
let! layoutTemplate = TemplateCache.get theme (if isHtmx ctx then "layout-partial" else "layout") ctx.Data
return! htmlString (layoutTemplate.Render hash) next ctx
}
/// Convert messages to headers (used for htmx responses) /// Convert messages to headers (used for htmx responses)
let messagesToHeaders (messages : UserMessage array) : HttpHandler = let messagesToHeaders (messages : UserMessage array) : HttpHandler =
seq { seq {
@@ -249,50 +232,12 @@ let messagesToHeaders (messages : UserMessage array) : HttpHandler =
} }
|> Seq.reduce (>=>) |> Seq.reduce (>=>)
/// Render a bare view for the specified theme, using the specified template and hash
let bareForTheme themeId template next ctx (hash : Hash) = task {
let! hash = addViewContext ctx hash
let (ThemeId theme) = themeId
if not (hash.ContainsKey ViewContext.Content) then
let! contentTemplate = TemplateCache.get theme template ctx.Data
addToHash ViewContext.Content (contentTemplate.Render hash) hash |> ignore
// Bare templates are rendered with layout-bare
let! layoutTemplate = TemplateCache.get theme "layout-bare" ctx.Data
return!
(messagesToHeaders (hash[ViewContext.Messages] :?> UserMessage[])
>=> htmlString (layoutTemplate.Render hash))
next ctx
}
/// Return a view for the web log's default theme
let themedView template next ctx hash = task {
let! hash = addViewContext ctx hash
return! viewForTheme (hash[ViewContext.WebLog] :?> WebLog).ThemeId template next ctx hash
}
/// Display a view for the admin theme
let adminView template =
viewForTheme (ThemeId "admin") template
/// Display a bare view for the admin theme
let adminBareView template =
bareForTheme (ThemeId "admin") template
/// Redirect after doing some action; commits session and issues a temporary redirect /// Redirect after doing some action; commits session and issues a temporary redirect
let redirectToGet url : HttpHandler = fun _ ctx -> task { let redirectToGet url : HttpHandler = fun _ ctx -> task {
do! commitSession ctx do! commitSession ctx
return! redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink url)) earlyReturn ctx return! redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink url)) earlyReturn ctx
} }
/// Validate the anti cross-site request forgery token in the current request
let validateCsrf : HttpHandler = fun next ctx -> task {
match! ctx.AntiForgery.IsRequestValidAsync ctx with
| true -> return! next ctx
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
}
/// Handlers for error conditions /// Handlers for error conditions
module Error = module Error =
@@ -322,10 +267,81 @@ module Error =
let messages = [| let messages = [|
{ UserMessage.error with Message = $"The URL {ctx.Request.Path.Value} was not found" } { UserMessage.error with Message = $"The URL {ctx.Request.Path.Value} was not found" }
|] |]
(messagesToHeaders messages >=> setStatusCode 404) earlyReturn ctx RequestErrors.notFound (messagesToHeaders messages) earlyReturn ctx
else else RequestErrors.NOT_FOUND "Not found" earlyReturn ctx)
(setStatusCode 404 >=> text "Not found") earlyReturn ctx)
let server message : HttpHandler =
handleContext (fun ctx ->
if isHtmx ctx then
let messages = [| { UserMessage.error with Message = message } |]
ServerErrors.internalError (messagesToHeaders messages) earlyReturn ctx
else ServerErrors.INTERNAL_ERROR message earlyReturn ctx)
/// Render a view for the specified theme, using the specified template, layout, and hash
let viewForTheme themeId template next ctx (hash : Hash) = task {
let! hash = addViewContext ctx hash
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render;
// the net effect is a "layout" capability similar to Razor or Pug
// Render view content...
match! TemplateCache.get themeId template ctx.Data with
| Ok contentTemplate ->
let _ = addToHash ViewContext.Content (contentTemplate.Render hash) hash
// ...then render that content with its layout
match! TemplateCache.get themeId (if isHtmx ctx then "layout-partial" else "layout") ctx.Data with
| Ok layoutTemplate -> return! htmlString (layoutTemplate.Render hash) next ctx
| Error message -> return! Error.server message next ctx
| Error message -> return! Error.server message next ctx
}
/// Render a bare view for the specified theme, using the specified template and hash
let bareForTheme themeId template next ctx (hash : Hash) = task {
let! hash = addViewContext ctx hash
let withContent = task {
if hash.ContainsKey ViewContext.Content then return Ok hash
else
match! TemplateCache.get themeId template ctx.Data with
| Ok contentTemplate -> return Ok (addToHash ViewContext.Content (contentTemplate.Render hash) hash)
| Error message -> return Error message
}
match! withContent with
| Ok completeHash ->
// Bare templates are rendered with layout-bare
match! TemplateCache.get themeId "layout-bare" ctx.Data with
| Ok layoutTemplate ->
return!
(messagesToHeaders (hash[ViewContext.Messages] :?> UserMessage[])
>=> htmlString (layoutTemplate.Render completeHash))
next ctx
| Error message -> return! Error.server message next ctx
| Error message -> return! Error.server message next ctx
}
/// Return a view for the web log's default theme
let themedView template next ctx hash = task {
let! hash = addViewContext ctx hash
return! viewForTheme (hash[ViewContext.WebLog] :?> WebLog).ThemeId template next ctx hash
}
/// The ID for the admin theme
let adminTheme = ThemeId "admin"
/// Display a view for the admin theme
let adminView template =
viewForTheme adminTheme template
/// Display a bare view for the admin theme
let adminBareView template =
bareForTheme adminTheme template
/// Validate the anti cross-site request forgery token in the current request
let validateCsrf : HttpHandler = fun next ctx -> task {
match! ctx.AntiForgery.IsRequestValidAsync ctx with
| true -> return! next ctx
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx
}
/// Require a user to be logged on /// Require a user to be logged on
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized

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,12 +106,14 @@ let router : HttpHandler = choose [
] ]
subRoute "/admin" (requireUser >=> choose [ subRoute "/admin" (requireUser >=> choose [
GET_HEAD >=> choose [ GET_HEAD >=> choose [
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
subRoute "/page" (choose [ subRoute "/page" (choose [
route "s" >=> Page.all 1 route "s" >=> Page.all 1
routef "s/page/%i" Page.all routef "s/page/%i" Page.all
@@ -129,34 +131,36 @@ 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
subRoute "/rss" (choose [ routef "/rss/%s/edit" Feed.editCustomFeed
route "" >=> Feed.editSettings subRoute "/user" (choose [
routef "/%s/edit" Feed.editCustomFeed route "s" >=> User.all
routef "/%s/edit" User.edit
]) ])
subRoute "/tag-mapping" (choose [ subRoute "/tag-mapping" (choose [
route "s" >=> Admin.tagMappings route "s" >=> Admin.TagMapping.all
route "s/bare" >=> Admin.tagMappingsBare routef "/%s/edit" Admin.TagMapping.edit
routef "/%s/edit" Admin.editMapping
]) ])
]) ])
route "/theme/update" >=> Admin.themeUpdatePage subRoute "/theme" (choose [
route "/list" >=> Admin.Theme.all
route "/new" >=> Admin.Theme.add
])
subRoute "/upload" (choose [ subRoute "/upload" (choose [
route "s" >=> Upload.list route "s" >=> Upload.list
route "/new" >=> Upload.showNew route "/new" >=> Upload.showNew
]) ])
subRoute "/user" (choose [
route "s" >=> User.all
route "s/bare" >=> User.bare
route "/my-info" >=> User.myInfo
routef "/%s/edit" User.edit
])
] ]
POST >=> validateCsrf >=> choose [ POST >=> validateCsrf >=> choose [
subRoute "/category" (choose [ subRoute "/cache" (choose [
route "/save" >=> Admin.saveCategory routef "/theme/%s/refresh" Admin.Cache.refreshTheme
routef "/%s/delete" Admin.deleteCategory routef "/web-log/%s/refresh" Admin.Cache.refreshWebLog
]) ])
subRoute "/category" (choose [
route "/save" >=> Admin.Category.save
routef "/%s/delete" Admin.Category.delete
])
route "/my-info" >=> User.saveMyInfo
subRoute "/page" (choose [ subRoute "/page" (choose [
route "/save" >=> Page.save route "/save" >=> Page.save
route "/permalinks" >=> Page.savePermalinks route "/permalinks" >=> Page.savePermalinks
@@ -174,28 +178,30 @@ 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 [
route "/save" >=> User.save
routef "/%s/delete" User.delete
]) ])
]) ])
route "/theme/update" >=> Admin.updateTheme subRoute "/theme" (choose [
route "/new" >=> Admin.Theme.save
routef "/%s/delete" Admin.Theme.delete
])
subRoute "/upload" (choose [ subRoute "/upload" (choose [
route "/save" >=> Upload.save route "/save" >=> Upload.save
routexp "/delete/(.*)" Upload.deleteFromDisk routexp "/delete/(.*)" Upload.deleteFromDisk
routef "/%s/delete" Upload.deleteFromDb routef "/%s/delete" Upload.deleteFromDb
]) ])
subRoute "/user" (choose [
route "/my-info" >=> User.saveMyInfo
route "/save" >=> User.save
routef "/%s/delete" User.delete
])
] ]
]) ])
GET_HEAD >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts GET_HEAD >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts

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
@@ -72,34 +75,18 @@ let logOff : HttpHandler = fun next ctx -> task {
open System.Collections.Generic open System.Collections.Generic
open Giraffe.Htmx open Giraffe.Htmx
open Microsoft.AspNetCore.Http
/// Create the hash needed to display the user list /// Got no time for URL/form manipulators...
let private userListHash (ctx : HttpContext) = task { let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?"
// GET /admin/settings/users
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id
return! return!
hashForPage "User Administration" hashForPage "User Administration"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList) |> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|> addViewContext ctx |> adminBareView "user-list-body" next ctx
}
/// Got no time for URL/form manipulators...
let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?"
// GET /admin/users
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! hash = userListHash ctx
let! tmpl = TemplateCache.get "admin" "user-list-body" ctx.Data
return!
addToHash "user_list" (tmpl.Render hash) hash
|> adminView "user-list" next ctx
}
// GET /admin/users/bare
let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! hash = userListHash ctx
return! adminBareView "user-list-body" next ctx hash
} }
/// Show the edit user page /// Show the edit user page
@@ -116,7 +103,7 @@ let private showEdit (model : EditUserModel) : HttpHandler = fun next ctx ->
|] |]
|> adminBareView "user-edit" next ctx |> adminBareView "user-edit" next ctx
// GET /admin/user/{id}/edit // GET /admin/settings/user/{id}/edit
let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let isNew = usrId = "new" let isNew = usrId = "new"
let userId = WebLogUserId usrId let userId = WebLogUserId usrId
@@ -128,44 +115,7 @@ let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> tas
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// POST /admin/user/save // POST /admin/settings/user/{id}/delete
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditUserModel> ()
let data = ctx.Data
let tryUser =
if model.IsNew then
{ WebLogUser.empty with
Id = WebLogUserId.create ()
WebLogId = ctx.WebLog.Id
CreatedOn = DateTime.UtcNow
} |> someTask
else data.WebLogUser.FindById (WebLogUserId model.Id) ctx.WebLog.Id
match! tryUser with
| Some user when model.Password = model.PasswordConfirm ->
let updatedUser = model.UpdateUser user
if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then
return! goAway next ctx
else
let updatedUser =
if model.Password = "" then updatedUser
else
let salt = Guid.NewGuid ()
{ updatedUser with PasswordHash = hashedPassword model.Password model.Email salt; Salt = salt }
do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) updatedUser
do! addMessage ctx
{ UserMessage.success with
Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully"""
}
return! bare next ctx
| Some _ ->
do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" }
return!
(withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" })
next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/user/{id}/delete
let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
match! data.WebLogUser.FindById (WebLogUserId userId) ctx.WebLog.Id with match! data.WebLogUser.FindById (WebLogUserId userId) ctx.WebLog.Id with
@@ -179,14 +129,14 @@ let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
{ UserMessage.success with { UserMessage.success with
Message = $"User {WebLogUser.displayName user} deleted successfully" Message = $"User {WebLogUser.displayName user} deleted successfully"
} }
return! bare next ctx return! all next ctx
| Error msg -> | Error msg ->
do! addMessage ctx do! addMessage ctx
{ UserMessage.error with { UserMessage.error with
Message = $"User {WebLogUser.displayName user} was not deleted" Message = $"User {WebLogUser.displayName user} was not deleted"
Detail = Some msg Detail = Some msg
} }
return! bare next ctx return! all next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@@ -201,14 +151,14 @@ let private showMyInfo (model : EditMyInfoModel) (user : WebLogUser) : HttpHandl
|> adminView "my-info" next ctx |> adminView "my-info" next ctx
// GET /admin/user/my-info // GET /admin/my-info
let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task { let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
| Some user -> return! showMyInfo (EditMyInfoModel.fromUser user) user next ctx | Some user -> return! showMyInfo (EditMyInfoModel.fromUser user) user next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// POST /admin/user/my-info // POST /admin/my-info
let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task { let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditMyInfoModel> () let! model = ctx.BindFormAsync<EditMyInfoModel> ()
let data = ctx.Data let data = ctx.Data
@@ -231,9 +181,50 @@ let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
do! data.WebLogUser.Update user do! data.WebLogUser.Update user
let pwMsg = if model.NewPassword = "" then "" else " and updated your password" let pwMsg = if model.NewPassword = "" then "" else " and updated your password"
do! addMessage ctx { UserMessage.success with Message = $"Saved your information{pwMsg} successfully" } do! addMessage ctx { UserMessage.success with Message = $"Saved your information{pwMsg} successfully" }
return! redirectToGet "admin/user/my-info" next ctx return! redirectToGet "admin/my-info" next ctx
| Some user -> | Some user ->
do! addMessage ctx { UserMessage.error with Message = "Passwords did not match; no updates made" } do! addMessage ctx { UserMessage.error with Message = "Passwords did not match; no updates made" }
return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// User save is not statically compilable; not sure why, but we'll revisit it at some point
#nowarn "3511"
// POST /admin/settings/user/save
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditUserModel> ()
let data = ctx.Data
let tryUser =
if model.IsNew then
{ WebLogUser.empty with
Id = WebLogUserId.create ()
WebLogId = ctx.WebLog.Id
CreatedOn = DateTime.UtcNow
} |> someTask
else data.WebLogUser.FindById (WebLogUserId model.Id) ctx.WebLog.Id
match! tryUser with
| Some user when model.Password = model.PasswordConfirm ->
let updatedUser = model.UpdateUser user
if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then
return! goAway next ctx
else
let toUpdate =
if model.Password = "" then updatedUser
else
let salt = Guid.NewGuid ()
{ updatedUser with PasswordHash = hashedPassword model.Password model.Email salt; Salt = salt }
do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) toUpdate
do! addMessage ctx
{ UserMessage.success with
Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully"""
}
return! all next ctx
| Some _ ->
do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" }
return!
(withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" })
next ctx
| None -> return! Error.notFound next ctx
}

View File

@@ -128,26 +128,28 @@ let importLinks args sp = task {
// Loading a theme and restoring a backup are not statically compilable; this is OK // Loading a theme and restoring a backup are not statically compilable; this is OK
#nowarn "3511" #nowarn "3511"
open Microsoft.Extensions.Logging
/// Load a theme from the given ZIP file /// Load a theme from the given ZIP file
let loadTheme (args : string[]) (sp : IServiceProvider) = task { let loadTheme (args : string[]) (sp : IServiceProvider) = task {
if args.Length > 1 then if args.Length = 2 then
let fileName = let fileName =
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.getThemeName fileName with match Handlers.Admin.Theme.deriveIdFromFileName fileName with
| Ok themeName -> | Ok themeId ->
let data = sp.GetRequiredService<IData> () let data = sp.GetRequiredService<IData> ()
let clean = if args.Length > 2 then bool.Parse args[2] else true
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
do! Handlers.Admin.loadThemeFromZip themeName copy clean data let! theme = Handlers.Admin.Theme.loadFromZip themeId copy data
printfn $"Theme {themeName} loaded successfully" let fac = sp.GetRequiredService<ILoggerFactory> ()
let log = fac.CreateLogger "MyWebLog.Themes"
log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded"
| Error message -> eprintfn $"{message}" | Error message -> eprintfn $"{message}"
else else
eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name] [*clean-load]" eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name]"
eprintfn " * optional, defaults to true"
} }
/// Back up a web log's data /// Back up a web log's data

View File

@@ -2,11 +2,8 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>
<SelfContained>false</SelfContained> <SelfContained>false</SelfContained>
<DebugType>embedded</DebugType>
<NoWarn>3391</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -82,6 +82,7 @@ let showHelp () =
Task.FromResult () Task.FromResult ()
open System.IO
open Giraffe open Giraffe
open Giraffe.EndpointRouting open Giraffe.EndpointRouting
open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Authentication.Cookies
@@ -135,7 +136,7 @@ let rec main args =
|> ignore |> ignore
builder.Services.AddScoped<IData, SQLiteData> () |> ignore builder.Services.AddScoped<IData, SQLiteData> () |> ignore
// Use SQLite for caching as well // Use SQLite for caching as well
let cachePath = Option.ofObj (cfg.GetConnectionString "SQLiteCachePath") |> Option.defaultValue "./session.db" let cachePath = defaultArg (Option.ofObj (cfg.GetConnectionString "SQLiteCachePath")) "./session.db"
builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore
| _ -> () | _ -> ()
@@ -162,7 +163,11 @@ let rec main args =
| Some it -> | Some it ->
printfn $"""Unrecognized command "{it}" - valid commands are:""" printfn $"""Unrecognized command "{it}" - valid commands are:"""
showHelp () showHelp ()
| None -> | None -> task {
// Load all themes in the application directory
for themeFile in Directory.EnumerateFiles (".", "*-theme.zip") do
do! Maintenance.loadTheme [| ""; themeFile |] app.Services
let _ = app.UseForwardedHeaders () let _ = app.UseForwardedHeaders ()
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
let _ = app.UseMiddleware<WebLogMiddleware> () let _ = app.UseMiddleware<WebLogMiddleware> ()
@@ -172,7 +177,8 @@ let rec main args =
let _ = app.UseSession () let _ = app.UseSession ()
let _ = app.UseGiraffe Handlers.Routes.endpoint let _ = app.UseGiraffe Handlers.Routes.endpoint
Task.FromResult (app.Run ()) app.Run ()
}
|> Async.AwaitTask |> Async.RunSynchronously |> Async.AwaitTask |> Async.RunSynchronously
0 // Exit code 0 // Exit code

View File

@@ -1,5 +1,5 @@
{ {
"Generator": "myWebLog 2.0-beta05", "Generator": "myWebLog 2.0-rc1",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"MyWebLog.Handlers": "Information" "MyWebLog.Handlers": "Information"

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

@@ -7,28 +7,42 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarText"> <div class="collapse navbar-collapse" id="navbarText">
{% if is_logged_on -%} {%- if is_logged_on %}
<ul class="navbar-nav"> <ul class="navbar-nav">
{{ "admin/dashboard" | nav_link: "Dashboard" }} {{ "admin/dashboard" | nav_link: "Dashboard" }}
{% if is_author %} {%- if is_author %}
{{ "admin/pages" | nav_link: "Pages" }} {{ "admin/pages" | nav_link: "Pages" }}
{{ "admin/posts" | nav_link: "Posts" }} {{ "admin/posts" | nav_link: "Posts" }}
{{ "admin/uploads" | nav_link: "Uploads" }} {{ "admin/uploads" | nav_link: "Uploads" }}
{% endif %} {%- endif %}
{% if is_web_log_admin %} {%- if is_web_log_admin %}
{{ "admin/categories" | nav_link: "Categories" }} {{ "admin/categories" | nav_link: "Categories" }}
{{ "admin/users" | nav_link: "Users" }}
{{ "admin/settings" | nav_link: "Settings" }} {{ "admin/settings" | nav_link: "Settings" }}
{% endif %} {%- endif %}
{%- if is_administrator %}
{{ "admin/administration" | nav_link: "Admin" }}
{%- endif %}
</ul> </ul>
{%- endif %} {%- endif %}
<ul class="navbar-nav flex-grow-1 justify-content-end"> <ul class="navbar-nav flex-grow-1 justify-content-end">
{% if is_logged_on -%} {%- if is_logged_on %}
{{ "admin/user/my-info" | nav_link: "My Info" }} {{ "admin/my-info" | nav_link: "My Info" }}
<li class="nav-item">
<a class="nav-link" href="https://bitbadger.solutions/open-source/myweblog/#how-to-use-myweblog"
target="_blank">
Docs
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ "user/log-off" | relative_link }}" hx-boost="false">Log Off</a> <a class="nav-link" href="{{ "user/log-off" | relative_link }}" hx-boost="false">Log Off</a>
</li> </li>
{%- else -%} {%- else -%}
<li class="nav-item">
<a class="nav-link" href="https://bitbadger.solutions/open-source/myweblog/#how-to-use-myweblog"
target="_blank">
Docs
</a>
</li>
{{ "user/log-on" | nav_link: "Log On" }} {{ "user/log-on" | nav_link: "Log On" }}
{%- endif %} {%- endif %}
</ul> </ul>
@@ -36,29 +50,36 @@
</div> </div>
</nav> </nav>
</header> </header>
<main class="mx-3 mt-3"> <div id="toastHost" class="position-fixed top-0 w-100" aria-live="polite" aria-atomic="true">
<div class="messages mt-2" id="msgContainer"> <div id="toasts" class="toast-container position-absolute p-3 mt-5 top-0 end-0">
{% for msg in messages %} {% for msg in messages %}
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show"> <div class="toast" role="alert" aria-live="assertive" aria-atomic="true"
{%- unless msg.level == "success" %} data-bs-autohide="false"{% endunless %}>
<div class="toast-header bg-{{ msg.level }}{% unless msg.level == "warning" %} text-white{% endunless %}">
<strong class="me-auto text-uppercase">
{% if msg.level == "danger" %}error{% else %}{{ msg.level}}{% endif %}
</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body bg-{{ msg.level }} bg-opacity-25">
{{ msg.message }} {{ msg.message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> {%- if msg.detail %}
{% if msg.detail %}
<hr> <hr>
{{ msg.detail.value }} {{ msg.detail.value }}
{% endif %} {%- endif %}
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
<main class="mx-3 mt-3">
<div class="load-overlay p-5" id="loadOverlay"><h1 class="p-3">Loading&hellip;</h1></div>
{{ content }} {{ content }}
</main> </main>
<footer class="position-fixed bottom-0 w-100"> <footer class="position-fixed bottom-0 w-100">
<div class="container-fluid"> <div class="text-end text-white me-2">
<div class="row">
<div class="col-xs-12 text-end">
{%- assign version = generator | split: " " -%} {%- assign version = generator | split: " " -%}
<small class="me-1 align-baseline">v{{ version[1] }}</small> <small class="me-1 align-baseline">v{{ version[1] }}</small>
<img src="{{ "themes/admin/logo-light.png" | relative_link }}" alt="myWebLog" width="120" height="34"> <img src="{{ "themes/admin/logo-light.png" | relative_link }}" alt="myWebLog" width="120" height="34">
</div> </div>
</div>
</div>
</footer> </footer>

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<fieldset class="container mb-3 pb-0">
<legend>Themes</legend>
<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/" -%}
<legend>Caches</legend>
<div class="row pb-2">
<div class="col">
<p>
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>
<div class="row">
<div class="col-12 col-lg-6 pb-3">
<div class="card">
<header class="card-header text-white bg-secondary">Web Logs</header>
<div class="card-body pb-0">
<h6 class="card-subtitle text-muted pb-3">
These caches include the page list and categories for each web log
</h6>
{%- assign web_log_base_url = cache_base_url | append: "web-log/" -%}
<form method="post" class="container g-0" hx-boost="false" hx-target="body"
hx-swap="innerHTML show:window:top">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<button type="submit" class="btn btn-sm btn-primary mb-2"
hx-post="{{ web_log_base_url | append: "all/refresh" | relative_link }}">
Refresh All
</button>
<div class="row mwl-table-heading">
<div class="col">Web Log</div>
</div>
{%- for web_log in web_logs %}
<div class="row mwl-table-detail">
<div class="col">
{{ web_log[1] }}<br>
<small>
<span class="text-muted">{{ web_log[2] }}</span><br>
{%- assign refresh_url = web_log_base_url | append: web_log[0] | append: "/refresh" | relative_link -%}
<a href="{{ refresh_url }}" hx-post="{{ refresh_url }}">Refresh</a>
</small>
</div>
</div>
{%- endfor %}
</form>
</div>
</div>
</div>
<div class="col-12 col-lg-6 pb-3">
<div class="card">
<header class="card-header text-white bg-secondary">Themes</header>
<div class="card-body pb-0">
<h6 class="card-subtitle text-muted pb-3">
The theme template cache is filled on demand as pages are displayed; refreshing a theme with no cached
templates will still refresh its asset cache
</h6>
{%- assign theme_base_url = cache_base_url | append: "theme/" -%}
<form method="post" class="container g-0" hx-boost="false" hx-target="body"
hx-swap="innerHTML show:window:top">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<button type="submit" class="btn btn-sm btn-primary mb-2"
hx-post="{{ theme_base_url | append: "all/refresh" | relative_link }}">
Refresh All
</button>
<div class="row mwl-table-heading">
<div class="col-8">Theme</div>
<div class="col-4">Cached</div>
</div>
{%- for theme in cached_themes %}
{% unless theme[0] == "admin" %}
<div class="row mwl-table-detail">
<div class="col-8">
{{ theme[1] }}<br>
<small>
<span class="text-muted">{{ theme[0] }} &bull; </span>
{%- assign refresh_url = theme_base_url | append: theme[0] | append: "/refresh" | relative_link -%}
<a href="{{ refresh_url }}" hx-post="{{ refresh_url }}">Refresh</a>
</small>
</div>
<div class="col-4">{{ theme[2] }}</div>
</div>
{% endunless %}
{%- endfor %}
</form>
</div>
</div>
</div>
</div>
</fieldset>
</article>

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

@@ -1,10 +1,19 @@
<form method="post" id="catList" class="container" hx-target="this" hx-swap="outerHTML show:window:top"> <div id="catList" class="container">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <div class="row">
<div class="row mwl-table-detail" id="cat_new"></div> <div class="col">
{%- assign cat_count = categories | size -%} {%- assign cat_count = categories | size -%}
{% if cat_count > 0 %} {% if cat_count > 0 %}
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%} {%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%} {%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
<div class="container">
<div class="row mwl-table-heading">
<div class="{{ cat_col }}">Category<span class="d-md-none">; Description</span></div>
<div class="{{ desc_col }} d-none d-md-inline-block">Description</div>
</div>
</div>
<form method="post" class="container" hx-target="#catList" hx-swap="outerHTML show:window:top">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="row mwl-table-detail" id="cat_new"></div>
{% for cat in categories -%} {% for cat in categories -%}
<div class="row mwl-table-detail" id="cat_{{ cat.id }}"> <div class="row mwl-table-detail" id="cat_{{ cat.id }}">
<div class="{{ cat_col }} no-wrap"> <div class="{{ cat_col }} no-wrap">
@@ -37,9 +46,12 @@
</div> </div>
</div> </div>
{%- endfor %} {%- endfor %}
</form>
{%- else -%} {%- else -%}
<div class="row"> <div id="cat_new">
<div class="col-12 text-muted fst-italic text-center">This web log has no categores defined</div> <p class="text-muted fst-italic text-center">This web log has no categores defined</p>
</div> </div>
{%- endif %} {%- endif %}
</form> </div>
</div>
</div>

View File

@@ -4,13 +4,5 @@
hx-target="#cat_new"> hx-target="#cat_new">
Add a New Category Add a New Category
</a> </a>
<div class="container">
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
<div class="row mwl-table-heading">
<div class="{{ cat_col }}">Category<span class="d-md-none">; Description</span></div>
<div class="{{ desc_col }} d-none d-md-inline-block">Description</div>
</div>
</div>
{{ category_list }} {{ category_list }}
</article> </article>

View File

@@ -5,6 +5,5 @@
</head> </head>
<body> <body>
{% include_template "_layout" %} {% include_template "_layout" %}
<script>Admin.dismissSuccesses()</script>
</body> </body>
</html> </html>

View File

@@ -4,29 +4,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="{{ generator }}"> <meta name="generator" content="{{ generator }}">
<title>{{ page_title | strip_html }} &laquo; Admin &laquo; {{ web_log.name | strip_html }}</title> <title>{{ page_title | strip_html }} &laquo; Admin &laquo; {{ web_log.name | strip_html }}</title>
<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">
<link rel="stylesheet" href="{{ "themes/admin/admin.css" | relative_link }}"> <link rel="stylesheet" href="{{ "themes/admin/admin.css" | relative_link }}">
</head> </head>
<body hx-boost="true"> <body hx-boost="true" hx-indicator="#loadOverlay">
{% include_template "_layout" %} {% include_template "_layout" %}
<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>
{{ 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>
<script>Admin.dismissSuccesses()</script>
</body> </body>
</html> </html>

View File

@@ -1,6 +1,6 @@
<h2 class="my-3">{{ page_title }}</h2> <h2 class="my-3">{{ page_title }}</h2>
<article> <article>
<form action="{{ "admin/user/my-info" | relative_link }}" method="post"> <form action="{{ "admin/my-info" | relative_link }}" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="d-flex flex-row flex-wrap justify-content-around"> <div class="d-flex flex-row flex-wrap justify-content-around">
<div class="text-center mb-3 lh-sm"> <div class="text-center mb-3 lh-sm">

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

@@ -2,6 +2,7 @@
<article> <article>
<a href="{{ "admin/page/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Create a New Page</a> <a href="{{ "admin/page/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Create a New Page</a>
{%- assign page_count = pages | size -%} {%- assign page_count = pages | size -%}
{% if page_count > 0 %}
{%- assign title_col = "col-12 col-md-5" -%} {%- assign title_col = "col-12 col-md-5" -%}
{%- assign link_col = "col-12 col-md-5" -%} {%- assign link_col = "col-12 col-md-5" -%}
{%- assign upd8_col = "col-12 col-md-2" -%} {%- assign upd8_col = "col-12 col-md-2" -%}
@@ -14,7 +15,6 @@
<div class="{{ link_col }} d-none d-md-inline-block">Permalink</div> <div class="{{ link_col }} d-none d-md-inline-block">Permalink</div>
<div class="{{ upd8_col }} d-none d-md-inline-block">Updated</div> <div class="{{ upd8_col }} d-none d-md-inline-block">Updated</div>
</div> </div>
{% if page_count > 0 %}
{% for pg in pages -%} {% for pg in pages -%}
<div class="row mwl-table-detail"> <div class="row mwl-table-detail">
<div class="{{ title_col }}"> <div class="{{ title_col }}">
@@ -48,18 +48,13 @@
</div> </div>
</div> </div>
{%- endfor %} {%- endfor %}
{% else %}
<div class="row">
<div class="col text-muted fst-italic text-center">This web log has no pages</div>
</div>
{% endif %}
</form> </form>
{% if page_nbr > 1 or page_count == 25 %} {% if page_nbr > 1 or page_count == 25 %}
<div class="d-flex justify-content-evenly pb-3"> <div class="d-flex justify-content-evenly mb-3">
<div> <div>
{% if page_nbr > 1 %} {% if page_nbr > 1 %}
<p> <p>
<a class="btn btn-default" href="{{ "admin/pages" | append: prev_page | relative_link }}"> <a class="btn btn-secondary" href="{{ "admin/pages" | append: prev_page | relative_link }}">
&laquo; Previous &laquo; Previous
</a> </a>
</p> </p>
@@ -68,10 +63,15 @@
<div class="text-right"> <div class="text-right">
{% if page_count == 25 %} {% if page_count == 25 %}
<p> <p>
<a class="btn btn-default" href="{{ "admin/pages" | append: next_page | relative_link }}">Next &raquo;</a> <a class="btn btn-secondary" href="{{ "admin/pages" | append: next_page | relative_link }}">
Next &raquo;
</a>
</p> </p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% else %}
<p class="text-muted fst-italic text-center">This web log has no pages</p>
{% endif %}
</article> </article>

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 }}">
@@ -344,3 +312,4 @@
</div> </div>
</form> </form>
</article> </article>
<script>window.setTimeout(() => Admin.toggleEpisodeFields(), 500)</script>

View File

@@ -1,9 +1,10 @@
<h2 class="my-3">{{ page_title }}</h2> <h2 class="my-3">{{ page_title }}</h2>
<article> <article>
<a href="{{ "admin/post/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Write a New Post</a> <a href="{{ "admin/post/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Write a New Post</a>
{%- assign post_count = model.posts | size -%}
{%- if post_count > 0 %}
<form method="post" class="container" hx-target="body"> <form method="post" class="container" hx-target="body">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
{%- assign post_count = model.posts | size -%}
{%- assign date_col = "col-xs-12 col-md-3 col-lg-2" -%} {%- assign date_col = "col-xs-12 col-md-3 col-lg-2" -%}
{%- assign title_col = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4" -%} {%- assign title_col = "col-xs-12 col-md-7 col-lg-6 col-xl-5 col-xxl-4" -%}
{%- assign author_col = "col-xs-12 col-md-2 col-lg-1" -%} {%- assign author_col = "col-xs-12 col-md-2 col-lg-1" -%}
@@ -16,7 +17,6 @@
<div class="{{ author_col }} d-none d-md-inline-block">Author</div> <div class="{{ author_col }} d-none d-md-inline-block">Author</div>
<div class="{{ tag_col }}">Tags</div> <div class="{{ tag_col }}">Tags</div>
</div> </div>
{%- if post_count > 0 %}
{% for post in model.posts -%} {% for post in model.posts -%}
<div class="row mwl-table-detail"> <div class="row mwl-table-detail">
<div class="{{ date_col }} no-wrap"> <div class="{{ date_col }} no-wrap">
@@ -77,24 +77,22 @@
</div> </div>
</div> </div>
{%- endfor %} {%- endfor %}
{% else %}
<div class="row">
<div class="col text-muted fst-italic text-center">This web log has no posts</div>
</div>
{% endif %}
</form> </form>
{% if model.newer_link or model.older_link %} {% if model.newer_link or model.older_link %}
<div class="d-flex justify-content-evenly"> <div class="d-flex justify-content-evenly mb-3">
<div> <div>
{% if model.newer_link %} {% if model.newer_link %}
<p><a class="btn btn-default" href="{{ model.newer_link.value }}">&laquo; Newer Posts</a></p> <p><a class="btn btn-secondary" href="{{ model.newer_link.value }}">&laquo; Newer Posts</a></p>
{% endif %} {% endif %}
</div> </div>
<div class="text-right"> <div class="text-right">
{% if model.older_link %} {% if model.older_link %}
<p><a class="btn btn-default" href="{{ model.older_link.value }}">Older Posts &raquo;</a></p> <p><a class="btn btn-secondary" href="{{ model.older_link.value }}">Older Posts &raquo;</a></p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% else %}
<p class="text-muted fst-italic text-center">This web log has no posts</p>
{% endif %}
</article> </article>

View File

@@ -1,112 +0,0 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<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 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 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 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="{{ model.feed_name }}">
<label for="feedName">Feed File Name</label>
<span class="form-text">Default is <code>feed.xml</code></span>
</div>
</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="{{ 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="{{ 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>
</form>
<h3>Custom Feeds</h3>
<a class="btn btn-sm btn-secondary" href="{{ 'admin/settings/rss/new/edit' | relative_link }}">
Add a New Custom Feed
</a>
<form method="post" class="container" 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>
{%- assign feed_count = custom_feeds | size -%}
{% if feed_count > 0 %}
{% 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 %}
{% else %}
<tr>
<td colspan="3" class="text-muted fst-italic text-center">No custom feeds defined</td>
</tr>
{% endif %}
</form>
</article>

View File

@@ -1,9 +1,11 @@
<h2 class="my-3">{{ web_log.name }} Settings</h2> <h2 class="my-3">{{ web_log.name }} Settings</h2>
<p class="text-muted">
Other Settings: <a href="{{ "admin/settings/tag-mappings" | relative_link }}">Tag Mappings</a> &bull;
<a href="{{ "admin/settings/rss" | relative_link }}">RSS Settings</a>
</p>
<article> <article>
<p class="text-muted">
Go to: <a href="#users">Users</a> &bull; <a href="#rss-settings">RSS Settings</a> &bull;
<a href="#tag-mappings">Tag Mappings</a>
</p>
<fieldset class="container mb-3">
<legend>Web Log Settings</legend>
<form action="{{ "admin/settings" | relative_link }}" method="post"> <form action="{{ "admin/settings" | relative_link }}" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container"> <div class="container">
@@ -21,7 +23,8 @@
value="{{ model.slug }}"> value="{{ model.slug }}">
<label for="slug">Slug</label> <label for="slug">Slug</label>
<span class="form-text"> <span class="form-text">
<span class="badge rounded-pill bg-warning text-dark">WARNING</span> changing this value may break links <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" (<a href="https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
target="_blank">more</a>) target="_blank">more</a>)
</span> </span>
@@ -49,9 +52,8 @@
<div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3"> <div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3">
<div class="form-floating"> <div class="form-floating">
<select name="DefaultPage" id="defaultPage" class="form-control" required> <select name="DefaultPage" id="defaultPage" class="form-control" required>
{% for pg in pages -%} {%- for pg in pages %}
<option value="{{ pg[0] }}" <option value="{{ pg[0] }}"{% if pg[0] == model.default_page %} selected="selected"{% endif %}>
{%- if pg[0] == model.default_page %} selected="selected"{% endif %}>
{{ pg[1] }} {{ pg[1] }}
</option> </option>
{%- endfor %} {%- endfor %}
@@ -103,4 +105,142 @@
</div> </div>
</div> </div>
</form> </form>
</fieldset>
<fieldset id="users" class="container mb-3 pb-0">
<legend>Users</legend>
{% include_template "_user-list-columns" %}
<a href="{{ "admin/settings/user/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
hx-target="#user_new">
Add a New User
</a>
<div class="container g-0">
<div class="row mwl-table-heading">
<div class="{{ user_col }}">User<span class="d-md-none">; Full Name / E-mail; Last Log On</span></div>
<div class="{{ email_col }} d-none d-md-inline-block">Full Name / E-mail</div>
<div class="{{ cre8_col }}">Created</div>
<div class="{{ last_col }} d-none d-md-block">Last Log On</div>
</div>
</div>
{{ user_list }}
</fieldset>
<fieldset id="rss-settings" class="container mb-3 pb-0">
<legend>RSS Settings</legend>
<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>
</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 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>
</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>
<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> </article>

View File

@@ -22,7 +22,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col text-center"> <div class="col text-center">
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button> <button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
<a href="{{ "admin/settings/tag-mappings/bare" | relative_link }}" class="btn btn-sm btn-secondary ms-3"> <a href="{{ "admin/settings/tag-mappings" | relative_link }}" class="btn btn-sm btn-secondary ms-3">
Cancel Cancel
</a> </a>
</div> </div>

View File

@@ -1,8 +1,17 @@
<form method="post" class="container" id="tagList" hx-target="this" hx-swap="outerHTML show:window:top"> <div id="tagList" class="container">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <div class="row">
<div class="row mwl-table-detail" id="tag_new"></div> <div class="col">
{%- assign map_count = mappings | size -%} {%- assign map_count = mappings | size -%}
{% if map_count > 0 -%} {% if map_count > 0 -%}
<div class="container">
<div class="row mwl-table-heading">
<div class="col">Tag</div>
<div class="col">URL Value</div>
</div>
</div>
<form method="post" class="container" hx-target="#tagList" hx-swap="outerHTML">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="row mwl-table-detail" id="tag_new"></div>
{% for map in mappings -%} {% for map in mappings -%}
{%- assign map_id = mapping_ids | value: map.tag -%} {%- assign map_id = mapping_ids | value: map.tag -%}
<div class="row mwl-table-detail" id="tag_{{ map_id }}"> <div class="row mwl-table-detail" id="tag_{{ map_id }}">
@@ -25,9 +34,12 @@
<div class="col">{{ map.url_value }}</div> <div class="col">{{ map.url_value }}</div>
</div> </div>
{%- endfor %} {%- endfor %}
</form>
{%- else -%} {%- else -%}
<div class="row"> <div id="tag_new">
<div class="col text-muted text-center fst-italic">This web log has no tag mappings</div> <p class="text-muted text-center fst-italic">This web log has no tag mappings</p>
</div> </div>
{%- endif %} {%- endif %}
</form> </div>
</div>
</div>

View File

@@ -1,14 +0,0 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<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>
<div class="container">
<div class="row mwl-table-heading">
<div class="col">Tag</div>
<div class="col">URL Value</div>
</div>
</div>
{{ tag_mapping_list }}
</article>

View File

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

View File

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

View File

@@ -7,20 +7,20 @@
<form method="post" class="container" hx-target="body"> <form method="post" class="container" hx-target="body">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="row"> <div class="row">
<div class="col text-muted text-center"><em>Uploaded files served from</em><br>{{ upload_base }}</div> <div class="col text-center"><em class="text-muted">Uploaded files served from</em><br>{{ upload_base }}</div>
</div> </div>
{%- assign file_count = files | size -%}
{%- if file_count > 0 %}
<div class="row mwl-table-heading"> <div class="row mwl-table-heading">
<div class="col-6">File Name</div> <div class="col-6">File Name</div>
<div class="col-3">Path</div> <div class="col-3">Path</div>
<div class="col-3">File Date/Time</div> <div class="col-3">File Date/Time</div>
</div> </div>
{%- assign file_count = files | size -%}
{%- if file_count > 0 %}
{% for file in files %} {% for file in files %}
<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
@@ -69,7 +69,7 @@
{% endfor %} {% endfor %}
{%- else -%} {%- else -%}
<div class="row"> <div class="row">
<div class="col text-muted fst-italic text-center">This web log has uploaded files</div> <div class="col text-muted fst-italic text-center"><br>This web log has uploaded files</div>
</div> </div>
{%- endif %} {%- endif %}
</form> </form>

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

@@ -1,26 +0,0 @@
<h2>Upload a Theme</h2>
<article>
<form action="{{ "admin/theme/update" | relative_link }}"
method="post" class="container" enctype="multipart/form-data" hx-boost="false">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="row">
<div class="col-12 col-sm-6 offset-sm-3 pb-3">
<div class="form-floating">
<input type="file" id="file" name="file" class="form-control" accept=".zip" placeholder="Theme File" required>
<label for="file">Theme File</label>
</div>
</div>
<div class="col-12 col-sm-6 pb-3">
<div class="form-check form-switch pb-2">
<input type="checkbox" name="clean" id="clean" class="form-check-input" value="true">
<label for="clean" class="form-check-label">Delete Existing Theme Files</label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Upload Theme</button>
</div>
</div>
</form>
</article>

View File

@@ -1,13 +1,13 @@
<div class="col-12"> <div class="col-12">
<h5 class="my-3">{{ page_title }}</h5> <h5 class="my-3">{{ page_title }}</h5>
<form hx-post="{{ "admin/user/save" | relative_link }}" method="post" class="container" <form hx-post="{{ "admin/settings/user/save" | relative_link }}" method="post" class="container"
hx-target="#userList" hx-swap="outerHTML show:window:top"> hx-target="#userList" hx-swap="outerHTML show:window:top">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type="hidden" name="Id" value="{{ model.id }}"> <input type="hidden" name="Id" value="{{ model.id }}">
<div class="row"> <div class="row">
<div class="col-12 col-md-5 col-lg-3 col-xxl-2 offset-xxl-1 mb-3"> <div class="col-12 col-md-5 col-lg-3 col-xxl-2 offset-xxl-1 mb-3">
<div class="form-floating"> <div class="form-floating">
<select name="AccessLevel" id="accessLevel" class="form-control" required> <select name="AccessLevel" id="accessLevel" class="form-control" required autofocus>
{%- for level in access_levels %} {%- for level in access_levels %}
<option value="{{ level[0] }}"{% if model.access_level == level[0] %} selected{% endif %}> <option value="{{ level[0] }}"{% if model.access_level == level[0] %} selected{% endif %}>
{{ level[1] }} {{ level[1] }}
@@ -88,7 +88,14 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col text-center"> <div class="col text-center">
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button> <button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
<a href="{{ "admin/users/bare" | relative_link }}" class="btn btn-sm btn-secondary ms-3">Cancel</a> {% if model.is_new %}
<button type="button" class="btn btn-sm btn-secondary ms-3"
onclick="document.getElementById('user_new').innerHTML = ''">
Cancel
</button>
{% else %}
<a href="{{ "admin/settings/users" | relative_link }}" class="btn btn-sm btn-secondary ms-3">Cancel</a>
{% endif %}
</div> </div>
</div> </div>
</form> </form>

View File

@@ -1,10 +1,10 @@
<form method="post" id="userList" class="container" hx-target="this" hx-swap="outerHTML show:window:top"> <div id="userList">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <div class="container g-0">
<div class="row mwl-table-detail" id="user_new"></div> <div class="row mwl-table-detail" id="user_new"></div>
{%- assign user_col = "col-12 col-md-4 col-xl-3" -%} </div>
{%- assign email_col = "col-12 col-md-4 col-xl-4" -%} <form method="post" id="userList" class="container g-0" hx-target="this" hx-swap="outerHTML show:window:top">
{%- assign cre8_col = "d-none d-xl-block col-xl-2" -%} <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
{%- assign last_col = "col-12 col-md-4 col-xl-3" -%} {% include_template "_user-list-columns" %}
{%- assign badge = "ms-2 badge bg" -%} {%- assign badge = "ms-2 badge bg" -%}
{% for user in users -%} {% for user in users -%}
<div class="row mwl-table-detail" id="user_{{ user.id }}"> <div class="row mwl-table-detail" id="user_{{ user.id }}">
@@ -21,7 +21,7 @@
{%- endif %}<br> {%- endif %}<br>
{%- unless is_administrator == false and user.access_level == "Administrator" %} {%- unless is_administrator == false and user.access_level == "Administrator" %}
<small> <small>
{%- assign user_url_base = "admin/user/" | append: user.id -%} {%- assign user_url_base = "admin/settings/user/" | append: user.id -%}
<a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}" <a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}"
hx-swap="innerHTML show:#user_{{ user.id }}:top"> hx-swap="innerHTML show:#user_{{ user.id }}:top">
Edit Edit
@@ -58,3 +58,4 @@
</div> </div>
{%- endfor %} {%- endfor %}
</form> </form>
</div>

View File

@@ -1,20 +0,0 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<a href="{{ "admin/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">
{%- assign user_col = "col-12 col-md-4 col-xl-3" -%}
{%- assign email_col = "col-12 col-md-4 col-xl-4" -%}
{%- assign cre8_col = "d-none d-xl-block col-xl-2" -%}
{%- assign last_col = "col-12 col-md-4 col-xl-3" -%}
<div class="row mwl-table-heading">
<div class="{{ user_col }}">User<span class="d-md-none">; Details; Last Log On</span></div>
<div class="{{ email_col }} d-none d-md-inline-block">Details</div>
<div class="{{ cre8_col }}">Created</div>
<div class="{{ last_col }} d-none d-md-block">Last Log On</div>
</div>
</div>
{{ user_list }}
</article>

View File

@@ -1,2 +1,2 @@
myWebLog Admin myWebLog Admin
2.0.0-beta05 2.0.0-rc1

View File

@@ -29,7 +29,6 @@ header nav {
footer { footer {
background-color: #808080; background-color: #808080;
border-top: solid 1px black; border-top: solid 1px black;
color: white;
} }
.messages { .messages {
max-width: 60rem; max-width: 60rem;
@@ -92,3 +91,27 @@ a.text-danger:link:hover, a.text-danger:visited:hover {
border-radius: .5rem; border-radius: .5rem;
padding: .5rem; padding: .5rem;
} }
.load-overlay {
display: none;
position: fixed;
top: 55px;
left: 0;
z-index: 100;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, .1);
transition: ease-in-out .5s;
}
.load-overlay h1 {
background-color: rgba(204, 204, 0, .95);
height: fit-content;
border: solid 6px darkgreen;
border-radius: 2rem;
}
.load-overlay.htmx-request {
display: flex;
flex-flow: row;
}
#toastHost {
z-index: 5;
}

View File

@@ -293,33 +293,46 @@ this.Admin = {
const parts = msg.split("|||") const parts = msg.split("|||")
if (parts.length < 2) return if (parts.length < 2) return
const msgDiv = document.createElement("div") // Create the toast header
msgDiv.className = `alert alert-${parts[0]} alert-dismissible fade show` const toastType = document.createElement("strong")
msgDiv.setAttribute("role", "alert") toastType.className = "me-auto text-uppercase"
msgDiv.innerHTML = parts[1] toastType.innerText = parts[0] === "danger" ? "error" : parts[0]
const closeBtn = document.createElement("button") const closeBtn = document.createElement("button")
closeBtn.type = "button" closeBtn.type = "button"
closeBtn.className = "btn-close" closeBtn.className = "btn-close"
closeBtn.setAttribute("data-bs-dismiss", "alert") closeBtn.setAttribute("data-bs-dismiss", "toast")
closeBtn.setAttribute("aria-label", "Close") closeBtn.setAttribute("aria-label", "Close")
msgDiv.appendChild(closeBtn)
const toastHead = document.createElement("div")
toastHead.className = `toast-header bg-${parts[0]}${parts[0] === "warning" ? "" : " text-white"}`
toastHead.appendChild(toastType)
toastHead.appendChild(closeBtn)
// Create the toast body
const toastBody = document.createElement("div")
toastBody.className = `toast-body bg-${parts[0]} bg-opacity-25`
toastBody.innerHTML = parts[1]
if (parts.length === 3) { if (parts.length === 3) {
msgDiv.innerHTML += `<hr>${parts[2]}` toastBody.innerHTML += `<hr>${parts[2]}`
} }
document.getElementById("msgContainer").appendChild(msgDiv)
})
},
/** // Assemble the toast
* Set all "success" alerts to close after 4 seconds const toast = document.createElement("div")
*/ toast.className = "toast"
dismissSuccesses() { toast.setAttribute("role", "alert")
[...document.querySelectorAll(".alert-success")].forEach(alert => { toast.setAttribute("aria-live", "assertive")
setTimeout(() => { toast.setAttribute("aria-atomic", "true")
(bootstrap.Alert.getInstance(alert) ?? new bootstrap.Alert(alert)).close() toast.appendChild(toastHead)
}, 4000) toast.appendChild(toastBody)
document.getElementById("toasts").appendChild(toast)
let options = { delay: 4000 }
if (parts[0] !== "success") options.autohide = false
const theToast = new bootstrap.Toast(toast, options)
theToast.show()
}) })
} }
} }
@@ -329,8 +342,19 @@ htmx.on("htmx:afterOnLoad", function (evt) {
// Show messages if there were any in the response // Show messages if there were any in the response
if (hdrs.indexOf("x-message") >= 0) { if (hdrs.indexOf("x-message") >= 0) {
Admin.showMessage(evt.detail.xhr.getResponseHeader("x-message")) Admin.showMessage(evt.detail.xhr.getResponseHeader("x-message"))
Admin.dismissSuccesses()
} }
// Initialize any toasts that were pre-rendered from the server
[...document.querySelectorAll(".toast")].forEach(el => {
if (el.getAttribute("data-mwl-shown") === "true" && el.className.indexOf("hide") >= 0) {
document.removeChild(el)
} else {
const toast = new bootstrap.Toast(el,
el.getAttribute("data-bs-autohide") === "false"
? { autohide: false } : { delay: 6000, autohide: true })
toast.show()
el.setAttribute("data-mwl-shown", "true")
}
})
}) })
htmx.on("htmx:responseError", function (evt) { htmx.on("htmx:responseError", function (evt) {

View File

@@ -1,12 +1,11 @@
{%- if is_category or is_tag %} {%- if is_category or is_tag %}
<h1 class="index-title">{{ page_title }}</h1> <h1 class="index-title">{{ page_title }}</h1>
{%- if is_category %} {%- if subtitle %}<h4 class="text-muted">{{ subtitle }}</h4>{% endif -%}
{%- assign cat = categories | where: "slug", slug | first -%} {% endif %}
{%- if cat.description %}<h4 class="text-muted">{{ cat.description.value }}</h4>{% endif -%} {%- assign post_count = model.posts | size -%}
{%- endif %} {%- if post_count > 0 %}
{%- endif %}
<section class="container mt-3" aria-label="The posts for the page"> <section class="container mt-3" aria-label="The posts for the page">
{% for post in model.posts %} {%- for post in model.posts %}
<article> <article>
<h1> <h1>
<a href="{{ post | relative_link }}" title="Permanent link to &quot;{{ post.title | escape }}&quot;"> <a href="{{ post | relative_link }}" title="Permanent link to &quot;{{ post.title | escape }}&quot;">
@@ -27,7 +26,7 @@
{%- if category_count > 0 -%} {%- if category_count > 0 -%}
Categorized under: Categorized under:
{% for cat in post.category_ids -%} {% for cat in post.category_ids -%}
{%- assign this_cat = categories | where: "id", cat | first -%} {%- assign this_cat = categories | where: "Id", cat | first -%}
{{ this_cat.name }}{% unless forloop.last %}, {% endunless %} {{ this_cat.name }}{% unless forloop.last %}, {% endunless %}
{%- assign cat_names = this_cat.name | concat: cat_names -%} {%- assign cat_names = this_cat.name | concat: cat_names -%}
{%- endfor -%} {%- endfor -%}
@@ -54,3 +53,8 @@
{%- endif -%} {%- endif -%}
</ul> </ul>
</nav> </nav>
{%- else %}
<article>
<p class="text-center mt-3">No posts found</p>
</article>
{%- endif %}

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>

View File

@@ -20,7 +20,7 @@
<h4 class="item-meta text-muted"> <h4 class="item-meta text-muted">
Categorized under Categorized under
{% for cat_id in post.category_ids -%} {% for cat_id in post.category_ids -%}
{% assign cat = categories | where: "id", cat_id | first %} {% assign cat = categories | where: "Id", cat_id | first %}
<span class="text-nowrap"> <span class="text-nowrap">
<a href="{{ cat | category_link }}" title="Categorized under &ldquo;{{ cat.name | escape }}&rdquo;"> <a href="{{ cat | category_link }}" title="Categorized under &ldquo;{{ cat.name | escape }}&rdquo;">
{{ cat.name }} {{ cat.name }}

View File

@@ -1,2 +1,2 @@
myWebLog Default Theme myWebLog Default Theme
2.0.0-alpha36 2.0.0-rc1