Compare commits
11 Commits
v2.0-beta0
...
v2.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 33698bd182 | |||
| 6b49793fbb | |||
| a8386d6c97 | |||
| b1ca48c2c5 | |||
| 3189681021 | |||
| ff9c08842b | |||
| e103738d39 | |||
| d854178255 | |||
| 0a32181e65 | |||
| 81fe03b8f3 | |||
| 4514c4864d |
19
build.fsx
19
build.fsx
@@ -34,9 +34,9 @@ let version =
|
||||
let zipTheme (name : string) (_ : TargetParameter) =
|
||||
let path = $"src/{name}-theme"
|
||||
!! $"{path}/**/*"
|
||||
|> Zip.filesAsSpecs path //$"src/{name}-theme"
|
||||
|> Zip.filesAsSpecs path
|
||||
|> 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
|
||||
let publishFor rid (_ : TargetParameter) =
|
||||
@@ -45,11 +45,14 @@ let publishFor rid (_ : TargetParameter) =
|
||||
/// Package published output for the given runtime ID
|
||||
let packageFor (rid : string) (_ : TargetParameter) =
|
||||
let path = $"{projectPath}/bin/Release/net6.0/{rid}/publish"
|
||||
let prodSettings = $"{path}/appsettings.Production.json"
|
||||
if File.exists prodSettings then File.delete prodSettings
|
||||
[ !! $"{path}/**/*"
|
||||
|> Zip.filesAsSpecs path
|
||||
|> Zip.moveToFolder "app"
|
||||
Seq.singleton ($"{releasePath}/admin.zip", "admin.zip")
|
||||
Seq.singleton ($"{releasePath}/default.zip", "default.zip")
|
||||
|> Seq.map (fun (orig, dest) ->
|
||||
orig, if dest.StartsWith "MyWebLog" then dest.Replace ("MyWebLog", "myWebLog") else dest)
|
||||
Seq.singleton ($"{releasePath}/admin-theme.zip", "admin-theme.zip")
|
||||
Seq.singleton ($"{releasePath}/default-theme.zip", "default-theme.zip")
|
||||
]
|
||||
|> Seq.concat
|
||||
|> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{rid}.zip"
|
||||
@@ -86,7 +89,7 @@ Target.create "RepackageLinux" (fun _ ->
|
||||
Shell.mkdir workDir
|
||||
Zip.unzip workDir zipArchive
|
||||
Shell.cd workDir
|
||||
sh "chmod" [ "+x"; "app/MyWebLog" ]
|
||||
sh "chmod" [ "+x"; "./myWebLog" ]
|
||||
sh "tar" [ "cfj"; $"../myWebLog-{version}.linux-x64.tar.bz2"; "." ]
|
||||
Shell.cd "../.."
|
||||
Shell.rm zipArchive
|
||||
@@ -96,8 +99,8 @@ Target.create "RepackageLinux" (fun _ ->
|
||||
Target.create "All" ignore
|
||||
|
||||
Target.create "RemoveThemeArchives" (fun _ ->
|
||||
Shell.rm $"{releasePath}/admin.zip"
|
||||
Shell.rm $"{releasePath}/default.zip"
|
||||
Shell.rm $"{releasePath}/admin-theme.zip"
|
||||
Shell.rm $"{releasePath}/default-theme.zip"
|
||||
)
|
||||
|
||||
Target.create "CI" ignore
|
||||
|
||||
@@ -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
10
src/Directory.Build.props
Normal 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>
|
||||
@@ -5,6 +5,16 @@ open System.Threading.Tasks
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// The result of a category deletion attempt
|
||||
type CategoryDeleteResult =
|
||||
/// The category was deleted successfully
|
||||
| CategoryDeleted
|
||||
/// The category was deleted successfully, and its children were reassigned to its parent
|
||||
| ReassignedChildCategories
|
||||
/// The category was not found, so no effort was made to delete it
|
||||
| CategoryNotFound
|
||||
|
||||
|
||||
/// Data functions to support manipulating categories
|
||||
type ICategoryData =
|
||||
|
||||
@@ -18,7 +28,7 @@ type ICategoryData =
|
||||
abstract member CountTopLevel : WebLogId -> Task<int>
|
||||
|
||||
/// Delete a category (also removes it from posts)
|
||||
abstract member Delete : CategoryId -> WebLogId -> Task<bool>
|
||||
abstract member Delete : CategoryId -> WebLogId -> Task<CategoryDeleteResult>
|
||||
|
||||
/// Find all categories for a web log, sorted alphabetically and grouped by hierarchy
|
||||
abstract member FindAllForView : WebLogId -> Task<DisplayCategory[]>
|
||||
@@ -167,9 +177,15 @@ type ITagMapData =
|
||||
/// Functions to manipulate themes
|
||||
type IThemeData =
|
||||
|
||||
/// Retrieve all themes (except "admin")
|
||||
/// Retrieve all themes (except "admin") (excluding the text of templates)
|
||||
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
|
||||
abstract member FindById : ThemeId -> Task<Theme option>
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -96,6 +96,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
||||
let keyPrefix = $"^{ThemeId.toString themeId}/"
|
||||
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
|
||||
let ensureIndexes table fields = backgroundTask {
|
||||
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
|
||||
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
|
||||
member _.Conn = conn
|
||||
|
||||
@@ -262,7 +274,21 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
||||
|
||||
member this.Delete catId webLogId = backgroundTask {
|
||||
match! this.FindById catId webLogId with
|
||||
| Some _ ->
|
||||
| Some cat ->
|
||||
// Reassign any children to the category's parent category
|
||||
let! children = rethink<int> {
|
||||
withTable Table.Category
|
||||
filter (nameof Category.empty.ParentId) catId
|
||||
count
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
if children > 0 then
|
||||
do! rethink {
|
||||
withTable Table.Category
|
||||
filter (nameof Category.empty.ParentId) catId
|
||||
update [ nameof Category.empty.ParentId, cat.ParentId :> obj ]
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
// Delete the category off all posts where it is assigned
|
||||
do! rethink {
|
||||
withTable Table.Post
|
||||
@@ -279,8 +305,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
||||
delete
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
return true
|
||||
| None -> return false
|
||||
return if children = 0 then CategoryDeleted else ReassignedChildCategories
|
||||
| None -> return CategoryNotFound
|
||||
}
|
||||
|
||||
member _.Restore cats = backgroundTask {
|
||||
@@ -711,11 +737,21 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
||||
member _.All () = rethink<Theme list> {
|
||||
withTable Table.Theme
|
||||
filter (fun row -> row[nameof Theme.empty.Id].Ne "admin" :> obj)
|
||||
without [ nameof Theme.empty.Templates ]
|
||||
merge withoutTemplateText
|
||||
orderBy (nameof Theme.empty.Id)
|
||||
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> {
|
||||
withTable Table.Theme
|
||||
get themeId
|
||||
@@ -725,12 +761,24 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
||||
member _.FindByIdWithoutText themeId = rethink<Theme> {
|
||||
withTable Table.Theme
|
||||
get themeId
|
||||
merge (fun row ->
|
||||
{| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.empty.Text |]
|
||||
|})
|
||||
merge withoutTemplateText
|
||||
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 {
|
||||
withTable Table.Theme
|
||||
get theme.Id
|
||||
@@ -748,12 +796,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
||||
result; withRetryDefault conn
|
||||
}
|
||||
|
||||
member _.DeleteByTheme themeId = rethink {
|
||||
withTable Table.ThemeAsset
|
||||
filter (matchAssetByThemeId themeId)
|
||||
delete
|
||||
write; withRetryDefault; ignoreResult conn
|
||||
}
|
||||
member _.DeleteByTheme themeId = deleteAssetsByTheme themeId
|
||||
|
||||
member _.FindById assetId = rethink<ThemeAsset> {
|
||||
withTable Table.ThemeAsset
|
||||
|
||||
@@ -242,9 +242,9 @@ module Map =
|
||||
}
|
||||
|
||||
/// Create a theme template from the current row in the given data reader
|
||||
let toThemeTemplate rdr : ThemeTemplate =
|
||||
{ Name = getString "name" rdr
|
||||
Text = getString "template" rdr
|
||||
let toThemeTemplate includeText rdr : ThemeTemplate =
|
||||
{ Name = getString "name" rdr
|
||||
Text = if includeText then getString "template" rdr else ""
|
||||
}
|
||||
|
||||
/// Create an uploaded file from the current row in the given data reader
|
||||
|
||||
@@ -122,13 +122,23 @@ type SQLiteCategoryData (conn : SqliteConnection) =
|
||||
/// Delete a category
|
||||
let delete catId webLogId = backgroundTask {
|
||||
match! findById catId webLogId with
|
||||
| Some _ ->
|
||||
| Some cat ->
|
||||
use cmd = conn.CreateCommand ()
|
||||
// Reassign any children to the category's parent category
|
||||
cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE parent_id = @parentId"
|
||||
cmd.Parameters.AddWithValue ("@parentId", CategoryId.toString catId) |> ignore
|
||||
let! children = count cmd
|
||||
if children > 0 then
|
||||
cmd.CommandText <- "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId"
|
||||
cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map CategoryId.toString))
|
||||
|> ignore
|
||||
do! write cmd
|
||||
// Delete the category off all posts where it is assigned
|
||||
cmd.CommandText <- """
|
||||
DELETE FROM post_category
|
||||
WHERE category_id = @id
|
||||
AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId)"""
|
||||
cmd.Parameters.Clear ()
|
||||
let catIdParameter = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId)
|
||||
cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
|
||||
do! write cmd
|
||||
@@ -137,8 +147,8 @@ type SQLiteCategoryData (conn : SqliteConnection) =
|
||||
cmd.Parameters.Clear ()
|
||||
cmd.Parameters.Add catIdParameter |> ignore
|
||||
do! write cmd
|
||||
return true
|
||||
| None -> return false
|
||||
return if children = 0 then CategoryDeleted else ReassignedChildCategories
|
||||
| None -> return CategoryNotFound
|
||||
}
|
||||
|
||||
/// Restore categories from a backup
|
||||
|
||||
@@ -328,7 +328,7 @@ type SQLitePageData (conn : SqliteConnection) =
|
||||
is_in_page_list = @isInPageList,
|
||||
template = @template,
|
||||
page_text = @text
|
||||
WHERE id = @pageId
|
||||
WHERE id = @id
|
||||
AND web_log_id = @webLogId"""
|
||||
addPageParameters cmd page
|
||||
do! write cmd
|
||||
|
||||
@@ -8,12 +8,31 @@ open MyWebLog.Data
|
||||
/// SQLite myWebLog theme data implementation
|
||||
type SQLiteThemeData (conn : SqliteConnection) =
|
||||
|
||||
/// Retrieve all themes (except 'admin'; excludes templates)
|
||||
/// Retrieve all themes (except 'admin'; excludes template text)
|
||||
let all () = backgroundTask {
|
||||
use cmd = conn.CreateCommand ()
|
||||
cmd.CommandText <- "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id"
|
||||
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
|
||||
@@ -28,7 +47,7 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
||||
templateCmd.CommandText <- "SELECT * FROM theme_template WHERE theme_id = @id"
|
||||
templateCmd.Parameters.Add cmd.Parameters["@id"] |> ignore
|
||||
use! templateRdr = templateCmd.ExecuteReaderAsync ()
|
||||
return Some { theme with Templates = toList Map.toThemeTemplate templateRdr }
|
||||
return Some { theme with Templates = toList (Map.toThemeTemplate true) templateRdr }
|
||||
else
|
||||
return None
|
||||
}
|
||||
@@ -43,6 +62,21 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
||||
| 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
|
||||
let save (theme : Theme) = backgroundTask {
|
||||
use cmd = conn.CreateCommand ()
|
||||
@@ -102,6 +136,8 @@ type SQLiteThemeData (conn : SqliteConnection) =
|
||||
|
||||
interface IThemeData with
|
||||
member _.All () = all ()
|
||||
member _.Delete themeId = delete themeId
|
||||
member _.Exists themeId = exists themeId
|
||||
member _.FindById themeId = findById themeId
|
||||
member _.FindByIdWithoutText themeId = findByIdWithoutText themeId
|
||||
member _.Save theme = save theme
|
||||
|
||||
@@ -320,6 +320,7 @@ type SQLiteWebLogData (conn : SqliteConnection) =
|
||||
copyright = @copyright
|
||||
WHERE id = @id"""
|
||||
addWebLogRssParameters cmd webLog
|
||||
cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore
|
||||
do! write cmd
|
||||
do! updateCustomFeeds webLog
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="SupportTypes.fs" />
|
||||
<Compile Include="DataTypes.fs" />
|
||||
|
||||
@@ -12,6 +12,17 @@ module private Helpers =
|
||||
match (defaultArg (Option.ofObj it) "").Trim () with "" -> None | trimmed -> Some trimmed
|
||||
|
||||
|
||||
/// Helper functions that are needed outside this file
|
||||
[<AutoOpen>]
|
||||
module PublicHelpers =
|
||||
|
||||
/// If the web log is not being served from the domain root, add the path information to relative URLs in page and
|
||||
/// post text
|
||||
let addBaseToRelativeUrls extra (text : string) =
|
||||
if extra = "" then text
|
||||
else text.Replace("href=\"/", $"href=\"{extra}/").Replace ("src=\"/", $"src=\"{extra}/")
|
||||
|
||||
|
||||
/// The model used to display the admin dashboard
|
||||
[<NoComparison; NoEquality>]
|
||||
type DashboardModel =
|
||||
@@ -147,7 +158,7 @@ type DisplayPage =
|
||||
UpdatedOn = page.UpdatedOn
|
||||
IsInPageList = page.IsInPageList
|
||||
IsDefault = pageId = webLog.DefaultPage
|
||||
Text = if extra = "" then page.Text else page.Text.Replace ("href=\"/", $"href=\"{extra}/")
|
||||
Text = addBaseToRelativeUrls extra page.Text
|
||||
Metadata = page.Metadata
|
||||
}
|
||||
|
||||
@@ -176,6 +187,40 @@ with
|
||||
|
||||
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
|
||||
[<NoComparison; NoEquality>]
|
||||
type DisplayUpload =
|
||||
@@ -1027,7 +1072,7 @@ type PostListItem =
|
||||
Permalink = Permalink.toString post.Permalink
|
||||
PublishedOn = post.PublishedOn |> Option.map inTZ |> Option.toNullable
|
||||
UpdatedOn = inTZ post.UpdatedOn
|
||||
Text = if extra = "" then post.Text else post.Text.Replace ("href=\"/", $"href=\"{extra}/")
|
||||
Text = addBaseToRelativeUrls extra post.Text
|
||||
CategoryIds = post.CategoryIds |> List.map CategoryId.toString
|
||||
Tags = post.Tags
|
||||
Episode = post.Episode
|
||||
@@ -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
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type UserMessage =
|
||||
|
||||
@@ -80,11 +80,19 @@ module WebLogCache =
|
||||
let set webLog =
|
||||
_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
|
||||
let fill (data : IData) = backgroundTask {
|
||||
let! webLogs = data.WebLog.All ()
|
||||
_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
|
||||
@@ -93,22 +101,30 @@ module PageListCache =
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// 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 exists (ctx : HttpContext) = _cache.ContainsKey ctx.WebLog.UrlBase
|
||||
|
||||
/// 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] <-
|
||||
let private fillPages (webLog : WebLog) pages =
|
||||
_cache[webLog.Id] <-
|
||||
pages
|
||||
|> List.map (fun pg -> DisplayPage.fromPage webLog { pg with Text = "" })
|
||||
|> 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
|
||||
|
||||
/// The cache itself
|
||||
let private _cache = ConcurrentDictionary<string, DisplayCategory[]> ()
|
||||
let private _cache = ConcurrentDictionary<WebLogId, DisplayCategory[]> ()
|
||||
|
||||
/// 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
|
||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.UrlBase]
|
||||
let get (ctx : HttpContext) = _cache[ctx.WebLog.Id]
|
||||
|
||||
/// Update the cache with fresh data
|
||||
let update (ctx : HttpContext) = backgroundTask {
|
||||
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,29 +169,54 @@ module TemplateCache =
|
||||
let private hasInclude = Regex ("""{% include_template \"(.*)\" %}""", RegexOptions.None, TimeSpan.FromSeconds 2)
|
||||
|
||||
/// Get a template for the given theme and template name
|
||||
let get (themeId : string) (templateName : string) (data : IData) = backgroundTask {
|
||||
let templatePath = $"{themeId}/{templateName}"
|
||||
let get (themeId : ThemeId) (templateName : string) (data : IData) = backgroundTask {
|
||||
let templatePath = $"{ThemeId.toString themeId}/{templateName}"
|
||||
match _cache.ContainsKey templatePath with
|
||||
| true -> ()
|
||||
| true -> return Ok _cache[templatePath]
|
||||
| false ->
|
||||
match! data.Theme.FindById (ThemeId themeId) with
|
||||
match! data.Theme.FindById themeId with
|
||||
| Some theme ->
|
||||
let mutable text = (theme.Templates |> List.find (fun t -> t.Name = templateName)).Text
|
||||
while hasInclude.IsMatch text do
|
||||
let child = hasInclude.Match text
|
||||
let childText = (theme.Templates |> List.find (fun t -> t.Name = child.Groups[1].Value)).Text
|
||||
text <- text.Replace (child.Value, childText)
|
||||
_cache[templatePath] <- Template.Parse (text, SyntaxCompatibility.DotLiquid22)
|
||||
| None -> ()
|
||||
return _cache[templatePath]
|
||||
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
|
||||
let child = hasInclude.Match 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)
|
||||
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)
|
||||
return Ok _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
|
||||
let invalidateTheme (themeId : string) =
|
||||
let invalidateTheme (themeId : ThemeId) =
|
||||
let keyPrefix = ThemeId.toString themeId
|
||||
_cache.Keys
|
||||
|> Seq.filter (fun key -> key.StartsWith themeId)
|
||||
|> Seq.filter (fun key -> key.StartsWith keyPrefix)
|
||||
|> List.ofSeq
|
||||
|> 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
|
||||
|
||||
@@ -200,7 +200,7 @@ type UserLinksTag () =
|
||||
|> Seq.iter result.WriteLine
|
||||
|
||||
/// 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 () =
|
||||
static member Value (_ : Context, items : MetaItem list, name : string) =
|
||||
match items |> List.tryFind (fun it -> it.Name = name) with
|
||||
@@ -227,12 +227,12 @@ let register () =
|
||||
typeof<CustomFeed>; typeof<Episode>; typeof<Episode option>; typeof<MetaItem>; typeof<Page>
|
||||
typeof<RssOptions>; typeof<TagMap>; typeof<UploadDestination>; typeof<WebLog>
|
||||
// View models
|
||||
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
|
||||
typeof<DisplayRevision>; typeof<DisplayUpload>; typeof<DisplayUser>; typeof<EditCategoryModel>
|
||||
typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>; typeof<EditPostModel>
|
||||
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
|
||||
typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>; typeof<PostListItem>
|
||||
typeof<SettingsModel>; typeof<UserMessage>
|
||||
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
|
||||
typeof<DisplayRevision>; typeof<DisplayTheme>; typeof<DisplayUpload>; typeof<DisplayUser>
|
||||
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditMyInfoModel>; typeof<EditPageModel>
|
||||
typeof<EditPostModel>; typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>
|
||||
typeof<LogOnModel>; typeof<ManagePermalinksModel>; typeof<ManageRevisionsModel>; typeof<PostDisplay>
|
||||
typeof<PostListItem>; typeof<SettingsModel>; typeof<UserMessage>
|
||||
// Framework types
|
||||
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
|
||||
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>
|
||||
|
||||
@@ -6,338 +6,513 @@ open Giraffe
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
// GET /admin
|
||||
let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||
let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id
|
||||
let data = ctx.Data
|
||||
let posts = getCount (data.Post.CountByStatus Published)
|
||||
let drafts = getCount (data.Post.CountByStatus Draft)
|
||||
let pages = getCount data.Page.CountAll
|
||||
let listed = getCount data.Page.CountListed
|
||||
let cats = getCount data.Category.CountAll
|
||||
let topCats = getCount data.Category.CountTopLevel
|
||||
let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats)
|
||||
return!
|
||||
hashForPage "Dashboard"
|
||||
|> addToHash ViewContext.Model {
|
||||
Posts = posts.Result
|
||||
Drafts = drafts.Result
|
||||
Pages = pages.Result
|
||||
ListedPages = listed.Result
|
||||
Categories = cats.Result
|
||||
TopLevelCategories = topCats.Result
|
||||
}
|
||||
|> adminView "dashboard" next ctx
|
||||
}
|
||||
/// ~~ DASHBOARDS ~~
|
||||
module Dashboard =
|
||||
|
||||
// GET /admin/dashboard
|
||||
let user : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||
let getCount (f : WebLogId -> Task<int>) = f ctx.WebLog.Id
|
||||
let data = ctx.Data
|
||||
let posts = getCount (data.Post.CountByStatus Published)
|
||||
let drafts = getCount (data.Post.CountByStatus Draft)
|
||||
let pages = getCount data.Page.CountAll
|
||||
let listed = getCount data.Page.CountListed
|
||||
let cats = getCount data.Category.CountAll
|
||||
let topCats = getCount data.Category.CountTopLevel
|
||||
let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats)
|
||||
return!
|
||||
hashForPage "Dashboard"
|
||||
|> addToHash ViewContext.Model {
|
||||
Posts = posts.Result
|
||||
Drafts = drafts.Result
|
||||
Pages = pages.Result
|
||||
ListedPages = listed.Result
|
||||
Categories = cats.Result
|
||||
TopLevelCategories = topCats.Result
|
||||
}
|
||||
|> adminView "dashboard" next ctx
|
||||
}
|
||||
|
||||
// -- 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
|
||||
}
|
||||
|
||||
// GET /admin/categories
|
||||
let listCategories : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data
|
||||
let! hash =
|
||||
/// 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
|
||||
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
match! TemplateCache.get adminTheme "category-list-body" ctx.Data with
|
||||
| Ok catListTemplate ->
|
||||
let! hash =
|
||||
hashForPage "Categories"
|
||||
|> withAntiCsrf ctx
|
||||
|> addViewContext ctx
|
||||
return!
|
||||
addToHash "category_list" (catListTemplate.Render hash) hash
|
||||
|> adminView "category-list" next ctx
|
||||
| Error message -> return! Error.server message next ctx
|
||||
}
|
||||
|
||||
// GET /admin/categories/bare
|
||||
let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
|
||||
hashForPage "Categories"
|
||||
|> withAntiCsrf ctx
|
||||
|> addViewContext ctx
|
||||
return!
|
||||
addToHash "category_list" (catListTemplate.Render hash) hash
|
||||
|> adminView "category-list" next ctx
|
||||
}
|
||||
|
||||
// GET /admin/categories/bare
|
||||
let listCategoriesBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
|
||||
hashForPage "Categories"
|
||||
|> withAntiCsrf ctx
|
||||
|> adminBareView "category-list-body" next ctx
|
||||
|> adminBareView "category-list-body" next ctx
|
||||
|
||||
|
||||
// GET /admin/category/{id}/edit
|
||||
let editCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let! result = task {
|
||||
match catId with
|
||||
| "new" -> return Some ("Add a New Category", { Category.empty with Id = CategoryId "new" })
|
||||
| _ ->
|
||||
match! ctx.Data.Category.FindById (CategoryId catId) ctx.WebLog.Id with
|
||||
| Some cat -> return Some ("Edit Category", cat)
|
||||
| None -> return None
|
||||
// GET /admin/category/{id}/edit
|
||||
let edit catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let! result = task {
|
||||
match catId with
|
||||
| "new" -> return Some ("Add a New Category", { Category.empty with Id = CategoryId "new" })
|
||||
| _ ->
|
||||
match! ctx.Data.Category.FindById (CategoryId catId) ctx.WebLog.Id with
|
||||
| Some cat -> return Some ("Edit Category", cat)
|
||||
| None -> return None
|
||||
}
|
||||
match result with
|
||||
| Some (title, cat) ->
|
||||
return!
|
||||
hashForPage title
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash ViewContext.Model (EditCategoryModel.fromCategory cat)
|
||||
|> adminBareView "category-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
match result with
|
||||
| Some (title, cat) ->
|
||||
return!
|
||||
hashForPage title
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash ViewContext.Model (EditCategoryModel.fromCategory cat)
|
||||
|> adminBareView "category-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/category/save
|
||||
let saveCategory : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditCategoryModel> ()
|
||||
let category =
|
||||
if model.IsNew then someTask { Category.empty with Id = CategoryId.create (); WebLogId = ctx.WebLog.Id }
|
||||
else data.Category.FindById (CategoryId model.CategoryId) ctx.WebLog.Id
|
||||
match! category with
|
||||
| Some cat ->
|
||||
let updatedCat =
|
||||
{ cat with
|
||||
Name = model.Name
|
||||
Slug = model.Slug
|
||||
Description = if model.Description = "" then None else Some model.Description
|
||||
ParentId = if model.ParentId = "" then None else Some (CategoryId model.ParentId)
|
||||
}
|
||||
do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with Message = "Category saved successfully" }
|
||||
return! listCategoriesBare next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/category/{id}/delete
|
||||
let deleteCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
match! ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id with
|
||||
| true ->
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with Message = "Category deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with Message = "Category not found; cannot delete" }
|
||||
return! listCategoriesBare next ctx
|
||||
}
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
// -- TAG MAPPINGS --
|
||||
|
||||
/// 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
|
||||
return!
|
||||
hashForPage "Tag Mappings"
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash "mappings" mappings
|
||||
|> addToHash "mapping_ids" (mappings |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
|
||||
|> addViewContext ctx
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mappings
|
||||
let tagMappings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let! hash = tagMappingHash ctx
|
||||
let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body" ctx.Data
|
||||
return!
|
||||
addToHash "tag_mapping_list" (listTemplate.Render hash) hash
|
||||
|> 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
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mapping/{id}/edit
|
||||
let editMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let isNew = tagMapId = "new"
|
||||
let tagMap =
|
||||
if isNew then someTask { TagMap.empty with Id = TagMapId "new" }
|
||||
else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
return!
|
||||
hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag")
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm)
|
||||
|> adminBareView "tag-mapping-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/save
|
||||
let saveMapping : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditTagMapModel> ()
|
||||
let tagMap =
|
||||
if model.IsNew then someTask { TagMap.empty with Id = TagMapId.create (); WebLogId = ctx.WebLog.Id }
|
||||
else data.TagMap.FindById (TagMapId model.Id) ctx.WebLog.Id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower (); UrlValue = model.UrlValue.ToLower () }
|
||||
do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" }
|
||||
return! tagMappingsBare next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/{id}/delete
|
||||
let deleteMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
|
||||
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" }
|
||||
return! tagMappingsBare next ctx
|
||||
}
|
||||
|
||||
// -- THEMES --
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open System.Text.RegularExpressions
|
||||
open MyWebLog.Data
|
||||
|
||||
// GET /admin/theme/update
|
||||
let themeUpdatePage : HttpHandler = requireAccess Administrator >=> fun next ctx ->
|
||||
hashForPage "Upload Theme"
|
||||
|> withAntiCsrf ctx
|
||||
|> adminView "upload-theme" next ctx
|
||||
|
||||
/// Update the name and version for a theme based on the version.txt file, if present
|
||||
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
|
||||
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
|
||||
| Some versionItem ->
|
||||
use versionFile = new StreamReader(versionItem.Open ())
|
||||
let! versionText = versionFile.ReadToEndAsync ()
|
||||
let parts = versionText.Trim().Replace("\r", "").Split "\n"
|
||||
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.Id
|
||||
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
|
||||
return { theme with Name = displayName; Version = version }
|
||||
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
|
||||
}
|
||||
|
||||
/// 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
|
||||
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let tasks =
|
||||
zip.Entries
|
||||
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|
||||
|> Seq.map (fun templateItem -> backgroundTask {
|
||||
use templateFile = new StreamReader (templateItem.Open ())
|
||||
let! template = templateFile.ReadToEndAsync ()
|
||||
return { Name = templateItem.Name.Replace (".liquid", ""); Text = template }
|
||||
})
|
||||
let! templates = Task.WhenAll tasks
|
||||
return
|
||||
templates
|
||||
|> Array.fold (fun t template ->
|
||||
{ t with Templates = template :: (t.Templates |> List.filter (fun it -> it.Name <> template.Name)) })
|
||||
theme
|
||||
}
|
||||
|
||||
/// Update theme assets from the ZIP archive
|
||||
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
|
||||
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
|
||||
let assetName = asset.FullName.Replace ("wwwroot/", "")
|
||||
if assetName <> "" && not (assetName.EndsWith "/") then
|
||||
use stream = new MemoryStream ()
|
||||
do! asset.Open().CopyToAsync stream
|
||||
do! data.ThemeAsset.Save
|
||||
{ Id = ThemeAssetId (themeId, assetName)
|
||||
UpdatedOn = asset.LastWriteTime.DateTime
|
||||
Data = stream.ToArray ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the theme name from the file name given
|
||||
let getThemeName (fileName : string) =
|
||||
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
|
||||
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok themeName else Error $"Theme name {fileName} is invalid"
|
||||
|
||||
/// Load a theme from the given stream, which should contain a ZIP archive
|
||||
let loadThemeFromZip themeName file clean (data : IData) = backgroundTask {
|
||||
use zip = new ZipArchive (file, ZipArchiveMode.Read)
|
||||
let themeId = ThemeId themeName
|
||||
let! theme = backgroundTask {
|
||||
match! data.Theme.FindById themeId with
|
||||
| Some t -> return t
|
||||
| None -> return { Theme.empty with Id = themeId }
|
||||
// POST /admin/category/save
|
||||
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditCategoryModel> ()
|
||||
let category =
|
||||
if model.IsNew then someTask { Category.empty with Id = CategoryId.create (); WebLogId = ctx.WebLog.Id }
|
||||
else data.Category.FindById (CategoryId model.CategoryId) ctx.WebLog.Id
|
||||
match! category with
|
||||
| Some cat ->
|
||||
let updatedCat =
|
||||
{ cat with
|
||||
Name = model.Name
|
||||
Slug = model.Slug
|
||||
Description = if model.Description = "" then None else Some model.Description
|
||||
ParentId = if model.ParentId = "" then None else Some (CategoryId model.ParentId)
|
||||
}
|
||||
do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat
|
||||
do! CategoryCache.update ctx
|
||||
do! addMessage ctx { UserMessage.success with Message = "Category saved successfully" }
|
||||
return! bare next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
let! theme = updateNameAndVersion theme zip
|
||||
let! theme = checkForCleanLoad theme clean data
|
||||
let! theme = updateTemplates theme zip
|
||||
do! data.Theme.Save theme
|
||||
do! updateAssets themeId zip data
|
||||
}
|
||||
|
||||
// POST /admin/theme/update
|
||||
let updateTheme : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
||||
let themeFile = Seq.head ctx.Request.Form.Files
|
||||
match getThemeName themeFile.FileName with
|
||||
| Ok themeName when themeName <> "admin" ->
|
||||
let data = ctx.Data
|
||||
use stream = new MemoryStream ()
|
||||
do! themeFile.CopyToAsync stream
|
||||
do! loadThemeFromZip themeName stream true data
|
||||
do! ThemeAssetCache.refreshTheme (ThemeId themeName) data
|
||||
TemplateCache.invalidateTheme themeName
|
||||
do! addMessage ctx { UserMessage.success with Message = "Theme updated successfully" }
|
||||
return! redirectToGet "admin/dashboard" next ctx
|
||||
| Ok _ ->
|
||||
do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" }
|
||||
return! redirectToGet "admin/theme/update" next ctx
|
||||
| Error message ->
|
||||
do! addMessage ctx { UserMessage.error with Message = message }
|
||||
return! redirectToGet "admin/theme/update" next ctx
|
||||
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
|
||||
}
|
||||
// POST /admin/category/{id}/delete
|
||||
let delete catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let! result = ctx.Data.Category.Delete (CategoryId catId) ctx.WebLog.Id
|
||||
match result with
|
||||
| CategoryDeleted
|
||||
| ReassignedChildCategories ->
|
||||
do! CategoryCache.update ctx
|
||||
let detail =
|
||||
match result with
|
||||
| ReassignedChildCategories ->
|
||||
Some "<em>(Its child categories were reassigned to its parent category)</em>"
|
||||
| _ -> None
|
||||
do! addMessage ctx { UserMessage.success with Message = "Category deleted successfully"; Detail = detail }
|
||||
| CategoryNotFound ->
|
||||
do! addMessage ctx { UserMessage.error with Message = "Category not found; cannot delete" }
|
||||
return! bare next ctx
|
||||
}
|
||||
|
||||
// -- WEB LOG SETTINGS --
|
||||
|
||||
open System.Collections.Generic
|
||||
|
||||
// GET /admin/settings
|
||||
let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! allPages = data.Page.All ctx.WebLog.Id
|
||||
let! themes = data.Theme.All ()
|
||||
return!
|
||||
hashForPage "Web Log Settings"
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
|
||||
|> addToHash "pages" (
|
||||
seq {
|
||||
KeyValuePair.Create ("posts", "- First Page of Posts -")
|
||||
yield! allPages
|
||||
|> List.sortBy (fun p -> p.Title.ToLower ())
|
||||
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title))
|
||||
}
|
||||
|> Array.ofSeq)
|
||||
|> addToHash "themes" (
|
||||
themes
|
||||
|> Seq.ofList
|
||||
|> Seq.map (fun it -> KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|
||||
|> Array.ofSeq)
|
||||
|> addToHash "upload_values" [|
|
||||
KeyValuePair.Create (UploadDestination.toString Database, "Database")
|
||||
KeyValuePair.Create (UploadDestination.toString Disk, "Disk")
|
||||
|]
|
||||
|> adminView "settings" next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings
|
||||
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<SettingsModel> ()
|
||||
match! data.WebLog.FindById ctx.WebLog.Id with
|
||||
| Some webLog ->
|
||||
let oldSlug = webLog.Slug
|
||||
let webLog = model.update webLog
|
||||
do! data.WebLog.UpdateSettings webLog
|
||||
|
||||
// Update cache
|
||||
WebLogCache.set webLog
|
||||
|
||||
if oldSlug <> webLog.Slug then
|
||||
// Rename disk directory if it exists
|
||||
let uploadRoot = Path.Combine ("wwwroot", "upload")
|
||||
let oldDir = Path.Combine (uploadRoot, oldSlug)
|
||||
if Directory.Exists oldDir then Directory.Move (oldDir, Path.Combine (uploadRoot, webLog.Slug))
|
||||
/// ~~ TAG MAPPINGS ~~
|
||||
module TagMapping =
|
||||
|
||||
do! addMessage ctx { UserMessage.success with Message = "Web log settings saved successfully" }
|
||||
return! redirectToGet "admin/settings" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// Add tag mappings to the given hash
|
||||
let withTagMappings (ctx : HttpContext) hash = task {
|
||||
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
|
||||
return
|
||||
addToHash "mappings" mappings hash
|
||||
|> addToHash "mapping_ids" (
|
||||
mappings
|
||||
|> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mappings
|
||||
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let! hash =
|
||||
hashForPage ""
|
||||
|> withAntiCsrf ctx
|
||||
|> withTagMappings ctx
|
||||
return! adminBareView "tag-mapping-list-body" next ctx hash
|
||||
}
|
||||
|
||||
// GET /admin/settings/tag-mapping/{id}/edit
|
||||
let edit tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let isNew = tagMapId = "new"
|
||||
let tagMap =
|
||||
if isNew then someTask { TagMap.empty with Id = TagMapId "new" }
|
||||
else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
return!
|
||||
hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag")
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm)
|
||||
|> adminBareView "tag-mapping-edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/save
|
||||
let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<EditTagMapModel> ()
|
||||
let tagMap =
|
||||
if model.IsNew then someTask { TagMap.empty with Id = TagMapId.create (); WebLogId = ctx.WebLog.Id }
|
||||
else data.TagMap.FindById (TagMapId model.Id) ctx.WebLog.Id
|
||||
match! tagMap with
|
||||
| Some tm ->
|
||||
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower (); UrlValue = model.UrlValue.ToLower () }
|
||||
do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" }
|
||||
return! all next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings/tag-mapping/{id}/delete
|
||||
let delete tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
|
||||
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" }
|
||||
| false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" }
|
||||
return! all next ctx
|
||||
}
|
||||
|
||||
|
||||
/// ~~ THEMES ~~
|
||||
module Theme =
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open System.Text.RegularExpressions
|
||||
open MyWebLog.Data
|
||||
|
||||
// GET /admin/theme/list
|
||||
let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||
let! themes = ctx.Data.Theme.All ()
|
||||
return!
|
||||
hashForPage "Themes"
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash "themes" (themes |> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse) |> Array.ofList)
|
||||
|> adminBareView "theme-list-body" next ctx
|
||||
}
|
||||
|
||||
// GET /admin/theme/new
|
||||
let add : HttpHandler = requireAccess Administrator >=> fun next ctx ->
|
||||
hashForPage "Upload a Theme File"
|
||||
|> withAntiCsrf ctx
|
||||
|> adminBareView "theme-upload" next ctx
|
||||
|
||||
/// Update the name and version for a theme based on the version.txt file, if present
|
||||
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let now () = DateTime.UtcNow.ToString "yyyyMMdd.HHmm"
|
||||
match zip.Entries |> Seq.filter (fun it -> it.FullName = "version.txt") |> Seq.tryHead with
|
||||
| Some versionItem ->
|
||||
use versionFile = new StreamReader(versionItem.Open ())
|
||||
let! versionText = versionFile.ReadToEndAsync ()
|
||||
let parts = versionText.Trim().Replace("\r", "").Split "\n"
|
||||
let displayName = if parts[0] > "" then parts[0] else ThemeId.toString theme.Id
|
||||
let version = if parts.Length > 1 && parts[1] > "" then parts[1] else now ()
|
||||
return { theme with Name = displayName; Version = version }
|
||||
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
|
||||
}
|
||||
|
||||
/// Update the theme with all templates from the ZIP archive
|
||||
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
|
||||
let tasks =
|
||||
zip.Entries
|
||||
|> Seq.filter (fun it -> it.Name.EndsWith ".liquid")
|
||||
|> Seq.map (fun templateItem -> backgroundTask {
|
||||
use templateFile = new StreamReader (templateItem.Open ())
|
||||
let! template = templateFile.ReadToEndAsync ()
|
||||
return { Name = templateItem.Name.Replace (".liquid", ""); Text = template }
|
||||
})
|
||||
let! templates = Task.WhenAll tasks
|
||||
return
|
||||
templates
|
||||
|> Array.fold (fun t template ->
|
||||
{ t with Templates = template :: (t.Templates |> List.filter (fun it -> it.Name <> template.Name)) })
|
||||
theme
|
||||
}
|
||||
|
||||
/// Update theme assets from the ZIP archive
|
||||
let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundTask {
|
||||
for asset in zip.Entries |> Seq.filter (fun it -> it.FullName.StartsWith "wwwroot") do
|
||||
let assetName = asset.FullName.Replace ("wwwroot/", "")
|
||||
if assetName <> "" && not (assetName.EndsWith "/") then
|
||||
use stream = new MemoryStream ()
|
||||
do! asset.Open().CopyToAsync stream
|
||||
do! data.ThemeAsset.Save
|
||||
{ Id = ThemeAssetId (themeId, assetName)
|
||||
UpdatedOn = asset.LastWriteTime.DateTime
|
||||
Data = stream.ToArray ()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the theme ID from the file name given
|
||||
let deriveIdFromFileName (fileName : string) =
|
||||
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
|
||||
if themeName.EndsWith "-theme" then
|
||||
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then
|
||||
Ok (ThemeId (themeName.Substring (0, themeName.Length - 6)))
|
||||
else Error $"Theme ID {fileName} is invalid"
|
||||
else Error "Theme .zip file name must end in \"-theme.zip\""
|
||||
|
||||
/// Load a theme from the given stream, which should contain a ZIP archive
|
||||
let loadFromZip themeId file (data : IData) = backgroundTask {
|
||||
let! isNew, theme = backgroundTask {
|
||||
match! data.Theme.FindById themeId with
|
||||
| Some t -> return false, t
|
||||
| None -> return true, { Theme.empty with Id = themeId }
|
||||
}
|
||||
use zip = new ZipArchive (file, ZipArchiveMode.Read)
|
||||
let! theme = updateNameAndVersion theme zip
|
||||
if not isNew then do! data.ThemeAsset.DeleteByTheme theme.Id
|
||||
let! theme = updateTemplates { theme with Templates = [] } zip
|
||||
do! data.Theme.Save theme
|
||||
do! updateAssets themeId zip data
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
// POST /admin/theme/new
|
||||
let save : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
||||
let themeFile = Seq.head ctx.Request.Form.Files
|
||||
match deriveIdFromFileName themeFile.FileName with
|
||||
| Ok themeId when themeId <> adminTheme ->
|
||||
let data = ctx.Data
|
||||
let! exists = data.Theme.Exists themeId
|
||||
let isNew = not exists
|
||||
let! model = ctx.BindFormAsync<UploadThemeModel> ()
|
||||
if isNew || model.DoOverwrite then
|
||||
// Load the theme to the database
|
||||
use stream = new MemoryStream ()
|
||||
do! themeFile.CopyToAsync stream
|
||||
let! _ = loadFromZip themeId stream data
|
||||
do! ThemeAssetCache.refreshTheme themeId data
|
||||
TemplateCache.invalidateTheme themeId
|
||||
// Save the .zip file
|
||||
use file = new FileStream ($"{ThemeId.toString themeId}-theme.zip", FileMode.Create)
|
||||
do! themeFile.CopyToAsync file
|
||||
do! addMessage ctx
|
||||
{ UserMessage.success with
|
||||
Message = $"""Theme {if isNew then "add" else "updat"}ed successfully"""
|
||||
}
|
||||
return! toAdminDashboard next ctx
|
||||
else
|
||||
do! addMessage ctx
|
||||
{ UserMessage.error with
|
||||
Message = "Theme exists and overwriting was not requested; nothing saved"
|
||||
}
|
||||
return! toAdminDashboard next ctx
|
||||
| Ok _ ->
|
||||
do! addMessage ctx { UserMessage.error with Message = "You may not replace the admin theme" }
|
||||
return! toAdminDashboard next ctx
|
||||
| Error message ->
|
||||
do! addMessage ctx { UserMessage.error with Message = message }
|
||||
return! toAdminDashboard next ctx
|
||||
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
|
||||
}
|
||||
|
||||
// POST /admin/theme/{id}/delete
|
||||
let delete themeId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
match themeId with
|
||||
| "admin" | "default" ->
|
||||
do! addMessage ctx { UserMessage.error with Message = $"You may not delete the {themeId} theme" }
|
||||
return! all next ctx
|
||||
| it when WebLogCache.isThemeInUse (ThemeId it) ->
|
||||
do! addMessage ctx
|
||||
{ UserMessage.error with
|
||||
Message = $"You may not delete the {themeId} theme, as it is currently in use"
|
||||
}
|
||||
return! all next ctx
|
||||
| _ ->
|
||||
match! data.Theme.Delete (ThemeId themeId) with
|
||||
| true ->
|
||||
let zippedTheme = $"{themeId}-theme.zip"
|
||||
if File.Exists zippedTheme then File.Delete zippedTheme
|
||||
do! addMessage ctx { UserMessage.success with Message = $"Theme ID {themeId} deleted successfully" }
|
||||
return! all next ctx
|
||||
| false -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
/// ~~ WEB LOG SETTINGS ~~
|
||||
module WebLog =
|
||||
|
||||
open System.Collections.Generic
|
||||
open System.IO
|
||||
|
||||
// GET /admin/settings
|
||||
let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
match! TemplateCache.get adminTheme "user-list-body" data with
|
||||
| Ok userTemplate ->
|
||||
match! TemplateCache.get adminTheme "tag-mapping-list-body" ctx.Data with
|
||||
| Ok tagMapTemplate ->
|
||||
let! allPages = data.Page.All ctx.WebLog.Id
|
||||
let! themes = data.Theme.All ()
|
||||
let! users = data.WebLogUser.FindByWebLog ctx.WebLog.Id
|
||||
let! hash =
|
||||
hashForPage "Web Log Settings"
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
|
||||
|> addToHash "pages" (
|
||||
seq {
|
||||
KeyValuePair.Create ("posts", "- First Page of Posts -")
|
||||
yield! allPages
|
||||
|> List.sortBy (fun p -> p.Title.ToLower ())
|
||||
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title))
|
||||
}
|
||||
|> Array.ofSeq)
|
||||
|> addToHash "themes" (
|
||||
themes
|
||||
|> Seq.ofList
|
||||
|> Seq.map (fun it ->
|
||||
KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|
||||
|> Array.ofSeq)
|
||||
|> addToHash "upload_values" [|
|
||||
KeyValuePair.Create (UploadDestination.toString Database, "Database")
|
||||
KeyValuePair.Create (UploadDestination.toString Disk, "Disk")
|
||||
|]
|
||||
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|
||||
|> addToHash "rss_model" (EditRssModel.fromRssOptions ctx.WebLog.Rss)
|
||||
|> addToHash "custom_feeds" (
|
||||
ctx.WebLog.Rss.CustomFeeds
|
||||
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|
||||
|> Array.ofList)
|
||||
|> addViewContext ctx
|
||||
let! hash' = TagMapping.withTagMappings ctx hash
|
||||
return!
|
||||
addToHash "user_list" (userTemplate.Render hash') hash'
|
||||
|> addToHash "tag_mapping_list" (tagMapTemplate.Render hash')
|
||||
|> adminView "settings" next ctx
|
||||
| Error message -> return! Error.server message next ctx
|
||||
| Error message -> return! Error.server message next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings
|
||||
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
let! model = ctx.BindFormAsync<SettingsModel> ()
|
||||
match! data.WebLog.FindById ctx.WebLog.Id with
|
||||
| Some webLog ->
|
||||
let oldSlug = webLog.Slug
|
||||
let webLog = model.update webLog
|
||||
do! data.WebLog.UpdateSettings webLog
|
||||
|
||||
// Update cache
|
||||
WebLogCache.set webLog
|
||||
|
||||
if oldSlug <> webLog.Slug then
|
||||
// Rename disk directory if it exists
|
||||
let uploadRoot = Path.Combine ("wwwroot", "upload")
|
||||
let oldDir = Path.Combine (uploadRoot, oldSlug)
|
||||
if Directory.Exists oldDir then Directory.Move (oldDir, Path.Combine (uploadRoot, webLog.Slug))
|
||||
|
||||
do! addMessage ctx { UserMessage.success with Message = "Web log settings saved successfully" }
|
||||
return! redirectToGet "admin/settings" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
@@ -414,17 +414,6 @@ let generate (feedType : FeedType) postCount : HttpHandler = fun next ctx -> bac
|
||||
|
||||
// ~~ 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
|
||||
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
@@ -435,7 +424,7 @@ let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> t
|
||||
do! data.WebLog.UpdateRssOptions webLog
|
||||
WebLogCache.set webLog
|
||||
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
|
||||
}
|
||||
|
||||
@@ -507,6 +496,6 @@ let deleteCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun ne
|
||||
do! addMessage ctx { UserMessage.success with Message = "Custom feed deleted successfully" }
|
||||
else
|
||||
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
|
||||
}
|
||||
|
||||
@@ -218,23 +218,6 @@ let addViewContext ctx (hash : Hash) = task {
|
||||
let isHtmx (ctx : HttpContext) =
|
||||
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)
|
||||
let messagesToHeaders (messages : UserMessage array) : HttpHandler =
|
||||
seq {
|
||||
@@ -249,50 +232,12 @@ let messagesToHeaders (messages : UserMessage array) : HttpHandler =
|
||||
}
|
||||
|> 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
|
||||
let redirectToGet url : HttpHandler = fun _ ctx -> task {
|
||||
do! commitSession 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
|
||||
module Error =
|
||||
@@ -322,11 +267,82 @@ module Error =
|
||||
let messages = [|
|
||||
{ UserMessage.error with Message = $"The URL {ctx.Request.Path.Value} was not found" }
|
||||
|]
|
||||
(messagesToHeaders messages >=> setStatusCode 404) earlyReturn ctx
|
||||
else
|
||||
(setStatusCode 404 >=> text "Not found") earlyReturn ctx)
|
||||
RequestErrors.notFound (messagesToHeaders messages) earlyReturn ctx
|
||||
else RequestErrors.NOT_FOUND "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
|
||||
let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized
|
||||
|
||||
|
||||
@@ -124,8 +124,14 @@ let private findPageRevision pgId revDate (ctx : HttpContext) = task {
|
||||
let previewRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||
match! findPageRevision pgId revDate ctx with
|
||||
| Some pg, Some rev when canEdit pg.AuthorId ctx ->
|
||||
let _, extra = WebLog.hostAndPath ctx.WebLog
|
||||
return! {|
|
||||
content = $"""<div class="mwl-revision-preview mb-3">{MarkupText.toHtml rev.Text}</div>"""
|
||||
content =
|
||||
[ """<div class="mwl-revision-preview mb-3">"""
|
||||
(MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text
|
||||
"</div>"
|
||||
]
|
||||
|> String.concat ""
|
||||
|}
|
||||
|> makeHash |> adminBareView "" next ctx
|
||||
| Some _, Some _ -> return! Error.notAuthorized next ctx
|
||||
|
||||
@@ -329,8 +329,14 @@ let private findPostRevision postId revDate (ctx : HttpContext) = task {
|
||||
let previewRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||
match! findPostRevision postId revDate ctx with
|
||||
| Some post, Some rev when canEdit post.AuthorId ctx ->
|
||||
let _, extra = WebLog.hostAndPath ctx.WebLog
|
||||
return! {|
|
||||
content = $"""<div class="mwl-revision-preview mb-3">{MarkupText.toHtml rev.Text}</div>"""
|
||||
content =
|
||||
[ """<div class="mwl-revision-preview mb-3">"""
|
||||
(MarkupText.toHtml >> addBaseToRelativeUrls extra) rev.Text
|
||||
"</div>"
|
||||
]
|
||||
|> String.concat ""
|
||||
|}
|
||||
|> makeHash |> adminBareView "" next ctx
|
||||
| Some _, Some _ -> return! Error.notAuthorized next ctx
|
||||
|
||||
@@ -106,12 +106,14 @@ let router : HttpHandler = choose [
|
||||
]
|
||||
subRoute "/admin" (requireUser >=> choose [
|
||||
GET_HEAD >=> choose [
|
||||
route "/administration" >=> Admin.Dashboard.admin
|
||||
subRoute "/categor" (choose [
|
||||
route "ies" >=> Admin.listCategories
|
||||
route "ies/bare" >=> Admin.listCategoriesBare
|
||||
routef "y/%s/edit" Admin.editCategory
|
||||
route "ies" >=> Admin.Category.all
|
||||
route "ies/bare" >=> Admin.Category.bare
|
||||
routef "y/%s/edit" Admin.Category.edit
|
||||
])
|
||||
route "/dashboard" >=> Admin.dashboard
|
||||
route "/dashboard" >=> Admin.Dashboard.user
|
||||
route "/my-info" >=> User.myInfo
|
||||
subRoute "/page" (choose [
|
||||
route "s" >=> Page.all 1
|
||||
routef "s/page/%i" Page.all
|
||||
@@ -129,34 +131,36 @@ let router : HttpHandler = choose [
|
||||
routef "/%s/revisions" Post.editRevisions
|
||||
])
|
||||
subRoute "/settings" (choose [
|
||||
route "" >=> Admin.settings
|
||||
subRoute "/rss" (choose [
|
||||
route "" >=> Feed.editSettings
|
||||
routef "/%s/edit" Feed.editCustomFeed
|
||||
route "" >=> Admin.WebLog.settings
|
||||
routef "/rss/%s/edit" Feed.editCustomFeed
|
||||
subRoute "/user" (choose [
|
||||
route "s" >=> User.all
|
||||
routef "/%s/edit" User.edit
|
||||
])
|
||||
subRoute "/tag-mapping" (choose [
|
||||
route "s" >=> Admin.tagMappings
|
||||
route "s/bare" >=> Admin.tagMappingsBare
|
||||
routef "/%s/edit" Admin.editMapping
|
||||
route "s" >=> Admin.TagMapping.all
|
||||
routef "/%s/edit" Admin.TagMapping.edit
|
||||
])
|
||||
])
|
||||
route "/theme/update" >=> Admin.themeUpdatePage
|
||||
subRoute "/theme" (choose [
|
||||
route "/list" >=> Admin.Theme.all
|
||||
route "/new" >=> Admin.Theme.add
|
||||
])
|
||||
subRoute "/upload" (choose [
|
||||
route "s" >=> Upload.list
|
||||
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 [
|
||||
subRoute "/category" (choose [
|
||||
route "/save" >=> Admin.saveCategory
|
||||
routef "/%s/delete" Admin.deleteCategory
|
||||
subRoute "/cache" (choose [
|
||||
routef "/theme/%s/refresh" Admin.Cache.refreshTheme
|
||||
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 [
|
||||
route "/save" >=> Page.save
|
||||
route "/permalinks" >=> Page.savePermalinks
|
||||
@@ -174,28 +178,30 @@ let router : HttpHandler = choose [
|
||||
routef "/%s/revisions/purge" Post.purgeRevisions
|
||||
])
|
||||
subRoute "/settings" (choose [
|
||||
route "" >=> Admin.saveSettings
|
||||
route "" >=> Admin.WebLog.saveSettings
|
||||
subRoute "/rss" (choose [
|
||||
route "" >=> Feed.saveSettings
|
||||
route "/save" >=> Feed.saveCustomFeed
|
||||
routef "/%s/delete" Feed.deleteCustomFeed
|
||||
])
|
||||
subRoute "/tag-mapping" (choose [
|
||||
route "/save" >=> Admin.saveMapping
|
||||
routef "/%s/delete" Admin.deleteMapping
|
||||
route "/save" >=> Admin.TagMapping.save
|
||||
routef "/%s/delete" Admin.TagMapping.delete
|
||||
])
|
||||
subRoute "/user" (choose [
|
||||
route "/save" >=> User.save
|
||||
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 [
|
||||
route "/save" >=> Upload.save
|
||||
routexp "/delete/(.*)" Upload.deleteFromDisk
|
||||
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
|
||||
|
||||
@@ -85,7 +85,7 @@ open System.Text.RegularExpressions
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Turn a string into a lowercase URL-safe slug
|
||||
let makeSlug it = ((Regex """\s+""").Replace ((Regex "[^A-z0-9 ]").Replace (it, ""), "-")).ToLowerInvariant ()
|
||||
let makeSlug it = ((Regex """\s+""").Replace ((Regex "[^A-z0-9 -]").Replace (it, ""), "-")).ToLowerInvariant ()
|
||||
|
||||
// GET /admin/uploads
|
||||
let list : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||
|
||||
@@ -51,7 +51,10 @@ let doLogOn : HttpHandler = fun next ctx -> task {
|
||||
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
|
||||
do! data.WebLogUser.SetLastSeen user.Id user.WebLogId
|
||||
do! addMessage ctx
|
||||
{ UserMessage.success with Message = $"Logged on successfully | Welcome to {ctx.WebLog.Name}!" }
|
||||
{ UserMessage.success with
|
||||
Message = "Log on successful"
|
||||
Detail = Some $"Welcome to {ctx.WebLog.Name}!"
|
||||
}
|
||||
return!
|
||||
match model.ReturnTo with
|
||||
| Some url -> redirectTo false url next ctx
|
||||
@@ -72,34 +75,18 @@ let logOff : HttpHandler = fun next ctx -> task {
|
||||
|
||||
open System.Collections.Generic
|
||||
open Giraffe.Htmx
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// Create the hash needed to display the user list
|
||||
let private userListHash (ctx : HttpContext) = task {
|
||||
/// Got no time for URL/form manipulators...
|
||||
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
|
||||
return!
|
||||
hashForPage "User Administration"
|
||||
|> withAntiCsrf ctx
|
||||
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
|
||||
|> addViewContext 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
|
||||
|> adminBareView "user-list-body" next ctx
|
||||
}
|
||||
|
||||
/// Show the edit user page
|
||||
@@ -116,7 +103,7 @@ let private showEdit (model : EditUserModel) : HttpHandler = fun 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 isNew = usrId = "new"
|
||||
let userId = WebLogUserId usrId
|
||||
@@ -128,44 +115,7 @@ let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> tas
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /admin/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 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
|
||||
// POST /admin/settings/user/{id}/delete
|
||||
let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
|
||||
let data = ctx.Data
|
||||
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
|
||||
Message = $"User {WebLogUser.displayName user} deleted successfully"
|
||||
}
|
||||
return! bare next ctx
|
||||
return! all next ctx
|
||||
| Error msg ->
|
||||
do! addMessage ctx
|
||||
{ UserMessage.error with
|
||||
Message = $"User {WebLogUser.displayName user} was not deleted"
|
||||
Detail = Some msg
|
||||
}
|
||||
return! bare next ctx
|
||||
return! all 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
|
||||
|
||||
|
||||
// GET /admin/user/my-info
|
||||
// GET /admin/my-info
|
||||
let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||
match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
|
||||
| Some user -> return! showMyInfo (EditMyInfoModel.fromUser user) user 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! model = ctx.BindFormAsync<EditMyInfoModel> ()
|
||||
let data = ctx.Data
|
||||
@@ -231,9 +181,50 @@ let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|
||||
do! data.WebLogUser.Update user
|
||||
let pwMsg = if model.NewPassword = "" then "" else " and updated your password"
|
||||
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 ->
|
||||
do! addMessage ctx { UserMessage.error with Message = "Passwords did not match; no updates made" }
|
||||
return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user 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
|
||||
}
|
||||
|
||||
|
||||
@@ -128,26 +128,28 @@ let importLinks args sp = task {
|
||||
// Loading a theme and restoring a backup are not statically compilable; this is OK
|
||||
#nowarn "3511"
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// Load a theme from the given ZIP file
|
||||
let loadTheme (args : string[]) (sp : IServiceProvider) = task {
|
||||
if args.Length > 1 then
|
||||
if args.Length = 2 then
|
||||
let fileName =
|
||||
match args[1].LastIndexOf Path.DirectorySeparatorChar with
|
||||
| -1 -> args[1]
|
||||
| it -> args[1][(it + 1)..]
|
||||
match Handlers.Admin.getThemeName fileName with
|
||||
| Ok themeName ->
|
||||
match Handlers.Admin.Theme.deriveIdFromFileName fileName with
|
||||
| Ok themeId ->
|
||||
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 copy = new MemoryStream ()
|
||||
do! stream.CopyToAsync copy
|
||||
do! Handlers.Admin.loadThemeFromZip themeName copy clean data
|
||||
printfn $"Theme {themeName} loaded successfully"
|
||||
let! theme = Handlers.Admin.Theme.loadFromZip themeId copy data
|
||||
let fac = sp.GetRequiredService<ILoggerFactory> ()
|
||||
let log = fac.CreateLogger "MyWebLog.Themes"
|
||||
log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded"
|
||||
| Error message -> eprintfn $"{message}"
|
||||
else
|
||||
eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name] [*clean-load]"
|
||||
eprintfn " * optional, defaults to true"
|
||||
eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name]"
|
||||
}
|
||||
|
||||
/// Back up a web log's data
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>false</SelfContained>
|
||||
<DebugType>embedded</DebugType>
|
||||
<NoWarn>3391</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -82,6 +82,7 @@ let showHelp () =
|
||||
Task.FromResult ()
|
||||
|
||||
|
||||
open System.IO
|
||||
open Giraffe
|
||||
open Giraffe.EndpointRouting
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
@@ -135,7 +136,7 @@ let rec main args =
|
||||
|> ignore
|
||||
builder.Services.AddScoped<IData, SQLiteData> () |> ignore
|
||||
// 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
|
||||
| _ -> ()
|
||||
|
||||
@@ -162,7 +163,11 @@ let rec main args =
|
||||
| Some it ->
|
||||
printfn $"""Unrecognized command "{it}" - valid commands are:"""
|
||||
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.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
||||
let _ = app.UseMiddleware<WebLogMiddleware> ()
|
||||
@@ -172,7 +177,8 @@ let rec main args =
|
||||
let _ = app.UseSession ()
|
||||
let _ = app.UseGiraffe Handlers.Routes.endpoint
|
||||
|
||||
Task.FromResult (app.Run ())
|
||||
app.Run ()
|
||||
}
|
||||
|> Async.AwaitTask |> Async.RunSynchronously
|
||||
|
||||
0 // Exit code
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"Generator": "myWebLog 2.0-beta05",
|
||||
"Generator": "myWebLog 2.0-rc1",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"MyWebLog.Handlers": "Information"
|
||||
|
||||
32
src/admin-theme/_edit-common.liquid
Normal file
32
src/admin-theme/_edit-common.liquid
Normal 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"> • </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>
|
||||
<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>
|
||||
@@ -7,28 +7,42 @@
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarText">
|
||||
{% if is_logged_on -%}
|
||||
{%- if is_logged_on %}
|
||||
<ul class="navbar-nav">
|
||||
{{ "admin/dashboard" | nav_link: "Dashboard" }}
|
||||
{% if is_author %}
|
||||
{%- if is_author %}
|
||||
{{ "admin/pages" | nav_link: "Pages" }}
|
||||
{{ "admin/posts" | nav_link: "Posts" }}
|
||||
{{ "admin/uploads" | nav_link: "Uploads" }}
|
||||
{% endif %}
|
||||
{% if is_web_log_admin %}
|
||||
{%- endif %}
|
||||
{%- if is_web_log_admin %}
|
||||
{{ "admin/categories" | nav_link: "Categories" }}
|
||||
{{ "admin/users" | nav_link: "Users" }}
|
||||
{{ "admin/settings" | nav_link: "Settings" }}
|
||||
{% endif %}
|
||||
{%- endif %}
|
||||
{%- if is_administrator %}
|
||||
{{ "admin/administration" | nav_link: "Admin" }}
|
||||
{%- endif %}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
<ul class="navbar-nav flex-grow-1 justify-content-end">
|
||||
{% if is_logged_on -%}
|
||||
{{ "admin/user/my-info" | nav_link: "My Info" }}
|
||||
{%- if is_logged_on %}
|
||||
{{ "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">
|
||||
<a class="nav-link" href="{{ "user/log-off" | relative_link }}" hx-boost="false">Log Off</a>
|
||||
</li>
|
||||
{%- 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" }}
|
||||
{%- endif %}
|
||||
</ul>
|
||||
@@ -36,29 +50,36 @@
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="mx-3 mt-3">
|
||||
<div class="messages mt-2" id="msgContainer">
|
||||
<div id="toastHost" class="position-fixed top-0 w-100" aria-live="polite" aria-atomic="true">
|
||||
<div id="toasts" class="toast-container position-absolute p-3 mt-5 top-0 end-0">
|
||||
{% for msg in messages %}
|
||||
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show">
|
||||
{{ msg.message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
{% if msg.detail %}
|
||||
<hr>
|
||||
{{ msg.detail.value }}
|
||||
{% endif %}
|
||||
<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 }}
|
||||
{%- if msg.detail %}
|
||||
<hr>
|
||||
{{ msg.detail.value }}
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<main class="mx-3 mt-3">
|
||||
<div class="load-overlay p-5" id="loadOverlay"><h1 class="p-3">Loading…</h1></div>
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer class="position-fixed bottom-0 w-100">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-end">
|
||||
{%- assign version = generator | split: " " -%}
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end text-white me-2">
|
||||
{%- assign version = generator | split: " " -%}
|
||||
<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">
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
3
src/admin-theme/_theme-list-columns.liquid
Normal file
3
src/admin-theme/_theme-list-columns.liquid
Normal 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" -%}
|
||||
4
src/admin-theme/_user-list-columns.liquid
Normal file
4
src/admin-theme/_user-list-columns.liquid
Normal 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" -%}
|
||||
108
src/admin-theme/admin-dashboard.liquid
Normal file
108
src/admin-theme/admin-dashboard.liquid
Normal 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] }} • </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>
|
||||
@@ -7,21 +7,21 @@
|
||||
<div class="row">
|
||||
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Name" id="name" class="form-control form-control-sm" placeholder="Name" autofocus
|
||||
required value="{{ model.name | escape }}">
|
||||
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" autofocus required
|
||||
value="{{ model.name | escape }}">
|
||||
<label for="name">Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-4 col-xxl-3 mb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Slug" id="slug" class="form-control form-control-sm" placeholder="Slug" required
|
||||
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
|
||||
value="{{ model.slug | escape }}">
|
||||
<label for="slug">Slug</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 col-xxl-3 offset-xxl-1 mb-3">
|
||||
<div class="form-floating">
|
||||
<select name="ParentId" id="parentId" class="form-control form-control-sm">
|
||||
<select name="ParentId" id="parentId" class="form-control">
|
||||
<option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}>
|
||||
– None –
|
||||
</option>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<div class="col-12 col-xl-10 offset-xl-1 mb-3">
|
||||
<div class="form-floating">
|
||||
<input name="Description" id="description" class="form-control form-control-sm"
|
||||
<input name="Description" id="description" class="form-control"
|
||||
placeholder="A short description of this category" value="{{ model.description | escape }}">
|
||||
<label for="description">Description</label>
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
<form method="post" id="catList" class="container" hx-target="this" 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>
|
||||
{%- assign cat_count = categories | size -%}
|
||||
{% if cat_count > 0 %}
|
||||
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
|
||||
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
|
||||
{% for cat in categories -%}
|
||||
<div class="row mwl-table-detail" id="cat_{{ cat.id }}">
|
||||
<div class="{{ cat_col }} no-wrap">
|
||||
{%- if cat.parent_names %}
|
||||
<small class="text-muted">{% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %}</small>
|
||||
{%- endif %}
|
||||
{{ cat.name }}<br>
|
||||
<small>
|
||||
{%- assign cat_url_base = "admin/category/" | append: cat.id -%}
|
||||
{%- if cat.post_count > 0 %}
|
||||
<a href="{{ cat | category_link }}" target="_blank">
|
||||
View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%}
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- endif %}
|
||||
<a href="{{ cat_url_base | append: "/edit" | relative_link }}" hx-target="#cat_{{ cat.id }}"
|
||||
hx-swap="innerHTML show:#cat_{{ cat.id }}:top">
|
||||
Edit
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- assign cat_del_link = cat_url_base | append: "/delete" | relative_link -%}
|
||||
<a href="{{ cat_del_link }}" hx-post="{{ cat_del_link }}" class="text-danger"
|
||||
hx-confirm="Are you sure you want to delete the category “{{ cat.name }}”? This action cannot be undone.">
|
||||
Delete
|
||||
</a>
|
||||
</small>
|
||||
<div id="catList" class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{%- assign cat_count = categories | size -%}
|
||||
{% if cat_count > 0 %}
|
||||
{%- assign cat_col = "col-12 col-md-6 col-xl-5 col-xxl-4" -%}
|
||||
{%- assign desc_col = "col-12 col-md-6 col-xl-7 col-xxl-8" -%}
|
||||
<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>
|
||||
<div class="{{ desc_col }}">
|
||||
{%- if cat.description %}{{ cat.description.value }}{% else %}<em class="text-muted">none</em>{% endif %}
|
||||
<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 -%}
|
||||
<div class="row mwl-table-detail" id="cat_{{ cat.id }}">
|
||||
<div class="{{ cat_col }} no-wrap">
|
||||
{%- if cat.parent_names %}
|
||||
<small class="text-muted">{% for name in cat.parent_names %}{{ name }} ⟩ {% endfor %}</small>
|
||||
{%- endif %}
|
||||
{{ cat.name }}<br>
|
||||
<small>
|
||||
{%- assign cat_url_base = "admin/category/" | append: cat.id -%}
|
||||
{%- if cat.post_count > 0 %}
|
||||
<a href="{{ cat | category_link }}" target="_blank">
|
||||
View {{ cat.post_count }} Post{% unless cat.post_count == 1 %}s{% endunless -%}
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- endif %}
|
||||
<a href="{{ cat_url_base | append: "/edit" | relative_link }}" hx-target="#cat_{{ cat.id }}"
|
||||
hx-swap="innerHTML show:#cat_{{ cat.id }}:top">
|
||||
Edit
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- assign cat_del_link = cat_url_base | append: "/delete" | relative_link -%}
|
||||
<a href="{{ cat_del_link }}" hx-post="{{ cat_del_link }}" class="text-danger"
|
||||
hx-confirm="Are you sure you want to delete the category “{{ cat.name }}”? This action cannot be undone.">
|
||||
Delete
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div class="{{ desc_col }}">
|
||||
{%- if cat.description %}{{ cat.description.value }}{% else %}<em class="text-muted">none</em>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
</form>
|
||||
{%- else -%}
|
||||
<div id="cat_new">
|
||||
<p class="text-muted fst-italic text-center">This web log has no categores defined</p>
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{%- else -%}
|
||||
<div class="row">
|
||||
<div class="col-12 text-muted fst-italic text-center">This web log has no categores defined</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,5 @@
|
||||
hx-target="#cat_new">
|
||||
Add a New Category
|
||||
</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 }}
|
||||
</article>
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
</head>
|
||||
<body>
|
||||
{% include_template "_layout" %}
|
||||
<script>Admin.dismissSuccesses()</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,29 +4,16 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="generator" content="{{ generator }}">
|
||||
<title>{{ page_title | strip_html }} « Admin « {{ web_log.name | strip_html }}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{ "themes/admin/admin.css" | relative_link }}">
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<body hx-boost="true" hx-indicator="#loadOverlay">
|
||||
{% include_template "_layout" %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
||||
crossorigin="anonymous"></script>
|
||||
{{ 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>Admin.dismissSuccesses()</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<h2 class="my-3">{{ page_title }}</h2>
|
||||
<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 }}">
|
||||
<div class="d-flex flex-row flex-wrap justify-content-around">
|
||||
<div class="text-center mb-3 lh-sm">
|
||||
|
||||
@@ -6,39 +6,9 @@
|
||||
<div class="container">
|
||||
<div class="row mb-3">
|
||||
<div class="col-9">
|
||||
<div class="form-floating pb-3">
|
||||
<input type="text" name="Title" id="title" class="form-control" autofocus required
|
||||
value="{{ model.title }}">
|
||||
<label for="title">Title</label>
|
||||
</div>
|
||||
<div class="form-floating pb-3">
|
||||
<input type="text" name="Permalink" id="permalink" class="form-control" required
|
||||
value="{{ model.permalink }}">
|
||||
<label for="permalink">Permalink</label>
|
||||
{%- unless model.is_new %}
|
||||
<span class="form-text">
|
||||
<a href="{{ "admin/page/" | append: model.page_id | append: "/permalinks" | relative_link }}">
|
||||
Manage Permalinks
|
||||
</a>
|
||||
<span class="text-muted"> • </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>
|
||||
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
|
||||
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
|
||||
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
|
||||
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<textarea name="Text" id="text" class="form-control">{{ model.text }}</textarea>
|
||||
</div>
|
||||
{%- assign entity = "page" -%}
|
||||
{%- assign entity_id = model.page_id -%}
|
||||
{% include_template "_edit-common" %}
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="form-floating pb-3">
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
<article>
|
||||
<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 title_col = "col-12 col-md-5" -%}
|
||||
{%- assign link_col = "col-12 col-md-5" -%}
|
||||
{%- assign upd8_col = "col-12 col-md-2" -%}
|
||||
<form method="post" class="container" hx-target="body">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="row mwl-table-heading">
|
||||
<div class="{{ title_col }}">
|
||||
<span class="d-none d-md-inline">Title</span><span class="d-md-none">Page</span>
|
||||
{% if page_count > 0 %}
|
||||
{%- assign title_col = "col-12 col-md-5" -%}
|
||||
{%- assign link_col = "col-12 col-md-5" -%}
|
||||
{%- assign upd8_col = "col-12 col-md-2" -%}
|
||||
<form method="post" class="container" hx-target="body">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="row mwl-table-heading">
|
||||
<div class="{{ title_col }}">
|
||||
<span class="d-none d-md-inline">Title</span><span class="d-md-none">Page</span>
|
||||
</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>
|
||||
<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>
|
||||
{% if page_count > 0 %}
|
||||
{% for pg in pages -%}
|
||||
<div class="row mwl-table-detail">
|
||||
<div class="{{ title_col }}">
|
||||
@@ -48,30 +48,30 @@
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col text-muted fst-italic text-center">This web log has no pages</div>
|
||||
</form>
|
||||
{% if page_nbr > 1 or page_count == 25 %}
|
||||
<div class="d-flex justify-content-evenly mb-3">
|
||||
<div>
|
||||
{% if page_nbr > 1 %}
|
||||
<p>
|
||||
<a class="btn btn-secondary" href="{{ "admin/pages" | append: prev_page | relative_link }}">
|
||||
« Previous
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{% if page_count == 25 %}
|
||||
<p>
|
||||
<a class="btn btn-secondary" href="{{ "admin/pages" | append: next_page | relative_link }}">
|
||||
Next »
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if page_nbr > 1 or page_count == 25 %}
|
||||
<div class="d-flex justify-content-evenly pb-3">
|
||||
<div>
|
||||
{% if page_nbr > 1 %}
|
||||
<p>
|
||||
<a class="btn btn-default" href="{{ "admin/pages" | append: prev_page | relative_link }}">
|
||||
« Previous
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{% if page_count == 25 %}
|
||||
<p>
|
||||
<a class="btn btn-default" href="{{ "admin/pages" | append: next_page | relative_link }}">Next »</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted fst-italic text-center">This web log has no pages</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
@@ -6,41 +6,9 @@
|
||||
<div class="container">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 col-lg-9">
|
||||
<div class="form-floating pb-3">
|
||||
<input type="text" name="Title" id="title" class="form-control" placeholder="Title" autofocus required
|
||||
value="{{ model.title }}">
|
||||
<label for="title">Title</label>
|
||||
</div>
|
||||
<div class="form-floating pb-3">
|
||||
<input type="text" name="Permalink" id="permalink" class="form-control" placeholder="Permalink" required
|
||||
value="{{ model.permalink }}">
|
||||
<label for="permalink">Permalink</label>
|
||||
{%- unless model.is_new %}
|
||||
<span class="form-text">
|
||||
<a href="{{ "admin/post/" | append: model.post_id | append: "/permalinks" | relative_link }}">
|
||||
Manage Permalinks
|
||||
</a>
|
||||
<span class="text-muted"> • </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>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Text format button group">
|
||||
<input type="radio" name="Source" id="source_html" class="btn-check" value="HTML"
|
||||
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
|
||||
<input type="radio" name="Source" id="source_md" class="btn-check" value="Markdown"
|
||||
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pb-3">
|
||||
<textarea name="Text" id="text" class="form-control" rows="20">{{ model.text }}</textarea>
|
||||
</div>
|
||||
{%- assign entity = "post" -%}
|
||||
{%- assign entity_id = model.post_id -%}
|
||||
{% include_template "_edit-common" %}
|
||||
<div class="form-floating pb-3">
|
||||
<input type="text" name="Tags" id="tags" class="form-control" placeholder="Tags"
|
||||
value="{{ model.tags }}">
|
||||
@@ -61,7 +29,7 @@
|
||||
<small>
|
||||
<input type="checkbox" name="IsEpisode" id="isEpisode" class="form-check-input" value="true"
|
||||
data-bs-toggle="collapse" data-bs-target="#episodeItems" onclick="Admin.toggleEpisodeFields()"
|
||||
{%- if model.is_episode %}checked="checked"{% endif %}>
|
||||
{%- if model.is_episode %} checked="checked"{% endif %}>
|
||||
</small>
|
||||
<label for="isEpisode">Podcast Episode</label>
|
||||
</span>
|
||||
@@ -344,3 +312,4 @@
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
<script>window.setTimeout(() => Admin.toggleEpisodeFields(), 500)</script>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<h2 class="my-3">{{ page_title }}</h2>
|
||||
<article>
|
||||
<a href="{{ "admin/post/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Write a New Post</a>
|
||||
<form method="post" class="container" hx-target="body">
|
||||
<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 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 tag_col = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block" -%}
|
||||
<div class="row mwl-table-heading">
|
||||
<div class="{{ date_col }}">
|
||||
<span class="d-md-none">Post</span><span class="d-none d-md-inline">Date</span>
|
||||
{%- assign post_count = model.posts | size -%}
|
||||
{%- if post_count > 0 %}
|
||||
<form method="post" class="container" hx-target="body">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
{%- 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 author_col = "col-xs-12 col-md-2 col-lg-1" -%}
|
||||
{%- assign tag_col = "col-lg-3 col-xl-4 col-xxl-5 d-none d-lg-inline-block" -%}
|
||||
<div class="row mwl-table-heading">
|
||||
<div class="{{ date_col }}">
|
||||
<span class="d-md-none">Post</span><span class="d-none d-md-inline">Date</span>
|
||||
</div>
|
||||
<div class="{{ title_col }} d-none d-md-inline-block">Title</div>
|
||||
<div class="{{ author_col }} d-none d-md-inline-block">Author</div>
|
||||
<div class="{{ tag_col }}">Tags</div>
|
||||
</div>
|
||||
<div class="{{ title_col }} d-none d-md-inline-block">Title</div>
|
||||
<div class="{{ author_col }} d-none d-md-inline-block">Author</div>
|
||||
<div class="{{ tag_col }}">Tags</div>
|
||||
</div>
|
||||
{%- if post_count > 0 %}
|
||||
{% for post in model.posts -%}
|
||||
<div class="row mwl-table-detail">
|
||||
<div class="{{ date_col }} no-wrap">
|
||||
@@ -77,24 +77,22 @@
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col text-muted fst-italic text-center">This web log has no posts</div>
|
||||
</form>
|
||||
{% if model.newer_link or model.older_link %}
|
||||
<div class="d-flex justify-content-evenly mb-3">
|
||||
<div>
|
||||
{% if model.newer_link %}
|
||||
<p><a class="btn btn-secondary" href="{{ model.newer_link.value }}">« Newer Posts</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{% if model.older_link %}
|
||||
<p><a class="btn btn-secondary" href="{{ model.older_link.value }}">Older Posts »</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if model.newer_link or model.older_link %}
|
||||
<div class="d-flex justify-content-evenly">
|
||||
<div>
|
||||
{% if model.newer_link %}
|
||||
<p><a class="btn btn-default" href="{{ model.newer_link.value }}">« Newer Posts</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{% if model.older_link %}
|
||||
<p><a class="btn btn-default" href="{{ model.older_link.value }}">Older Posts »</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted fst-italic text-center">This web log has no posts</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
@@ -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 “0” to use “Posts per Page” 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 %} <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"> • </span>
|
||||
<a href="{{ feed_url | append: "/edit" | relative_link }}">Edit</a>
|
||||
<span class="text-muted"> • </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>
|
||||
@@ -1,106 +1,246 @@
|
||||
<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> •
|
||||
<a href="{{ "admin/settings/rss" | relative_link }}">RSS Settings</a>
|
||||
</p>
|
||||
<article>
|
||||
<form action="{{ "admin/settings" | relative_link }}" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus
|
||||
value="{{ model.name }}">
|
||||
<label for="name">Name</label>
|
||||
<p class="text-muted">
|
||||
Go to: <a href="#users">Users</a> • <a href="#rss-settings">RSS Settings</a> •
|
||||
<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">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Name" id="name" class="form-control" placeholder="Name" required autofocus
|
||||
value="{{ model.name }}">
|
||||
<label for="name">Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
|
||||
value="{{ model.slug }}">
|
||||
<label for="slug">Slug</label>
|
||||
<span class="form-text">
|
||||
<span class="badge rounded-pill bg-warning text-dark">WARNING</span> changing this value may break
|
||||
links
|
||||
(<a href="https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
|
||||
target="_blank">more</a>)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
|
||||
value="{{ model.subtitle }}">
|
||||
<label for="subtitle">Subtitle</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 offset-xl-1 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="ThemeId" id="themeId" class="form-control" required>
|
||||
{% for theme in themes -%}
|
||||
<option value="{{ theme[0] }}"{% if model.theme_id == theme[0] %} selected="selected"{% endif %}>
|
||||
{{ theme[1] }}
|
||||
</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="themeId">Theme</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="DefaultPage" id="defaultPage" class="form-control" required>
|
||||
{%- for pg in pages %}
|
||||
<option value="{{ pg[0] }}"{% if pg[0] == model.default_page %} selected="selected"{% endif %}>
|
||||
{{ pg[1] }}
|
||||
</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="defaultPage">Default Page</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-xl-2 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="number" name="PostsPerPage" id="postsPerPage" class="form-control" min="0" max="50" required
|
||||
value="{{ model.posts_per_page }}">
|
||||
<label for="postsPerPage">Posts per Page</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Slug" id="slug" class="form-control" placeholder="Slug" required
|
||||
value="{{ model.slug }}">
|
||||
<label for="slug">Slug</label>
|
||||
<span class="form-text">
|
||||
<span class="badge rounded-pill bg-warning text-dark">WARNING</span> changing this value may break links
|
||||
(<a href="https://bitbadger.solutions/open-source/myweblog/configuring.html#blog-settings"
|
||||
target="_blank">more</a>)
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-4 col-xl-3 offset-xl-2 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="TimeZone" id="timeZone" class="form-control" placeholder="Time Zone" required
|
||||
value="{{ model.time_zone }}">
|
||||
<label for="timeZone">Time Zone</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-xl-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" name="AutoHtmx" id="autoHtmx" class="form-check-input" value="true"
|
||||
{%- if model.auto_htmx %} checked="checked"{% endif %}>
|
||||
<label for="autoHtmx" class="form-check-label">Auto-Load htmx</label>
|
||||
</div>
|
||||
<span class="form-text fst-italic">
|
||||
<a href="https://htmx.org" target="_blank" rel="noopener">What is this?</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="Subtitle" id="subtitle" class="form-control" placeholder="Subtitle"
|
||||
value="{{ model.subtitle }}">
|
||||
<label for="subtitle">Subtitle</label>
|
||||
<div class="col-12 col-md-4 col-xl-3 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="Uploads" id="uploads" class="form-control">
|
||||
{%- for it in upload_values %}
|
||||
<option value="{{ it[0] }}"{% if model.uploads == it[0] %} selected{% endif %}>{{ it[1] }}</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="uploads">Default Upload Destination</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-4 offset-xl-1 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="ThemeId" id="themeId" class="form-control" required>
|
||||
{% for theme in themes -%}
|
||||
<option value="{{ theme[0] }}"{% if model.theme_id == theme[0] %} selected="selected"{% endif %}>
|
||||
{{ theme[1] }}
|
||||
</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="themeId">Theme</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 offset-md-1 col-xl-4 offset-xl-0 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="DefaultPage" id="defaultPage" class="form-control" required>
|
||||
{% for pg in pages -%}
|
||||
<option value="{{ pg[0] }}"
|
||||
{%- if pg[0] == model.default_page %} selected="selected"{% endif %}>
|
||||
{{ pg[1] }}
|
||||
</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="defaultPage">Default Page</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-xl-2 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="number" name="PostsPerPage" id="postsPerPage" class="form-control" min="0" max="50" required
|
||||
value="{{ model.posts_per_page }}">
|
||||
<label for="postsPerPage">Posts per Page</label>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-center">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-4 col-xl-3 offset-xl-2 pb-3">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="TimeZone" id="timeZone" class="form-control" placeholder="Time Zone" required
|
||||
value="{{ model.time_zone }}">
|
||||
<label for="timeZone">Time Zone</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-xl-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" name="AutoHtmx" id="autoHtmx" class="form-check-input" value="true"
|
||||
{%- if model.auto_htmx %} checked="checked"{% endif %}>
|
||||
<label for="autoHtmx" class="form-check-label">Auto-Load htmx</label>
|
||||
</div>
|
||||
<span class="form-text fst-italic">
|
||||
<a href="https://htmx.org" target="_blank" rel="noopener">What is this?</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-xl-3 pb-3">
|
||||
<div class="form-floating">
|
||||
<select name="Uploads" id="uploads" class="form-control">
|
||||
{%- for it in upload_values %}
|
||||
<option value="{{ it[0] }}"{% if model.uploads == it[0] %} selected{% endif %}>{{ it[1] }}</option>
|
||||
{%- endfor %}
|
||||
</select>
|
||||
<label for="uploads">Default Upload Destination</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-center">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</form>
|
||||
{{ 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 “0” to use “Posts per Page” 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 %} <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"> • </span>
|
||||
<a href="{{ feed_url | append: "/edit" | relative_link }}">Edit</a>
|
||||
<span class="text-muted"> • </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>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="row mb-3">
|
||||
<div class="col text-center">
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,45 @@
|
||||
<form method="post" class="container" id="tagList" hx-target="this" 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="tag_new"></div>
|
||||
{%- assign map_count = mappings | size -%}
|
||||
{% if map_count > 0 -%}
|
||||
{% for map in mappings -%}
|
||||
{%- assign map_id = mapping_ids | value: map.tag -%}
|
||||
<div class="row mwl-table-detail" id="tag_{{ map_id }}">
|
||||
<div class="col no-wrap">
|
||||
{{ map.tag }}<br>
|
||||
<small>
|
||||
{%- assign map_url = "admin/settings/tag-mapping/" | append: map_id -%}
|
||||
<a href="{{ map_url | append: "/edit" | relative_link }}" hx-target="#tag_{{ map_id }}"
|
||||
hx-swap="innerHTML show:#tag_{{ map_id }}:top">
|
||||
Edit
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- assign map_del_link = map_url | append: "/delete" | relative_link -%}
|
||||
<a href="{{ map_del_link }}" hx-post="{{ map_del_link }}" class="text-danger"
|
||||
hx-confirm="Are you sure you want to delete the mapping for “{{ map.tag }}”? This action cannot be undone.">
|
||||
Delete
|
||||
</a>
|
||||
</small>
|
||||
<div id="tagList" class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{%- assign map_count = mappings | size -%}
|
||||
{% 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>
|
||||
<div class="col">{{ map.url_value }}</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{%- else -%}
|
||||
<div class="row">
|
||||
<div class="col text-muted text-center fst-italic">This web log has no tag mappings</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 -%}
|
||||
{%- assign map_id = mapping_ids | value: map.tag -%}
|
||||
<div class="row mwl-table-detail" id="tag_{{ map_id }}">
|
||||
<div class="col no-wrap">
|
||||
{{ map.tag }}<br>
|
||||
<small>
|
||||
{%- assign map_url = "admin/settings/tag-mapping/" | append: map_id -%}
|
||||
<a href="{{ map_url | append: "/edit" | relative_link }}" hx-target="#tag_{{ map_id }}"
|
||||
hx-swap="innerHTML show:#tag_{{ map_id }}:top">
|
||||
Edit
|
||||
</a>
|
||||
<span class="text-muted"> • </span>
|
||||
{%- assign map_del_link = map_url | append: "/delete" | relative_link -%}
|
||||
<a href="{{ map_del_link }}" hx-post="{{ map_del_link }}" class="text-danger"
|
||||
hx-confirm="Are you sure you want to delete the mapping for “{{ map.tag }}”? This action cannot be undone.">
|
||||
Delete
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col">{{ map.url_value }}</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
</form>
|
||||
{%- else -%}
|
||||
<div id="tag_new">
|
||||
<p class="text-muted text-center fst-italic">This web log has no tag mappings</p>
|
||||
</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
33
src/admin-theme/theme-list-body.liquid
Normal file
33
src/admin-theme/theme-list-body.liquid
Normal 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"> • </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 “{{ theme.name }}”? This action cannot be undone.">
|
||||
Delete
|
||||
</a>
|
||||
{% endunless %}
|
||||
<span class="d-md-none text-muted">
|
||||
<br>Slug: {{ theme.id }} • {{ 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>
|
||||
30
src/admin-theme/theme-upload.liquid
Normal file
30
src/admin-theme/theme-upload.liquid
Normal 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>
|
||||
@@ -7,20 +7,20 @@
|
||||
<form method="post" class="container" hx-target="body">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="row">
|
||||
<div class="col text-muted text-center"><em>Uploaded files served from</em><br>{{ upload_base }}</div>
|
||||
</div>
|
||||
<div class="row mwl-table-heading">
|
||||
<div class="col-6">File Name</div>
|
||||
<div class="col-3">Path</div>
|
||||
<div class="col-3">File Date/Time</div>
|
||||
<div class="col text-center"><em class="text-muted">Uploaded files served from</em><br>{{ upload_base }}</div>
|
||||
</div>
|
||||
{%- assign file_count = files | size -%}
|
||||
{%- if file_count > 0 %}
|
||||
<div class="row mwl-table-heading">
|
||||
<div class="col-6">File Name</div>
|
||||
<div class="col-3">Path</div>
|
||||
<div class="col-3">File Date/Time</div>
|
||||
</div>
|
||||
{% for file in files %}
|
||||
<div class="row mwl-table-detail">
|
||||
<div class="col-6">
|
||||
{%- capture badge_class -%}
|
||||
{%- if file.source == "disk" %}secondary{% else %}primary{% endif -%}
|
||||
{%- if file.source == "Disk" %}secondary{% else %}primary{% endif -%}
|
||||
{%- endcapture -%}
|
||||
{%- assign path_and_name = file.path | append: file.name -%}
|
||||
{%- assign blog_rel = upload_path | append: path_and_name -%}
|
||||
@@ -49,7 +49,7 @@
|
||||
{% if is_web_log_admin %}
|
||||
<span class="text-muted"> • </span>
|
||||
{%- capture delete_url -%}
|
||||
{%- if file.source == "disk" -%}
|
||||
{%- if file.source == "Disk" -%}
|
||||
admin/upload/delete/{{ path_and_name }}
|
||||
{%- else -%}
|
||||
admin/upload/{{ file.id }}/delete
|
||||
@@ -69,7 +69,7 @@
|
||||
{% endfor %}
|
||||
{%- else -%}
|
||||
<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>
|
||||
{%- endif %}
|
||||
</form>
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
<div class="col-12 col-md-6 pb-3 d-flex align-self-center justify-content-around">
|
||||
Destination<br>
|
||||
<div class="btn-group" role="group" aria-label="Upload destination button group">
|
||||
<input type="radio" name="Destination" id="destination_db" class="btn-check" value="database"
|
||||
{%- if destination == "database" %} checked="checked"{% endif %}>
|
||||
<input type="radio" name="Destination" id="destination_db" class="btn-check" value="Database"
|
||||
{%- if destination == "Database" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-outline-primary" for="destination_db">Database</label>
|
||||
<input type="radio" name="Destination" id="destination_disk" class="btn-check" value="disk"
|
||||
{%- if destination == "disk" %} checked="checked"{% endif %}>
|
||||
<input type="radio" name="Destination" id="destination_disk" class="btn-check" value="Disk"
|
||||
{%- if destination == "Disk" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-outline-secondary" for="destination_disk">Disk</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,13 +1,13 @@
|
||||
<div class="col-12">
|
||||
<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">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<input type="hidden" name="Id" value="{{ model.id }}">
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-5 col-lg-3 col-xxl-2 offset-xxl-1 mb-3">
|
||||
<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 %}
|
||||
<option value="{{ level[0] }}"{% if model.access_level == level[0] %} selected{% endif %}>
|
||||
{{ level[1] }}
|
||||
@@ -88,7 +88,14 @@
|
||||
<div class="row mb-3">
|
||||
<div class="col text-center">
|
||||
<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>
|
||||
</form>
|
||||
|
||||
@@ -1,60 +1,61 @@
|
||||
<form method="post" id="userList" class="container" hx-target="this" 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="user_new"></div>
|
||||
{%- 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" -%}
|
||||
{%- assign badge = "ms-2 badge bg" -%}
|
||||
{% for user in users -%}
|
||||
<div class="row mwl-table-detail" id="user_{{ user.id }}">
|
||||
<div class="{{ user_col }} no-wrap">
|
||||
{{ user.preferred_name }}
|
||||
{%- if user.access_level == "Administrator" %}
|
||||
<span class="{{ badge }}-success">ADMINISTRATOR</span>
|
||||
{%- elsif user.access_level == "WebLogAdmin" %}
|
||||
<span class="{{ badge }}-primary">WEB LOG ADMIN</span>
|
||||
{%- elsif user.access_level == "Editor" %}
|
||||
<span class="{{ badge }}-secondary">EDITOR</span>
|
||||
{%- elsif user.access_level == "Author" %}
|
||||
<span class="{{ badge }}-dark">AUTHOR</span>
|
||||
{%- endif %}<br>
|
||||
{%- unless is_administrator == false and user.access_level == "Administrator" %}
|
||||
<small>
|
||||
{%- assign user_url_base = "admin/user/" | append: user.id -%}
|
||||
<a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}"
|
||||
hx-swap="innerHTML show:#user_{{ user.id }}:top">
|
||||
Edit
|
||||
</a>
|
||||
{% unless user_id == user.id %}
|
||||
<span class="text-muted"> • </span>
|
||||
{%- assign user_del_link = user_url_base | append: "/delete" | relative_link -%}
|
||||
<a href="{{ user_del_link }}" hx-post="{{ user_del_link }}" class="text-danger"
|
||||
hx-confirm="Are you sure you want to delete the user “{{ user.preferred_name }}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)">
|
||||
Delete
|
||||
<div id="userList">
|
||||
<div class="container g-0">
|
||||
<div class="row mwl-table-detail" id="user_new"></div>
|
||||
</div>
|
||||
<form method="post" id="userList" 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 "_user-list-columns" %}
|
||||
{%- assign badge = "ms-2 badge bg" -%}
|
||||
{% for user in users -%}
|
||||
<div class="row mwl-table-detail" id="user_{{ user.id }}">
|
||||
<div class="{{ user_col }} no-wrap">
|
||||
{{ user.preferred_name }}
|
||||
{%- if user.access_level == "Administrator" %}
|
||||
<span class="{{ badge }}-success">ADMINISTRATOR</span>
|
||||
{%- elsif user.access_level == "WebLogAdmin" %}
|
||||
<span class="{{ badge }}-primary">WEB LOG ADMIN</span>
|
||||
{%- elsif user.access_level == "Editor" %}
|
||||
<span class="{{ badge }}-secondary">EDITOR</span>
|
||||
{%- elsif user.access_level == "Author" %}
|
||||
<span class="{{ badge }}-dark">AUTHOR</span>
|
||||
{%- endif %}<br>
|
||||
{%- unless is_administrator == false and user.access_level == "Administrator" %}
|
||||
<small>
|
||||
{%- assign user_url_base = "admin/settings/user/" | append: user.id -%}
|
||||
<a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}"
|
||||
hx-swap="innerHTML show:#user_{{ user.id }}:top">
|
||||
Edit
|
||||
</a>
|
||||
{% endunless %}
|
||||
{% unless user_id == user.id %}
|
||||
<span class="text-muted"> • </span>
|
||||
{%- assign user_del_link = user_url_base | append: "/delete" | relative_link -%}
|
||||
<a href="{{ user_del_link }}" hx-post="{{ user_del_link }}" class="text-danger"
|
||||
hx-confirm="Are you sure you want to delete the user “{{ user.preferred_name }}”? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)">
|
||||
Delete
|
||||
</a>
|
||||
{% endunless %}
|
||||
</small>
|
||||
{%- endunless %}
|
||||
</div>
|
||||
<div class="{{ email_col }}">
|
||||
{{ user.first_name }} {{ user.last_name }}<br>
|
||||
<small class="text-muted">
|
||||
{{ user.email }}
|
||||
{%- unless user.url == "" %}<br>{{ user.url }}{% endunless %}
|
||||
</small>
|
||||
{%- endunless %}
|
||||
</div>
|
||||
<div class="{{ cre8_col }}">
|
||||
{{ user.created_on | date: "MMMM d, yyyy" }}
|
||||
</div>
|
||||
<div class="{{ last_col }}">
|
||||
{% if user.last_seen_on %}
|
||||
{{ user.last_seen_on | date: "MMMM d, yyyy" }} at
|
||||
{{ user.last_seen_on | date: "h:mmtt" | downcase }}
|
||||
{% else %}
|
||||
--
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="{{ email_col }}">
|
||||
{{ user.first_name }} {{ user.last_name }}<br>
|
||||
<small class="text-muted">
|
||||
{{ user.email }}
|
||||
{%- unless user.url == "" %}<br>{{ user.url }}{% endunless %}
|
||||
</small>
|
||||
</div>
|
||||
<div class="{{ cre8_col }}">
|
||||
{{ user.created_on | date: "MMMM d, yyyy" }}
|
||||
</div>
|
||||
<div class="{{ last_col }}">
|
||||
{% if user.last_seen_on %}
|
||||
{{ user.last_seen_on | date: "MMMM d, yyyy" }} at
|
||||
{{ user.last_seen_on | date: "h:mmtt" | downcase }}
|
||||
{% else %}
|
||||
--
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
</form>
|
||||
{%- endfor %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,2 +1,2 @@
|
||||
myWebLog Admin
|
||||
2.0.0-beta05
|
||||
2.0.0-rc1
|
||||
@@ -29,7 +29,6 @@ header nav {
|
||||
footer {
|
||||
background-color: #808080;
|
||||
border-top: solid 1px black;
|
||||
color: white;
|
||||
}
|
||||
.messages {
|
||||
max-width: 60rem;
|
||||
@@ -92,3 +91,27 @@ a.text-danger:link:hover, a.text-danger:visited:hover {
|
||||
border-radius: .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;
|
||||
}
|
||||
|
||||
@@ -293,33 +293,46 @@ this.Admin = {
|
||||
const parts = msg.split("|||")
|
||||
if (parts.length < 2) return
|
||||
|
||||
const msgDiv = document.createElement("div")
|
||||
msgDiv.className = `alert alert-${parts[0]} alert-dismissible fade show`
|
||||
msgDiv.setAttribute("role", "alert")
|
||||
msgDiv.innerHTML = parts[1]
|
||||
// Create the toast header
|
||||
const toastType = document.createElement("strong")
|
||||
toastType.className = "me-auto text-uppercase"
|
||||
toastType.innerText = parts[0] === "danger" ? "error" : parts[0]
|
||||
|
||||
const closeBtn = document.createElement("button")
|
||||
closeBtn.type = "button"
|
||||
closeBtn.className = "btn-close"
|
||||
closeBtn.setAttribute("data-bs-dismiss", "alert")
|
||||
closeBtn.setAttribute("data-bs-dismiss", "toast")
|
||||
closeBtn.setAttribute("aria-label", "Close")
|
||||
msgDiv.appendChild(closeBtn)
|
||||
|
||||
if (parts.length === 3) {
|
||||
msgDiv.innerHTML += `<hr>${parts[2]}`
|
||||
}
|
||||
document.getElementById("msgContainer").appendChild(msgDiv)
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Set all "success" alerts to close after 4 seconds
|
||||
*/
|
||||
dismissSuccesses() {
|
||||
[...document.querySelectorAll(".alert-success")].forEach(alert => {
|
||||
setTimeout(() => {
|
||||
(bootstrap.Alert.getInstance(alert) ?? new bootstrap.Alert(alert)).close()
|
||||
}, 4000)
|
||||
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) {
|
||||
toastBody.innerHTML += `<hr>${parts[2]}`
|
||||
}
|
||||
|
||||
// Assemble the toast
|
||||
const toast = document.createElement("div")
|
||||
toast.className = "toast"
|
||||
toast.setAttribute("role", "alert")
|
||||
toast.setAttribute("aria-live", "assertive")
|
||||
toast.setAttribute("aria-atomic", "true")
|
||||
toast.appendChild(toastHead)
|
||||
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
|
||||
if (hdrs.indexOf("x-message") >= 0) {
|
||||
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) {
|
||||
|
||||
@@ -1,56 +1,60 @@
|
||||
{%- if is_category or is_tag %}
|
||||
<h1 class="index-title">{{ page_title }}</h1>
|
||||
{%- if is_category %}
|
||||
{%- assign cat = categories | where: "slug", slug | first -%}
|
||||
{%- if cat.description %}<h4 class="text-muted">{{ cat.description.value }}</h4>{% endif -%}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
<section class="container mt-3" aria-label="The posts for the page">
|
||||
{% for post in model.posts %}
|
||||
<article>
|
||||
<h1>
|
||||
<a href="{{ post | relative_link }}" title="Permanent link to "{{ post.title | escape }}"">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h1>
|
||||
<p>
|
||||
Published on {{ post.published_on | date: "MMMM d, yyyy" }}
|
||||
at {{ post.published_on | date: "h:mmtt" | downcase }}
|
||||
by {{ model.authors | value: post.author_id }}
|
||||
</p>
|
||||
{{ post.text }}
|
||||
{%- assign category_count = post.category_ids | size -%}
|
||||
{%- assign tag_count = post.tags | size -%}
|
||||
{% if category_count > 0 or tag_count > 0 %}
|
||||
<footer>
|
||||
<p>
|
||||
{%- if category_count > 0 -%}
|
||||
Categorized under:
|
||||
{% for cat in post.category_ids -%}
|
||||
{%- assign this_cat = categories | where: "id", cat | first -%}
|
||||
{{ this_cat.name }}{% unless forloop.last %}, {% endunless %}
|
||||
{%- assign cat_names = this_cat.name | concat: cat_names -%}
|
||||
{%- endfor -%}
|
||||
{%- assign cat_names = "" -%}
|
||||
<br>
|
||||
{% endif -%}
|
||||
{%- if tag_count > 0 %}
|
||||
Tagged: {{ post.tags | join: ", " }}
|
||||
{% endif -%}
|
||||
</p>
|
||||
</footer>
|
||||
{%- if subtitle %}<h4 class="text-muted">{{ subtitle }}</h4>{% endif -%}
|
||||
{% endif %}
|
||||
{%- assign post_count = model.posts | size -%}
|
||||
{%- if post_count > 0 %}
|
||||
<section class="container mt-3" aria-label="The posts for the page">
|
||||
{%- for post in model.posts %}
|
||||
<article>
|
||||
<h1>
|
||||
<a href="{{ post | relative_link }}" title="Permanent link to "{{ post.title | escape }}"">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h1>
|
||||
<p>
|
||||
Published on {{ post.published_on | date: "MMMM d, yyyy" }}
|
||||
at {{ post.published_on | date: "h:mmtt" | downcase }}
|
||||
by {{ model.authors | value: post.author_id }}
|
||||
</p>
|
||||
{{ post.text }}
|
||||
{%- assign category_count = post.category_ids | size -%}
|
||||
{%- assign tag_count = post.tags | size -%}
|
||||
{% if category_count > 0 or tag_count > 0 %}
|
||||
<footer>
|
||||
<p>
|
||||
{%- if category_count > 0 -%}
|
||||
Categorized under:
|
||||
{% for cat in post.category_ids -%}
|
||||
{%- assign this_cat = categories | where: "Id", cat | first -%}
|
||||
{{ this_cat.name }}{% unless forloop.last %}, {% endunless %}
|
||||
{%- assign cat_names = this_cat.name | concat: cat_names -%}
|
||||
{%- endfor -%}
|
||||
{%- assign cat_names = "" -%}
|
||||
<br>
|
||||
{% endif -%}
|
||||
{%- if tag_count > 0 %}
|
||||
Tagged: {{ post.tags | join: ", " }}
|
||||
{% endif -%}
|
||||
</p>
|
||||
</footer>
|
||||
{% endif %}
|
||||
<hr>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<nav aria-label="pagination">
|
||||
<ul class="pagination justify-content-evenly mt-2">
|
||||
{% if model.newer_link -%}
|
||||
<li class="page-item"><a class="page-link" href="{{ model.newer_link.value }}">« Newer Posts</a></li>
|
||||
{% endif %}
|
||||
<hr>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<nav aria-label="pagination">
|
||||
<ul class="pagination justify-content-evenly mt-2">
|
||||
{% if model.newer_link -%}
|
||||
<li class="page-item"><a class="page-link" href="{{ model.newer_link.value }}">« Newer Posts</a></li>
|
||||
{% endif %}
|
||||
{% if model.older_link -%}
|
||||
<li class="page-item"><a class="page-link" href="{{ model.older_link.value }}">Older Posts »</a></li>
|
||||
{%- endif -%}
|
||||
</ul>
|
||||
</nav>
|
||||
{% if model.older_link -%}
|
||||
<li class="page-item"><a class="page-link" href="{{ model.older_link.value }}">Older Posts »</a></li>
|
||||
{%- endif -%}
|
||||
</ul>
|
||||
</nav>
|
||||
{%- else %}
|
||||
<article>
|
||||
<p class="text-center mt-3">No posts found</p>
|
||||
</article>
|
||||
{%- endif %}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
|
||||
<title>{{ page_title | strip_html }}{% if page_title %} « {% endif %}{{ web_log.name | strip_html }}</title>
|
||||
{% page_head -%}
|
||||
</head>
|
||||
@@ -55,8 +55,8 @@
|
||||
<img src="{{ "themes/admin/logo-dark.png" | relative_link }}" alt="myWebLog" width="120" height="34">
|
||||
</div>
|
||||
</footer>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
|
||||
crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h4 class="item-meta text-muted">
|
||||
Categorized under
|
||||
{% 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">
|
||||
<a href="{{ cat | category_link }}" title="Categorized under “{{ cat.name | escape }}”">
|
||||
{{ cat.name }}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
myWebLog Default Theme
|
||||
2.0.0-alpha36
|
||||
2.0.0-rc1
|
||||
Reference in New Issue
Block a user