- 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
"timeZone", webLog.timeZone
"themePath", webLog.themePath
"autoHtmx", webLog.autoHtmx
]
write; withRetryDefault; ignoreResult
}

View File

@ -275,6 +275,9 @@ type WebLog =
/// The RSS options for this web log
rss : RssOptions
/// Whether to automatically load htmx
autoHtmx : bool
}
/// Functions to support web logs
@ -291,6 +294,7 @@ module WebLog =
urlBase = ""
timeZone = ""
rss = RssOptions.empty
autoHtmx = false
}
/// 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
themePath : string
/// Whether to automatically load htmx
autoHtmx : bool
}
/// Create a settings model from a web log
static member fromWebLog (webLog : WebLog) =
{ name = webLog.name
@ -685,6 +689,19 @@ type SettingsModel =
postsPerPage = webLog.postsPerPage
timeZone = webLog.timeZone
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.Web
open DotLiquid
open Giraffe.ViewEngine
open MyWebLog.ViewModels
/// Get the current web log from the DotLiquid context
let webLog (ctx : Context) =
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
let permalink (ctx : Context) (item : obj) (linkFunc : WebLog -> Permalink -> string) =
match item with
@ -72,9 +77,11 @@ type EditPostLinkFilter () =
type NavLinkFilter () =
static member NavLink (ctx : Context, url : string, text : string) =
let webLog = webLog ctx
let _, path = WebLog.hostAndPath webLog
let path = if path = "" then path else $"{path.Substring 1}/"
seq {
"<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=\""
WebLog.relativeUrl webLog (Permalink url)
"\">"
@ -98,10 +105,9 @@ type PageHeadTag () =
result.WriteLine $"""<meta name="generator" content="{context.Environments[0].["generator"]}">"""
// Theme assets
let has fileName = File.Exists (Path.Combine ("wwwroot", "themes", webLog.themePath, fileName))
if has "style.css" then
if assetExists "style.css" webLog then
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">"""
// RSS feeds and canonical URLs
@ -133,6 +139,22 @@ type PageHeadTag () =
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
type RelativeLinkFilter () =
static member RelativeLink (ctx : Context, item : obj) =
@ -167,7 +189,7 @@ type UserLinksTag () =
"""<ul class="navbar-nav flex-grow-1 justify-content-end">"""
match Convert.ToBoolean context.Environments[0].["logged_on"] with
| 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>"""
| false ->
$"""<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 --"
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> ()
match! Data.WebLog.findById webLog.id conn with
| Some webLog ->
let updated =
{ webLog with
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
let webLog = model.update webLog
do! Data.WebLog.updateSettings webLog conn
// Update cache
WebLogCache.set updated
WebLogCache.set webLog
do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" }
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
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
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 ("messages", messages)
hash.Add ("generator", generator ctx)
hash.Add ("htmx_script", htmxScript)
do! commitSession ctx
@ -101,7 +107,9 @@ let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
hash.Add ("content", contentTemplate.Render hash)
// ...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
}

View File

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

View File

@ -56,7 +56,8 @@ let doLogOn : HttpHandler = fun next ctx -> task {
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
do! addMessage ctx
{ 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" }
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\Routes.fs" />
<Compile Include="DotLiquidBespoke.fs" />
<Compile Include="Maintenance.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DotLiquid" Version="2.2.656" />
<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 Update="FSharp.Core" Version="6.0.4" />
<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
}
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.EndpointRouting
open Microsoft.AspNetCore.Antiforgery
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.HttpOverrides
open Microsoft.Extensions.Configuration
open MyWebLog.ViewModels
open Microsoft.Extensions.DependencyInjection
open RethinkDB.DistributedCache
open RethinkDb.Driver.FSharp
open RethinkDb.Driver.Net
[<EntryPoint>]
let main args =
@ -210,36 +79,15 @@ let main args =
let _ = builder.Services.AddGiraffe ()
// Set up DotLiquid
[ 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<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, [| "*" |]))
DotLiquidBespoke.register ()
let app = builder.Build ()
match args |> Array.tryHead with
| 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" ->
NewWebLog.importPermalinks args app.Services |> Async.AwaitTask |> Async.RunSynchronously
Maintenance.importPermalinks args app.Services |> Async.AwaitTask |> Async.RunSynchronously
| _ ->
let _ = app.UseForwardedHeaders ()
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))

View File

@ -3,7 +3,7 @@
"hostname": "data02.bitbadger.solutions",
"database": "myWebLog_dev"
},
"Generator": "myWebLog 2.0-alpha19",
"Generator": "myWebLog 2.0-alpha20",
"Logging": {
"LogLevel": {
"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">
<link rel="stylesheet" href="/themes/admin/admin.css">
</head>
<body>
<body hx-boost="true">
<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 }}">{{ 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"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@ -20,7 +20,7 @@
<div class="collapse navbar-collapse" id="navbarText">
{% if logged_on -%}
<ul class="navbar-nav">
{{ "admin" | nav_link: "Dashboard" }}
{{ "admin/dashboard" | nav_link: "Dashboard" }}
{{ "admin/pages" | nav_link: "Pages" }}
{{ "admin/posts" | nav_link: "Posts" }}
{{ "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"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"></script>
{{ htmx_script }}
<script>
const cssLoaded = [...document.styleSheets].filter(it => it.href.indexOf("bootstrap.min.css") > -1).length > 0
if (!cssLoaded) {

View File

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

View File

@ -32,7 +32,7 @@
<div>
<small>
{% 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>
{% else %}
<a href="{{ "user/log-on" | relative_link }}">Log On</a>

View File

@ -139,7 +139,7 @@
</a>
&bull; Powered by <a href="https://github.com/bit-badger/myWebLog/tree/v2">myWebLog</a> &bull;
{% if logged_on %}
<a href="{{ "admin" | relative_link }}">Dashboard</a>
<a href="{{ "admin/dashboard" | relative_link }}">Dashboard</a>
{% else %}
<a href="{{ "user/log-on" | relative_link }}">Log On</a>
{%- 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 -%}
</head>
<body>
<header class="site-header">
<header class="site-header" id="top">
<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 }}"
alt="A cartoon badger looking at a computer screen, with his paw on a mouse"
title="Bit Badger Solutions">
</a>
</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-social">
<a href="{{ "feed.xml" | relative_link }}" title="Subscribe to The Bit Badger Blog via RSS">
@ -36,8 +42,12 @@
</a>
</div>
</header>
<div class="content-wrapper">
<main class="content" role="main">
<div class="content-wrapper" hx-boost="true" hx-target="#content" hx-swap="innerHTML show:#top:top"
hx-indicator="#loadOverlay">
<div class="load-overlay" id="loadOverlay">
<h1>Loading&hellip;</h1>
</div>
<main class="content" id="content" role="main">
{{ content }}
</main>
<aside class="blog-sidebar">
@ -77,5 +87,6 @@
{%- endif %}
</span>
</footer>
{% page_foot %}
</body>
</html>

View File

@ -204,6 +204,64 @@ li {
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 {
text-align: left;
line-height: 2rem;