- Add autoHtmx field / htmx partial support

- Add page_foot tag for scripts
- Add htmx to admin area
- Move create/permalink import to its own module
- Add htmx to tech-blog theme
- Move dashboard to admin/dashboard
This commit is contained in:
Daniel J. Summers 2022-05-31 15:28:11 -04:00
parent 019ac229fb
commit 1fd2bfd08e
20 changed files with 396 additions and 190 deletions

View File

@ -752,6 +752,7 @@ module WebLog =
"postsPerPage", webLog.postsPerPage "postsPerPage", webLog.postsPerPage
"timeZone", webLog.timeZone "timeZone", webLog.timeZone
"themePath", webLog.themePath "themePath", webLog.themePath
"autoHtmx", webLog.autoHtmx
] ]
write; withRetryDefault; ignoreResult write; withRetryDefault; ignoreResult
} }

View File

@ -275,6 +275,9 @@ type WebLog =
/// The RSS options for this web log /// The RSS options for this web log
rss : RssOptions rss : RssOptions
/// Whether to automatically load htmx
autoHtmx : bool
} }
/// Functions to support web logs /// Functions to support web logs
@ -291,6 +294,7 @@ module WebLog =
urlBase = "" urlBase = ""
timeZone = "" timeZone = ""
rss = RssOptions.empty rss = RssOptions.empty
autoHtmx = false
} }
/// Get the host (including scheme) and extra path from the URL base /// Get the host (including scheme) and extra path from the URL base

View File

@ -676,7 +676,11 @@ type SettingsModel =
/// The theme to use to display the web log /// The theme to use to display the web log
themePath : string themePath : string
/// Whether to automatically load htmx
autoHtmx : bool
} }
/// Create a settings model from a web log /// Create a settings model from a web log
static member fromWebLog (webLog : WebLog) = static member fromWebLog (webLog : WebLog) =
{ name = webLog.name { name = webLog.name
@ -685,6 +689,19 @@ type SettingsModel =
postsPerPage = webLog.postsPerPage postsPerPage = webLog.postsPerPage
timeZone = webLog.timeZone timeZone = webLog.timeZone
themePath = webLog.themePath themePath = webLog.themePath
autoHtmx = webLog.autoHtmx
}
/// Update a web log with settings from the form
member this.update (webLog : WebLog) =
{ webLog with
name = this.name
subtitle = if this.subtitle = "" then None else Some this.subtitle
defaultPage = this.defaultPage
postsPerPage = this.postsPerPage
timeZone = this.timeZone
themePath = this.themePath
autoHtmx = this.autoHtmx
} }

View File

@ -5,12 +5,17 @@ open System
open System.IO open System.IO
open System.Web open System.Web
open DotLiquid open DotLiquid
open Giraffe.ViewEngine
open MyWebLog.ViewModels open MyWebLog.ViewModels
/// Get the current web log from the DotLiquid context /// Get the current web log from the DotLiquid context
let webLog (ctx : Context) = let webLog (ctx : Context) =
ctx.Environments[0].["web_log"] :?> WebLog ctx.Environments[0].["web_log"] :?> WebLog
/// Does an asset exist for the current theme?
let assetExists fileName (webLog : WebLog) =
File.Exists (Path.Combine ("wwwroot", "themes", webLog.themePath, fileName))
/// Obtain the link from known types /// Obtain the link from known types
let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) = let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) =
match item with match item with
@ -72,9 +77,11 @@ type EditPostLinkFilter () =
type NavLinkFilter () = type NavLinkFilter () =
static member NavLink (ctx : Context, url : string, text : string) = static member NavLink (ctx : Context, url : string, text : string) =
let webLog = webLog ctx let webLog = webLog ctx
let _, path = WebLog.hostAndPath webLog
let path = if path = "" then path else $"{path.Substring 1}/"
seq { seq {
"<li class=\"nav-item\"><a class=\"nav-link" "<li class=\"nav-item\"><a class=\"nav-link"
if url = string ctx.Environments[0].["current_page"] then " active" if (string ctx.Environments[0].["current_page"]).StartsWith $"{path}{url}" then " active"
"\" href=\"" "\" href=\""
WebLog.relativeUrl webLog (Permalink url) WebLog.relativeUrl webLog (Permalink url)
"\">" "\">"
@ -98,10 +105,9 @@ type PageHeadTag () =
result.WriteLine $"""<meta name="generator" content="{context.Environments[0].["generator"]}">""" result.WriteLine $"""<meta name="generator" content="{context.Environments[0].["generator"]}">"""
// Theme assets // Theme assets
let has fileName = File.Exists (Path.Combine ("wwwroot", "themes", webLog.themePath, fileName)) if assetExists "style.css" webLog then
if has "style.css" then
result.WriteLine $"""{s}<link rel="stylesheet" href="/themes/{webLog.themePath}/style.css">""" result.WriteLine $"""{s}<link rel="stylesheet" href="/themes/{webLog.themePath}/style.css">"""
if has "favicon.ico" then if assetExists "favicon.ico" webLog then
result.WriteLine $"""{s}<link rel="icon" href="/themes/{webLog.themePath}/favicon.ico">""" result.WriteLine $"""{s}<link rel="icon" href="/themes/{webLog.themePath}/favicon.ico">"""
// RSS feeds and canonical URLs // RSS feeds and canonical URLs
@ -132,6 +138,22 @@ type PageHeadTag () =
let url = WebLog.absoluteUrl webLog (Permalink page.permalink) let url = WebLog.absoluteUrl webLog (Permalink page.permalink)
result.WriteLine $"""{s}<link rel="canonical" href="{url}">""" result.WriteLine $"""{s}<link rel="canonical" href="{url}">"""
/// Create various items in the page header based on the state of the page being generated
type PageFootTag () =
inherit Tag ()
override this.Render (context : Context, result : TextWriter) =
let webLog = webLog context
// spacer
let s = " "
if webLog.autoHtmx then
result.WriteLine $"{s}{RenderView.AsString.htmlNode Htmx.Script.minified}"
if assetExists "script.js" webLog then
result.WriteLine $"""{s}<script src="/themes/{webLog.themePath}/script.js"></script>"""
/// A filter to generate a relative link /// A filter to generate a relative link
type RelativeLinkFilter () = type RelativeLinkFilter () =
@ -167,7 +189,7 @@ type UserLinksTag () =
"""<ul class="navbar-nav flex-grow-1 justify-content-end">""" """<ul class="navbar-nav flex-grow-1 justify-content-end">"""
match Convert.ToBoolean context.Environments[0].["logged_on"] with match Convert.ToBoolean context.Environments[0].["logged_on"] with
| true -> | true ->
$"""<li class="nav-item"><a class="nav-link" href="{link "admin"}">Dashboard</a></li>""" $"""<li class="nav-item"><a class="nav-link" href="{link "admin/dashboard"}">Dashboard</a></li>"""
$"""<li class="nav-item"><a class="nav-link" href="{link "user/log-off"}">Log Off</a></li>""" $"""<li class="nav-item"><a class="nav-link" href="{link "user/log-off"}">Log Off</a></li>"""
| false -> | false ->
$"""<li class="nav-item"><a class="nav-link" href="{link "user/log-on"}">Log On</a></li>""" $"""<li class="nav-item"><a class="nav-link" href="{link "user/log-on"}">Log On</a></li>"""
@ -184,3 +206,31 @@ type ValueFilter () =
| None -> $"-- {name} not found --" | None -> $"-- {name} not found --"
open System.Collections.Generic
open Microsoft.AspNetCore.Antiforgery
/// Register custom filters/tags and safe types
let register () =
[ typeof<AbsoluteLinkFilter>; typeof<CategoryLinkFilter>; typeof<EditPageLinkFilter>; typeof<EditPostLinkFilter>
typeof<NavLinkFilter>; typeof<RelativeLinkFilter>; typeof<TagLinkFilter>; typeof<ThemeAssetFilter>
typeof<ValueFilter>
]
|> List.iter Template.RegisterFilter
Template.RegisterTag<PageHeadTag> "page_head"
Template.RegisterTag<PageFootTag> "page_foot"
Template.RegisterTag<UserLinksTag> "user_links"
[ // Domain types
typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog>
// View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditPageModel>; typeof<EditPostModel>
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>
typeof<UserMessage>
// Framework types
typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list>
typeof<string list>; typeof<string option>; typeof<TagMap list>
]
|> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))

View File

@ -291,19 +291,11 @@ let saveSettings : HttpHandler = fun next ctx -> task {
let! model = ctx.BindFormAsync<SettingsModel> () let! model = ctx.BindFormAsync<SettingsModel> ()
match! Data.WebLog.findById webLog.id conn with match! Data.WebLog.findById webLog.id conn with
| Some webLog -> | Some webLog ->
let updated = let webLog = model.update webLog
{ webLog with do! Data.WebLog.updateSettings webLog conn
name = model.name
subtitle = if model.subtitle = "" then None else Some model.subtitle
defaultPage = model.defaultPage
postsPerPage = model.postsPerPage
timeZone = model.timeZone
themePath = model.themePath
}
do! Data.WebLog.updateSettings updated conn
// Update cache // Update cache
WebLogCache.set updated WebLogCache.set webLog
do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" } do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" }
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin")) next ctx return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin")) next ctx

View File

@ -79,6 +79,11 @@ let private deriveWebLogFromHash (hash : Hash) (ctx : HttpContext) =
hash["web_log"] :?> WebLog hash["web_log"] :?> WebLog
open Giraffe open Giraffe
open Giraffe.Htmx
open Giraffe.ViewEngine
/// htmx script tag
let private htmxScript = RenderView.AsString.htmlNode Htmx.Script.minified
/// Render a view for the specified theme, using the specified template, layout, and hash /// Render a view for the specified theme, using the specified template, layout, and hash
let viewForTheme theme template next ctx = fun (hash : Hash) -> task { let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
@ -90,6 +95,7 @@ let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
hash.Add ("current_page", ctx.Request.Path.Value.Substring 1) hash.Add ("current_page", ctx.Request.Path.Value.Substring 1)
hash.Add ("messages", messages) hash.Add ("messages", messages)
hash.Add ("generator", generator ctx) hash.Add ("generator", generator ctx)
hash.Add ("htmx_script", htmxScript)
do! commitSession ctx do! commitSession ctx
@ -101,7 +107,9 @@ let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
hash.Add ("content", contentTemplate.Render hash) hash.Add ("content", contentTemplate.Render hash)
// ...then render that content with its layout // ...then render that content with its layout
let! layoutTemplate = TemplateCache.get theme "layout" let isHtmx = ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
let layout = if isHtmx then "layout-partial" else "layout"
let! layoutTemplate = TemplateCache.get theme layout
return! htmlString (layoutTemplate.Render hash) next ctx return! htmlString (layoutTemplate.Render hash) next ctx
} }

View File

@ -95,11 +95,11 @@ let router : HttpHandler = choose [
] ]
subRoute "/admin" (requireUser >=> choose [ subRoute "/admin" (requireUser >=> choose [
GET >=> choose [ GET >=> choose [
route "" >=> Admin.dashboard
subRoute "/categor" (choose [ subRoute "/categor" (choose [
route "ies" >=> Admin.listCategories route "ies" >=> Admin.listCategories
routef "y/%s/edit" Admin.editCategory routef "y/%s/edit" Admin.editCategory
]) ])
route "/dashboard" >=> Admin.dashboard
subRoute "/page" (choose [ subRoute "/page" (choose [
route "s" >=> Admin.listPages 1 route "s" >=> Admin.listPages 1
routef "s/page/%i" Admin.listPages routef "s/page/%i" Admin.listPages

View File

@ -56,7 +56,8 @@ let doLogOn : HttpHandler = fun next ctx -> task {
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" } { UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" }
return! redirectToGet (defaultArg model.returnTo (WebLog.relativeUrl webLog (Permalink "admin"))) next ctx return! redirectToGet (defaultArg model.returnTo (WebLog.relativeUrl webLog (Permalink "admin/dashboard")))
next ctx
| _ -> | _ ->
do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" } do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" }
return! logOn model.returnTo next ctx return! logOn model.returnTo next ctx

126
src/MyWebLog/Maintenance.fs Normal file
View File

@ -0,0 +1,126 @@
module MyWebLog.Maintenance
open System
open Microsoft.Extensions.DependencyInjection
open RethinkDb.Driver.Net
open System.IO
open RethinkDb.Driver.FSharp
/// Create the web log information
let private doCreateWebLog (args : string[]) (sp : IServiceProvider) = task {
let conn = sp.GetRequiredService<IConnection> ()
let timeZone =
let local = TimeZoneInfo.Local.Id
match TimeZoneInfo.Local.HasIanaId with
| true -> local
| false ->
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
| true, ianaId -> ianaId
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
// Create the web log
let webLogId = WebLogId.create ()
let userId = WebLogUserId.create ()
let homePageId = PageId.create ()
do! Data.WebLog.add
{ WebLog.empty with
id = webLogId
name = args[2]
urlBase = args[1]
defaultPage = PageId.toString homePageId
timeZone = timeZone
} conn
// Create the admin user
let salt = Guid.NewGuid ()
do! Data.WebLogUser.add
{ WebLogUser.empty with
id = userId
webLogId = webLogId
userName = args[3]
firstName = "Admin"
lastName = "User"
preferredName = "Admin"
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
salt = salt
authorizationLevel = Administrator
} conn
// Create the default home page
do! Data.Page.add
{ Page.empty with
id = homePageId
webLogId = webLogId
authorId = userId
title = "Welcome to myWebLog!"
permalink = Permalink "welcome-to-myweblog.html"
publishedOn = DateTime.UtcNow
updatedOn = DateTime.UtcNow
text = "<p>This is your default home page.</p>"
revisions = [
{ asOf = DateTime.UtcNow
text = Html "<p>This is your default home page.</p>"
}
]
} conn
printfn $"Successfully initialized database for {args[2]} with URL base {args[1]}"
}
/// Create a new web log
let createWebLog args sp = task {
match args |> Array.length with
| 5 -> return! doCreateWebLog args sp
| _ ->
printfn "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
return! System.Threading.Tasks.Task.CompletedTask
}
/// Import prior permalinks from a text files with lines in the format "[old] [new]"
let importPriorPermalinks urlBase file (sp : IServiceProvider) = task {
let conn = sp.GetRequiredService<IConnection> ()
match! Data.WebLog.findByHost urlBase conn with
| Some webLog ->
let mapping =
File.ReadAllLines file
|> Seq.ofArray
|> Seq.map (fun it ->
let parts = it.Split " "
Permalink parts[0], Permalink parts[1])
for old, current in mapping do
match! Data.Post.findByPermalink current webLog.id conn with
| Some post ->
let! withLinks = rethink<Post> {
withTable Data.Table.Post
get post.id
result conn
}
do! rethink {
withTable Data.Table.Post
get post.id
update [ "priorPermalinks", old :: withLinks.priorPermalinks :> obj]
write; ignoreResult conn
}
printfn $"{Permalink.toString old} -> {Permalink.toString current}"
| None -> printfn $"Cannot find current post for {Permalink.toString current}"
printfn "Done!"
| None -> printfn $"No web log found at {urlBase}"
}
/// Import permalinks if all is well
let importPermalinks args sp = task {
match args |> Array.length with
| 3 -> return! importPriorPermalinks args[1] args[2] sp
| _ ->
printfn "Usage: MyWebLog import-permalinks [url] [file-name]"
return! System.Threading.Tasks.Task.CompletedTask
}

View File

@ -17,12 +17,15 @@
<Compile Include="Handlers\User.fs" /> <Compile Include="Handlers\User.fs" />
<Compile Include="Handlers\Routes.fs" /> <Compile Include="Handlers\Routes.fs" />
<Compile Include="DotLiquidBespoke.fs" /> <Compile Include="DotLiquidBespoke.fs" />
<Compile Include="Maintenance.fs" />
<Compile Include="Program.fs" /> <Compile Include="Program.fs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DotLiquid" Version="2.2.656" /> <PackageReference Include="DotLiquid" Version="2.2.656" />
<PackageReference Include="Giraffe" Version="6.0.0" /> <PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Giraffe.Htmx" Version="1.7.0" />
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.7.0" />
<PackageReference Include="RethinkDB.DistributedCache" Version="1.0.0-rc1" /> <PackageReference Include="RethinkDB.DistributedCache" Version="1.0.0-rc1" />
<PackageReference Update="FSharp.Core" Version="6.0.4" /> <PackageReference Update="FSharp.Core" Version="6.0.4" />
<PackageReference Include="System.ServiceModel.Syndication" Version="6.0.0" /> <PackageReference Include="System.ServiceModel.Syndication" Version="6.0.0" />

View File

@ -23,148 +23,17 @@ type WebLogMiddleware (next : RequestDelegate, log : ILogger<WebLogMiddleware>)
ctx.Response.StatusCode <- 404 ctx.Response.StatusCode <- 404
} }
open System open System
open Microsoft.Extensions.DependencyInjection
open RethinkDb.Driver.Net
/// Create the default information for a new web log
module NewWebLog =
open System.IO
open RethinkDb.Driver.FSharp
/// Create the web log information
let private createWebLog (args : string[]) (sp : IServiceProvider) = task {
let conn = sp.GetRequiredService<IConnection> ()
let timeZone =
let local = TimeZoneInfo.Local.Id
match TimeZoneInfo.Local.HasIanaId with
| true -> local
| false ->
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
| true, ianaId -> ianaId
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
// Create the web log
let webLogId = WebLogId.create ()
let userId = WebLogUserId.create ()
let homePageId = PageId.create ()
do! Data.WebLog.add
{ WebLog.empty with
id = webLogId
name = args[2]
urlBase = args[1]
defaultPage = PageId.toString homePageId
timeZone = timeZone
} conn
// Create the admin user
let salt = Guid.NewGuid ()
do! Data.WebLogUser.add
{ WebLogUser.empty with
id = userId
webLogId = webLogId
userName = args[3]
firstName = "Admin"
lastName = "User"
preferredName = "Admin"
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
salt = salt
authorizationLevel = Administrator
} conn
// Create the default home page
do! Data.Page.add
{ Page.empty with
id = homePageId
webLogId = webLogId
authorId = userId
title = "Welcome to myWebLog!"
permalink = Permalink "welcome-to-myweblog.html"
publishedOn = DateTime.UtcNow
updatedOn = DateTime.UtcNow
text = "<p>This is your default home page.</p>"
revisions = [
{ asOf = DateTime.UtcNow
text = Html "<p>This is your default home page.</p>"
}
]
} conn
printfn $"Successfully initialized database for {args[2]} with URL base {args[1]}"
}
/// Create a new web log
let create args sp = task {
match args |> Array.length with
| 5 -> return! createWebLog args sp
| _ ->
printfn "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
return! System.Threading.Tasks.Task.CompletedTask
}
/// Import prior permalinks from a text files with lines in the format "[old] [new]"
let importPriorPermalinks urlBase file (sp : IServiceProvider) = task {
let conn = sp.GetRequiredService<IConnection> ()
match! Data.WebLog.findByHost urlBase conn with
| Some webLog ->
let mapping =
File.ReadAllLines file
|> Seq.ofArray
|> Seq.map (fun it ->
let parts = it.Split " "
Permalink parts[0], Permalink parts[1])
for old, current in mapping do
match! Data.Post.findByPermalink current webLog.id conn with
| Some post ->
let! withLinks = rethink<Post> {
withTable Data.Table.Post
get post.id
result conn
}
do! rethink {
withTable Data.Table.Post
get post.id
update [ "priorPermalinks", old :: withLinks.priorPermalinks :> obj]
write; ignoreResult conn
}
printfn $"{Permalink.toString old} -> {Permalink.toString current}"
| None -> printfn $"Cannot find current post for {Permalink.toString current}"
printfn "Done!"
| None -> printfn $"No web log found at {urlBase}"
}
/// Import permalinks if all is well
let importPermalinks args sp = task {
match args |> Array.length with
| 3 -> return! importPriorPermalinks args[1] args[2] sp
| _ ->
printfn "Usage: MyWebLog import-permalinks [url] [file-name]"
return! System.Threading.Tasks.Task.CompletedTask
}
open System.Collections.Generic
open DotLiquid
open DotLiquidBespoke
open Giraffe open Giraffe
open Giraffe.EndpointRouting open Giraffe.EndpointRouting
open Microsoft.AspNetCore.Antiforgery
open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.HttpOverrides open Microsoft.AspNetCore.HttpOverrides
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open MyWebLog.ViewModels open Microsoft.Extensions.DependencyInjection
open RethinkDB.DistributedCache open RethinkDB.DistributedCache
open RethinkDb.Driver.FSharp open RethinkDb.Driver.FSharp
open RethinkDb.Driver.Net
[<EntryPoint>] [<EntryPoint>]
let main args = let main args =
@ -210,36 +79,15 @@ let main args =
let _ = builder.Services.AddGiraffe () let _ = builder.Services.AddGiraffe ()
// Set up DotLiquid // Set up DotLiquid
[ typeof<AbsoluteLinkFilter>; typeof<CategoryLinkFilter>; typeof<EditPageLinkFilter>; typeof<EditPostLinkFilter> DotLiquidBespoke.register ()
typeof<NavLinkFilter>; typeof<RelativeLinkFilter>; typeof<TagLinkFilter>; typeof<ThemeAssetFilter>
typeof<ValueFilter>
]
|> List.iter Template.RegisterFilter
Template.RegisterTag<PageHeadTag> "page_head"
Template.RegisterTag<UserLinksTag> "user_links"
[ // Domain types
typeof<CustomFeed>; typeof<MetaItem>; typeof<Page>; typeof<RssOptions>; typeof<TagMap>; typeof<WebLog>
// View models
typeof<DashboardModel>; typeof<DisplayCategory>; typeof<DisplayCustomFeed>; typeof<DisplayPage>
typeof<EditCategoryModel>; typeof<EditCustomFeedModel>; typeof<EditPageModel>; typeof<EditPostModel>
typeof<EditRssModel>; typeof<EditTagMapModel>; typeof<EditUserModel>; typeof<LogOnModel>
typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem>; typeof<SettingsModel>
typeof<UserMessage>
// Framework types
typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list>
typeof<string list>; typeof<string option>; typeof<TagMap list>
]
|> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))
let app = builder.Build () let app = builder.Build ()
match args |> Array.tryHead with match args |> Array.tryHead with
| Some it when it = "init" -> | Some it when it = "init" ->
NewWebLog.create args app.Services |> Async.AwaitTask |> Async.RunSynchronously Maintenance.createWebLog args app.Services |> Async.AwaitTask |> Async.RunSynchronously
| Some it when it = "import-permalinks" -> | Some it when it = "import-permalinks" ->
NewWebLog.importPermalinks args app.Services |> Async.AwaitTask |> Async.RunSynchronously Maintenance.importPermalinks args app.Services |> Async.AwaitTask |> Async.RunSynchronously
| _ -> | _ ->
let _ = app.UseForwardedHeaders () let _ = app.UseForwardedHeaders ()
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))

View File

@ -3,7 +3,7 @@
"hostname": "data02.bitbadger.solutions", "hostname": "data02.bitbadger.solutions",
"database": "myWebLog_dev" "database": "myWebLog_dev"
}, },
"Generator": "myWebLog 2.0-alpha19", "Generator": "myWebLog 2.0-alpha20",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"MyWebLog.Handlers": "Debug" "MyWebLog.Handlers": "Debug"

View File

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ page_title | escape }} &laquo; Admin &laquo; {{ web_log.name | escape }}</title>
</head>
<body>
<header>
<nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2 position-fixed top-0 w-100">
<div class="container-fluid">
<a class="navbar-brand" href="{{ "" | relative_link }}" hx-boost="false">{{ web_log.name }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
{% if logged_on -%}
<ul class="navbar-nav">
{{ "admin/dashboard" | nav_link: "Dashboard" }}
{{ "admin/pages" | nav_link: "Pages" }}
{{ "admin/posts" | nav_link: "Posts" }}
{{ "admin/categories" | nav_link: "Categories" }}
{{ "admin/settings" | nav_link: "Settings" }}
</ul>
{%- endif %}
<ul class="navbar-nav flex-grow-1 justify-content-end">
{% if logged_on -%}
{{ "admin/user/edit" | nav_link: "Edit User" }}
{{ "user/log-off" | nav_link: "Log Off" }}
{%- else -%}
{{ "user/log-on" | nav_link: "Log On" }}
{%- endif %}
</ul>
</div>
</div>
</nav>
</header>
<main class="mx-3">
{% if messages %}
<div class="messages mt-2">
{% 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>
{% endfor %}
</div>
{% endif %}
{{ content }}
</main>
<footer class="position-fixed bottom-0 w-100">
<div class="container-fluid">
<div class="row">
<div class="col-xs-12 text-end"><img src="/img/logo-light.png" alt="myWebLog" width="120" height="34"></div>
</div>
</div>
</footer>
</body>
</html>

View File

@ -8,11 +8,11 @@
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" href="/themes/admin/admin.css"> <link rel="stylesheet" href="/themes/admin/admin.css">
</head> </head>
<body> <body hx-boost="true">
<header> <header>
<nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2 position-fixed top-0 w-100"> <nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2 position-fixed top-0 w-100">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{{ "" | relative_link }}">{{ web_log.name }}</a> <a class="navbar-brand" href="{{ "" | relative_link }}" hx-boost="false">{{ web_log.name }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
@ -20,7 +20,7 @@
<div class="collapse navbar-collapse" id="navbarText"> <div class="collapse navbar-collapse" id="navbarText">
{% if logged_on -%} {% if logged_on -%}
<ul class="navbar-nav"> <ul class="navbar-nav">
{{ "admin" | nav_link: "Dashboard" }} {{ "admin/dashboard" | nav_link: "Dashboard" }}
{{ "admin/pages" | nav_link: "Pages" }} {{ "admin/pages" | nav_link: "Pages" }}
{{ "admin/posts" | nav_link: "Posts" }} {{ "admin/posts" | nav_link: "Posts" }}
{{ "admin/categories" | nav_link: "Categories" }} {{ "admin/categories" | nav_link: "Categories" }}
@ -66,6 +66,7 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
{{ htmx_script }}
<script> <script>
const cssLoaded = [...document.styleSheets].filter(it => it.href.indexOf("bootstrap.min.css") > -1).length > 0 const cssLoaded = [...document.styleSheets].filter(it => it.href.indexOf("bootstrap.min.css") > -1).length > 0
if (!cssLoaded) { if (!cssLoaded) {

View File

@ -27,7 +27,7 @@
<label for="postsPerPage">Posts per Page</label> <label for="postsPerPage">Posts per Page</label>
</div> </div>
</div> </div>
<div class="col-12 col-md-4 col-xl-3 offset-xl-1 pb-3"> <div class="col-12 col-md-4 col-xl-3 pb-3">
<div class="form-floating"> <div class="form-floating">
<select name="themePath" id="themePath" class="form-control" required> <select name="themePath" id="themePath" class="form-control" required>
{% for theme in themes -%} {% for theme in themes -%}
@ -46,7 +46,7 @@
<label for="timeZone">Time Zone</label> <label for="timeZone">Time Zone</label>
</div> </div>
</div> </div>
<div class="col-12 col-md-4 col-xl-4 pb-3"> <div class="col-12 col-md-4 pb-3">
<div class="form-floating"> <div class="form-floating">
<select name="defaultPage" id="defaultPage" class="form-control" required> <select name="defaultPage" id="defaultPage" class="form-control" required>
{% for pg in pages -%} {% for pg in pages -%}
@ -59,6 +59,16 @@
<label for="defaultPage">Default Page</label> <label for="defaultPage">Default Page</label>
</div> </div>
</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>
<div class="row pb-3"> <div class="row pb-3">
<div class="col text-center"> <div class="col text-center">

View File

@ -32,7 +32,7 @@
<div> <div>
<small> <small>
{% if logged_on -%} {% if logged_on -%}
<a href="{{ "admin" | relative_link }}">Dashboard</a> ~ <a href="{{ "admin/dashboard" | relative_link }}">Dashboard</a> ~
<a href="{{ "user/log-off" | relative_link }}">Log Off</a> <a href="{{ "user/log-off" | relative_link }}">Log Off</a>
{% else %} {% else %}
<a href="{{ "user/log-on" | relative_link }}">Log On</a> <a href="{{ "user/log-on" | relative_link }}">Log On</a>

View File

@ -139,7 +139,7 @@
</a> </a>
&bull; Powered by <a href="https://github.com/bit-badger/myWebLog/tree/v2">myWebLog</a> &bull; &bull; Powered by <a href="https://github.com/bit-badger/myWebLog/tree/v2">myWebLog</a> &bull;
{% if logged_on %} {% if logged_on %}
<a href="{{ "admin" | relative_link }}">Dashboard</a> <a href="{{ "admin/dashboard" | relative_link }}">Dashboard</a>
{% else %} {% else %}
<a href="{{ "user/log-on" | relative_link }}">Log On</a> <a href="{{ "user/log-on" | relative_link }}">Log On</a>
{%- endif %} {%- endif %}

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>
{%- if is_home -%}
{{ web_log.name }}{% if web_log.subtitle %} | {{ web_log.subtitle.value }}{% endif %}
{%- else -%}
{{ page_title | strip_html }}{% if page_title and page_title != "" %} &raquo; {% endif %}{{ web_log.name }}
{%- endif -%}
</title>
</head>
<body>{{ content }}</body>
</html>

View File

@ -14,15 +14,21 @@
{% page_head -%} {% page_head -%}
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header" id="top">
<div class="header-logo"> <div class="header-logo">
<a href="{{ "" | relative_link }}"> <a href="{{ "" | relative_link }}" hx-boost="true" hx-target="#content" hx-swap="innerHTML show:#top:top"
hx-indicator="#loadOverlay">
<img src="{{ "img/bitbadger.png" | theme_asset }}" <img src="{{ "img/bitbadger.png" | theme_asset }}"
alt="A cartoon badger looking at a computer screen, with his paw on a mouse" alt="A cartoon badger looking at a computer screen, with his paw on a mouse"
title="Bit Badger Solutions"> title="Bit Badger Solutions">
</a> </a>
</div> </div>
<div class="header-title"><a href="{{ "" | relative_link }}">The Bit Badger Blog</a></div> <div class="header-title">
<a href="{{ "" | relative_link }}" hx-boost="true" hx-target="#content" hx-swap="innerHTML show:#top:top"
hx-indicator="#loadOverlay">
The Bit Badger Blog
</a>
</div>
<div class="header-spacer">&nbsp;</div> <div class="header-spacer">&nbsp;</div>
<div class="header-social"> <div class="header-social">
<a href="{{ "feed.xml" | relative_link }}" title="Subscribe to The Bit Badger Blog via RSS"> <a href="{{ "feed.xml" | relative_link }}" title="Subscribe to The Bit Badger Blog via RSS">
@ -36,8 +42,12 @@
</a> </a>
</div> </div>
</header> </header>
<div class="content-wrapper"> <div class="content-wrapper" hx-boost="true" hx-target="#content" hx-swap="innerHTML show:#top:top"
<main class="content" role="main"> hx-indicator="#loadOverlay">
<div class="load-overlay" id="loadOverlay">
<h1>Loading&hellip;</h1>
</div>
<main class="content" id="content" role="main">
{{ content }} {{ content }}
</main> </main>
<aside class="blog-sidebar"> <aside class="blog-sidebar">
@ -77,5 +87,6 @@
{%- endif %} {%- endif %}
</span> </span>
</footer> </footer>
{% page_foot %}
</body> </body>
</html> </html>

View File

@ -204,6 +204,64 @@ li {
margin-right: 1rem; margin-right: 1rem;
} }
} }
.load-overlay {
position: fixed;
display: block;
width: 90%;
height: 0;
z-index: 2000;
background-color: rgba(0, 0, 0, .5);
border-radius: 1rem;
animation: fadeOut .25s ease-in-out;
overflow: hidden;
}
.load-overlay h1 {
color: white;
background-color: rgba(0, 0, 0, .75);
margin-left: auto;
margin-right: auto;
border-radius: 1rem;
width: 50%;
padding: 1rem;
}
.load-overlay.htmx-request {
display: block;
height: 80vh;
animation: fadeIn .25s ease-in-out;
}
@keyframes fadeIn {
0% {
opacity: 0;
height: 0;
}
100% {
opacity: 1;
height: 80vh;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
height: 80vh;
}
99% {
opacity: 0;
height: 80vh;
}
100% {
opacity: 0;
height: 0;
}
}
@media all and (min-width: 68rem) {
.content {
width: 66rem;
}
.load-overlay {
width: 60rem;
left: 2rem;
}
}
.home-title { .home-title {
text-align: left; text-align: left;
line-height: 2rem; line-height: 2rem;