Revise admin area with htmx

- Replace tables with grids (much better on mobile)
- Category / tag mapping now edit inline
- Messages shown in all request forms
- Success messages auto-hide at 4 seconds
This commit is contained in:
Daniel J. Summers 2022-06-01 21:31:10 -04:00
parent 2a796042ac
commit c2b6d7c82c
19 changed files with 493 additions and 386 deletions

View File

@ -264,7 +264,7 @@ type WebLog =
/// The number of posts to display on pages of posts
postsPerPage : int
/// The path of the theme (within /views/themes)
/// The path of the theme (within /themes)
themePath : string
/// The URL base

View File

@ -49,15 +49,28 @@ let dashboard : HttpHandler = fun next ctx -> task {
// GET /admin/categories
let listCategories : HttpHandler = fun next ctx -> task {
return!
Hash.FromAnonymousObject {|
let! catListTemplate = TemplateCache.get "admin" "category-list-body"
let hash = Hash.FromAnonymousObject {|
web_log = ctx.WebLog
categories = CategoryCache.get ctx
page_title = "Categories"
csrf = csrfToken ctx
|}
|> viewForTheme "admin" "category-list" next ctx
hash.Add ("category_list", catListTemplate.Render hash)
return! viewForTheme "admin" "category-list" next ctx hash
}
// GET /admin/categories/bare
let listCategoriesBare : HttpHandler = fun next ctx -> task {
return!
Hash.FromAnonymousObject {|
categories = CategoryCache.get ctx
csrf = csrfToken ctx
|}
|> bareForTheme "admin" "category-list-body" next ctx
}
// GET /admin/category/{id}/edit
let editCategory catId : HttpHandler = fun next ctx -> task {
let! result = task {
@ -77,7 +90,7 @@ let editCategory catId : HttpHandler = fun next ctx -> task {
page_title = title
categories = CategoryCache.get ctx
|}
|> viewForTheme "admin" "category-edit" next ctx
|> bareForTheme "admin" "category-edit" next ctx
| None -> return! Error.notFound next ctx
}
@ -103,9 +116,7 @@ let saveCategory : HttpHandler = fun next ctx -> task {
do! (match model.categoryId with "new" -> Data.Category.add | _ -> Data.Category.update) cat conn
do! CategoryCache.update ctx
do! addMessage ctx { UserMessage.success with message = "Category saved successfully" }
return!
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/category/{CategoryId.toString cat.id}/edit"))
next ctx
return! listCategoriesBare next ctx
| None -> return! Error.notFound next ctx
}
@ -117,7 +128,7 @@ let deleteCategory catId : HttpHandler = fun next ctx -> task {
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! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/categories")) next ctx
return! listCategoriesBare next ctx
}
// -- PAGES --
@ -304,20 +315,37 @@ let saveSettings : HttpHandler = fun next ctx -> task {
// -- TAG MAPPINGS --
// GET /admin/tag-mappings
let tagMappings : HttpHandler = fun next ctx -> task {
open Microsoft.AspNetCore.Http
/// Get the hash necessary to render the tag mapping list
let private tagMappingHash (ctx : HttpContext) = task {
let! mappings = Data.TagMap.findByWebLogId ctx.WebLog.id ctx.Conn
return!
Hash.FromAnonymousObject
{| csrf = csrfToken ctx
return Hash.FromAnonymousObject {|
web_log = ctx.WebLog
csrf = csrfToken ctx
mappings = mappings
mapping_ids = mappings |> List.map (fun it -> { name = it.tag; value = TagMapId.toString it.id })
page_title = "Tag Mappings"
|}
|> viewForTheme "admin" "tag-mapping-list" next ctx
}
// GET /admin/tag-mapping/{id}/edit
// GET /admin/settings/tag-mappings
let tagMappings : HttpHandler = fun next ctx -> task {
let! hash = tagMappingHash ctx
let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body"
hash.Add ("tag_mapping_list", listTemplate.Render hash)
hash.Add ("page_title", "Tag Mappings")
return! viewForTheme "admin" "tag-mapping-list" next ctx hash
}
// GET /admin/settings/tag-mappings/bare
let tagMappingsBare : HttpHandler = fun next ctx -> task {
let! hash = tagMappingHash ctx
return! bareForTheme "admin" "tag-mapping-list-body" next ctx hash
}
// GET /admin/settings/tag-mapping/{id}/edit
let editMapping tagMapId : HttpHandler = fun next ctx -> task {
let isNew = tagMapId = "new"
let tagMap =
@ -333,11 +361,11 @@ let editMapping tagMapId : HttpHandler = fun next ctx -> task {
model = EditTagMapModel.fromMapping tm
page_title = if isNew then "Add Tag Mapping" else $"Mapping for {tm.tag} Tag"
|}
|> viewForTheme "admin" "tag-mapping-edit" next ctx
|> bareForTheme "admin" "tag-mapping-edit" next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/tag-mapping/save
// POST /admin/settings/tag-mapping/save
let saveMapping : HttpHandler = fun next ctx -> task {
let webLog = ctx.WebLog
let conn = ctx.Conn
@ -351,17 +379,15 @@ let saveMapping : HttpHandler = fun next ctx -> task {
| Some tm ->
do! Data.TagMap.save { tm with tag = model.tag.ToLower (); urlValue = model.urlValue.ToLower () } conn
do! addMessage ctx { UserMessage.success with message = "Tag mapping saved successfully" }
return!
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/tag-mapping/{TagMapId.toString tm.id}/edit"))
next ctx
return! tagMappingsBare next ctx
| None -> return! Error.notFound next ctx
}
// POST /admin/tag-mapping/{id}/delete
// POST /admin/settings/tag-mapping/{id}/delete
let deleteMapping tagMapId : HttpHandler = fun next ctx -> task {
let webLog = ctx.WebLog
match! Data.TagMap.delete (TagMapId tagMapId) webLog.id ctx.Conn 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! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/tag-mappings")) next ctx
return! tagMappingsBare next ctx
}

View File

@ -85,8 +85,8 @@ 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 {
/// Populate the DotLiquid hash with standard information
let private populateHash hash ctx = task {
// Don't need the web log, but this adds it to the hash if the function is called directly
let _ = deriveWebLogFromHash hash ctx
let! messages = messages ctx
@ -98,6 +98,11 @@ let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
hash.Add ("htmx_script", htmxScript)
do! commitSession ctx
}
/// Render a view for the specified theme, using the specified template, layout, and hash
let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
do! populateHash hash ctx
// 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
@ -108,12 +113,38 @@ let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
// ...then render that content with its 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
let! layoutTemplate = TemplateCache.get theme (if isHtmx then "layout-partial" else "layout")
return! htmlString (layoutTemplate.Render hash) next ctx
}
/// Render a bare view for the specified theme, using the specified template and hash
let bareForTheme theme template next ctx = fun (hash : Hash) -> task {
do! populateHash hash ctx
// Bare templates are rendered with layout-bare
let! contentTemplate = TemplateCache.get theme template
hash.Add ("content", contentTemplate.Render hash)
let! layoutTemplate = TemplateCache.get theme "layout-bare"
// add messages as HTTP headers
let messages = hash["messages"] :?> UserMessage[]
let actions = seq {
yield!
messages
|> Array.map (fun m ->
match m.detail with
| Some detail -> $"{m.level}|||{m.message}|||{detail}"
| None -> $"{m.level}|||{m.message}"
|> setHttpHeader "X-Message")
withHxNoPush
htmlString (layoutTemplate.Render hash)
}
return! (actions |> Seq.reduce (>=>)) next ctx
}
/// Return a view for the web log's default theme
let themedView template next ctx = fun (hash : Hash) -> task {
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash

View File

@ -98,6 +98,7 @@ let router : HttpHandler = choose [
GET >=> choose [
subRoute "/categor" (choose [
route "ies" >=> Admin.listCategories
route "ies/bare" >=> Admin.listCategoriesBare
routef "y/%s/edit" Admin.editCategory
])
route "/dashboard" >=> Admin.dashboard
@ -121,6 +122,7 @@ let router : HttpHandler = choose [
])
subRoute "/tag-mapping" (choose [
route "s" >=> Admin.tagMappings
route "s/bare" >=> Admin.tagMappingsBare
routef "/%s/edit" Admin.editMapping
])
])

View File

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

View File

@ -1,27 +1,27 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<form action="{{ "admin/category/save" | relative_link }}" method="post">
<div class="col-12">
<h5 class="my-3">{{ page_title }}</h5>
<form hx-post="{{ "admin/category/save" | relative_link }}" 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 }}">
<input type="hidden" name="categoryId" value="{{ model.category_id }}">
<div class="container">
<div class="row mb-3">
<div class="col-6 col-lg-4 pb-3">
<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" placeholder="Name" autofocus required
value="{{ model.name | escape }}">
<input type="text" name="name" id="name" class="form-control form-control-sm" placeholder="Name" autofocus
required value="{{ model.name | escape }}">
<label for="name">Name</label>
</div>
</div>
<div class="col-6 col-lg-4 pb-3">
<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" placeholder="Slug" required
<input type="text" name="slug" id="slug" class="form-control form-control-sm" placeholder="Slug" required
value="{{ model.slug | escape }}">
<label for="slug">Slug</label>
</div>
</div>
<div class="col-12 col-lg-4 pb-3">
<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">
<select name="parentId" id="parentId" class="form-control form-control-sm">
<option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}>
&ndash; None &ndash;
</option>
@ -36,21 +36,19 @@
<label for="parentId">Parent Category</label>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col">
<div class="col-12 col-xl-10 offset-xl-1 mb-3">
<div class="form-floating">
<input name="description" id="description" class="form-control"
<input name="description" id="description" class="form-control form-control-sm"
placeholder="A short description of this category" value="{{ model.description | escape }}">
<label for="description">Description</label>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
<div class="col text-center">
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
<a href="{{ "admin/categories/bare" | relative_link }}" class="btn btn-sm btn-secondary ms-3">Cancel</a>
</div>
</div>
</form>
</article>
</div>

View File

@ -0,0 +1,46 @@
<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 }} &rang; {% endfor %}</small>
{%- endif %}
{{ cat.name }}<br>
<small>
{%- 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"> &bull; </span>
{%- endif %}
{%- capture cat_edit %}admin/category/{{ cat.id }}/edit{% endcapture -%}
<a href="{{ cat_edit | relative_link }}" hx-target="#cat_{{ cat.id }}"
hx-swap="innerHTML show:#cat_{{ cat.id }}:top">
Edit
</a>
<span class="text-muted"> &bull; </span>
{%- capture cat_del %}admin/category/{{ cat.id }}/delete{% endcapture -%}
{%- capture cat_del_link %}{{ cat_del | relative_link }}{% endcapture -%}
<a href="{{ cat_del_link }}" hx-post="{{ cat_del_link }}" class="text-danger"
hx-confirm="Are you sure you want to delete the category &ldquo;{{ cat.name }}&rdquo;? 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 %}
{%- else -%}
<div class="row">
<div class="col-12 text-muted fst-italic text-center">This web log has no categores defined</div>
</div>
{%- endif %}
</form>

View File

@ -1,54 +1,16 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<a href="{{ "admin/category/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">Add a New Category</a>
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody>
{%- assign cat_count = categories | size -%}
{% if cat_count > 0 %}
{% for cat in categories -%}
<tr>
<td class="no-wrap">
{%- if cat.parent_names %}
<small class="text-muted">{% for name in cat.parent_names %}{{ name }} &rang; {% endfor %}</small>
{%- endif %}
{{ cat.name }}<br>
<small>
{%- 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 href="{{ "admin/category/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3"
hx-target="#cat_new">
Add a New Category
</a>
<span class="text-muted"> &bull; </span>
{%- endif %}
{%- capture cat_edit %}admin/category/{{ cat.id }}/edit{% endcapture -%}
<a href="{{ cat_edit | relative_link }}">Edit</a>
<span class="text-muted"> &bull; </span>
{%- capture cat_del %}admin/category/{{ cat.id }}/delete{% endcapture -%}
{%- capture cat_del_link %}{{ cat_del | relative_link }}{% endcapture -%}
<a href="{{ cat_del_link }}" class="text-danger"
onclick="return Admin.deleteCategory('{{ cat.name }}', '{{ cat_del_link }}')">
Delete
</a>
</small>
</td>
<td>
{%- if cat.description %}{{ cat.description.value }}{% else %}<em class="text-muted">none</em>{% endif %}
</td>
</tr>
{%- endfor %}
{%- else -%}
<tr>
<td colspan="2" class="text-muted fst-italic text-center">This web log has no categores defined</td>
</tr>
{%- endif %}
</tbody>
</table>
<form method="post" id="deleteForm">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
</form>
<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>

View File

@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<head><title></title></head>
<body>{{ content }}</body>
</html>

View File

@ -34,9 +34,8 @@
</div>
</nav>
</header>
<main class="mx-3">
{% if messages %}
<div class="messages mt-2">
<main class="mx-3 mt-3">
<div class="messages mt-2" id="msgContainer">
{% for msg in messages %}
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show">
{{ msg.message }}
@ -48,7 +47,6 @@
</div>
{% endfor %}
</div>
{% endif %}
{{ content }}
</main>
<footer class="position-fixed bottom-0 w-100">
@ -58,5 +56,6 @@
</div>
</div>
</footer>
<script>Admin.dismissSuccesses()</script>
</body>
</html>

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width; initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="{{ generator }}">
<title>{{ page_title | escape }} &laquo; Admin &laquo; {{ web_log.name | escape }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
@ -39,9 +39,8 @@
</div>
</nav>
</header>
<main class="mx-3">
{% if messages %}
<div class="messages mt-2">
<main class="mx-3 mt-3">
<div class="messages mt-2" id="msgContainer">
{% for msg in messages %}
<div role="alert" class="alert alert-{{ msg.level }} alert-dismissible fade show">
{{ msg.message }}
@ -53,7 +52,6 @@
</div>
{% endfor %}
</div>
{% endif %}
{{ content }}
</main>
<footer class="position-fixed bottom-0 w-100">
@ -80,5 +78,6 @@
}, 2000)
</script>
<script src="/themes/admin/admin.js"></script>
<script>Admin.dismissSuccesses()</script>
</body>
</html>

View File

@ -2,19 +2,22 @@
<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 -%}
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Title</th>
<th scope="col">Permalink</th>
<th scope="col">Last Updated</th>
</tr>
</thead>
<tbody>
{%- 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>
{% if page_count > 0 %}
{% for pg in pages -%}
<tr>
<td>
<div class="row mwl-table-detail">
<div class="{{ title_col }}">
{{ pg.title }}
{%- if pg.is_default %} &nbsp; <span class="badge bg-success">HOME PAGE</span>{% endif -%}
{%- if pg.show_in_page_list %} &nbsp; <span class="badge bg-primary">IN PAGE LIST</span> {% endif -%}<br>
@ -26,25 +29,30 @@
<span class="text-muted"> &bull; </span>
{%- capture pg_del %}admin/page/{{ pg.id }}/delete{% endcapture -%}
{%- capture pg_del_link %}{{ pg_del | relative_link }}{% endcapture -%}
<a href="{{ pg_del_link }}" class="text-danger"
onclick="return Admin.deletePage('{{ pg.title }}', '{{ pg_del_link }}')">
<a href="{{ pg_del_link }}" hx-post="{{ pg_del_link }}" class="text-danger"
hx-confirm="Are you sure you want to delete the page &ldquo;{{ pg.title | strip_html | escape }}&rdquo;? This action cannot be undone.">
Delete
</a>
</small>
</td>
<td>/{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}</td>
<td>{{ pg.updated_on | date: "MMMM d, yyyy" }}</td>
</tr>
</div>
<div class="{{ link_col }}">
{%- capture pg_link %}/{% unless pg.is_default %}{{ pg.permalink }}{% endunless %}{% endcapture -%}
<small class="d-md-none">{{ pg_link }}</small><span class="d-none d-md-inline">{{ pg_link }}</span>
</div>
<div class="{{ upd8_col }}">
<small class="d-md-none text-muted">Updated {{ pg.updated_on | date: "MMMM d, yyyy" }}</small>
<span class="d-none d-md-inline">{{ pg.updated_on | date: "MMMM d, yyyy" }}</span>
</div>
</div>
{%- endfor %}
{% else %}
<tr>
<td colspan="3" class="text-muted fst-italic text-center">This web log has no pages</td>
</tr>
<div class="row">
<div class="col text-muted fst-italic text-center">This web log has no pages</div>
</div>
{% endif %}
</tbody>
</table>
</form>
{% if page_nbr > 1 or page_count == 25 %}
<div class="d-flex justify-content-evenly">
<div class="d-flex justify-content-evenly pb-3">
<div>
{% if page_nbr > 1 %}
{%- capture prev_link %}admin/pages{{ prev_page }}{% endcapture -%}
@ -59,7 +67,4 @@
</div>
</div>
{% endif %}
<form method="post" id="deleteForm">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
</form>
</article>

View File

@ -1,28 +1,47 @@
<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>
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col" style="width:300px;">Title</th>
<th scope="col">Author</th>
<th scope="col">Status</th>
<th scope="col">Tags</th>
</tr>
</thead>
<tbody>
<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>
</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 -%}
<tr>
<td class="no-wrap">
{% if post.published_on %}{{ post.published_on | date: "MMMM d, yyyy" }}{% else %}Not Published{% endif %}
<div class="row mwl-table-detail">
<div class="{{ date_col }} no-wrap">
<small class="d-md-none">
{%- if post.published_on -%}
Published {{ post.published_on | date: "MMMM d, yyyy" }}
{%- else -%}
Not Published
{%- endif -%}
{%- if post.published_on != post.updated_on -%}
<em class="text-muted"> (Updated {{ post.updated_on | date: "MMMM d, yyyy" }})</em>
{%- endif %}
</small>
<span class="d-none d-md-inline">
{%- if post.published_on -%}
{{ post.published_on | date: "MMMM d, yyyy" }}
{%- else -%}
Not Published
{%- endif -%}
{%- if post.published_on != post.updated_on %}<br>
<small class="text-muted"><em>{{ post.updated_on | date: "MMMM d, yyyy" }}</em></small>
{%- endif %}
</td>
<td>
</span>
</div>
<div class="{{ title_col }}">
{{ post.title }}<br>
<small>
<a href="{{ post | relative_link }}" target="_blank">View Post</a>
@ -31,24 +50,35 @@
<span class="text-muted"> &bull; </span>
{%- capture post_del %}admin/post/{{ post.id }}/delete{% endcapture -%}
{%- capture post_del_link %}{{ post_del | relative_link }}{% endcapture -%}
<a href="{{ post_del_link }}" class="text-danger"
onclick="return Admin.deletePost('{{ post.title }}', '{{ post_del_link }}')">
<a href="{{ post_del_link }}" hx-post="{{ post_del_link }}" class="text-danger"
hx-confirm="Are you sure you want to delete the page &ldquo;{{ post.title | strip_html | escape }}&rdquo;? This action cannot be undone.">
Delete
</a>
</small>
</td>
<td class="no-wrap">{{ model.authors | value: post.author_id }}</td>
<td>{{ post.status }}</td>
<td><span class="no-wrap">{{ post.tags | join: "</span>, <span class='no-wrap'>" }}</span></td>
</tr>
</div>
<div class="{{ author_col }}">
{%- assign tag_count = post.tags | size -%}
<small class="d-md-none">
Authored by {{ model.authors | value: post.author_id }} |
{% if tag_count == 0 -%}
No
{%- else -%}
{{ tag_count }}
{%- endif %} Tag{% unless tag_count == 1 %}s{% endunless %}
</small>
<span class="d-none d-md-inline">{{ model.authors | value: post.author_id }}</span>
</div>
<div class="{{ tag_col }}">
<span class="no-wrap">{{ post.tags | join: "</span>, <span class='no-wrap'>" }}</span>
</div>
</div>
{%- endfor %}
{% else %}
<tr>
<td colspan="5" class="text-muted fst-italic text-center">This web log has no posts</td>
</tr>
<div class="row">
<div class="col text-muted fst-italic text-center">This web log has no posts</div>
</div>
{% endif %}
</tbody>
</table>
</form>
{% if model.newer_link or model.older_link %}
<div class="d-flex justify-content-evenly">
<div>
@ -63,7 +93,4 @@
</div>
</div>
{% endif %}
<form method="post" id="deleteForm">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
</form>
</article>

View File

@ -67,21 +67,23 @@
<a class="btn btn-sm btn-secondary" href="{{ 'admin/settings/rss/new/edit' | relative_link }}">
Add a New Custom Feed
</a>
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Relative Path</th>
<th scope="col">Podcast?</th>
</tr>
</thead>
<tbody>
<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 %}
<tr>
<td>
{{ feed.source }}<br>
<div class="row mwl-table-detail">
<div class="{{ source_col }}">
{{ feed.source }}
{%- if feed.is_podcast %} &nbsp; <span class="badge bg-primary">PODCAST</span>{% endif %}<br>
<small>
<a href="{{ feed.path | relative_link }}" target="_blank">View Feed</a>
<span class="text-muted"> &bull; </span>
@ -90,24 +92,22 @@
<span class="text-muted"> &bull; </span>
{%- capture feed_del %}admin/settings/rss/{{ feed.id }}/delete{% endcapture -%}
{%- capture feed_del_link %}{{ feed_del | relative_link }}{% endcapture -%}
<a href="{{ feed_del_link }}" class="text-danger"
onclick="return Admin.deleteCustomFeed('{{ feed.source }}', '{{ feed_del_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>
</td>
<td>{{ feed.path }}</td>
<td>{% if feed.is_podcast %}Yes{% else %}No{% endif %}</td>
</tr>
</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 %}
</tbody>
</table>
<form method="post" id="deleteForm">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
</form>
</article>

View File

@ -1,23 +1,17 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<form action="{{ "admin/settings/tag-mapping/save" | relative_link }}" method="post">
<h5 class="my-3">{{ page_title }}</h5>
<form hx-post="{{ "admin/settings/tag-mapping/save" | relative_link }}" method="post" class="container"
hx-target="#tagList" 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="container">
<div class="row mb-3">
<div class="col">
<a href="{{ "admin/settings/tag-mappings" | relative_link }}">&laquo; Back to Tag Mappings</a>
</div>
</div>
<div class="row mb-3">
<div class="col-12 col-md-6 col-lg-4 offset-lg-2 pb-3">
<div class="col-6 col-lg-4 offset-lg-2">
<div class="form-floating">
<input type="text" name="tag" id="tag" class="form-control" placeholder="Tag" autofocus required
value="{{ model.tag }}">
<label for="tag">Tag</label>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4 pb-3">
<div class="col-6 col-lg-4">
<div class="form-floating">
<input type="text" name="urlValue" id="urlValue" class="form-control" placeholder="URL Value" required
value="{{ model.url_value }}">
@ -27,9 +21,10 @@
</div>
<div class="row mb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">Save Changes</button>
<button type="submit" class="btn btn-sm btn-primary">Save Changes</button>
<a href="{{ "admin/settings/tag-mappings/bare" | relative_link }}" class="btn btn-sm btn-secondary ms-3">
Cancel
</a>
</div>
</div>
</div>
</form>
</article>
</form>

View File

@ -0,0 +1,34 @@
<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>
{%- capture map_edit %}admin/settings/tag-mapping/{{ map_id }}/edit{% endcapture -%}
<a href="{{ map_edit | relative_link }}" hx-target="#tag_{{ map_id }}"
hx-swap="innerHTML show:#tag_{{ map_id }}:top">
Edit
</a>
<span class="text-muted"> &bull; </span>
{%- capture map_del %}admin/settings/tag-mapping/{{ map_id }}/delete{% endcapture -%}
{%- capture map_del_link %}{{ map_del | relative_link }}{% endcapture -%}
<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 &ldquo;{{ map.tag }}&rdquo;? This action cannot be undone.">
Delete
</a>
</small>
</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>
</div>
{%- endif %}
</form>

View File

@ -1,39 +1,14 @@
<h2 class="my-3">{{ page_title }}</h2>
<article class="container">
<a href="{{ "admin/settings/tag-mapping/new/edit" | relative_link }}" class="btn btn-primary btn-sm mb-3">
<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>
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Tag</th>
<th scope="col">URL Value</th>
</tr>
</thead>
<tbody>
{% for map in mappings -%}
{%- assign map_id = mapping_ids | value: map.tag -%}
<tr>
<td class="no-wrap">
{{ map.tag }}<br>
<small>
{%- capture map_edit %}admin/settings/tag-mapping/{{ map_id }}/edit{% endcapture -%}
<a href="{{ map_edit | relative_link }}">Edit</a>
<span class="text-muted"> &bull; </span>
{%- capture map_del %}admin/settings/tag-mapping/{{ map_id }}/delete{% endcapture -%}
{%- capture map_del_link %}{{ map_del | relative_link }}{% endcapture -%}
<a href="{{ map_del_link }}" class="text-danger"
onclick="return Admin.deleteTagMapping('{{ map.tag }}', '{{ map_del_link }}')">
Delete
</a>
</small>
</td>
<td>{{ map.url_value }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
<form method="post" id="deleteForm">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
</form>
<div class="container">
<div class="row mwl-table-heading">
<div class="col">Tag</div>
<div class="col">URL Value</div>
</div>
</div>
{{ tag_mapping_list }}
</article>

View File

@ -1,5 +1,6 @@
:root {
--dark-gray: #212529;
--light-accent: rgba(0, 0, 0, .075);
}
html {
background-color: var(--dark-gray);
@ -72,3 +73,15 @@ a.text-danger:link:hover, a.text-danger:visited:hover {
border-radius: 0.25rem;
color: white !important;
}
.mwl-table-heading {
font-size: 1.1rem;
font-weight: bold;
border-bottom: solid 1px var(--bs-dark);
}
.mwl-table-detail {
border-bottom: solid 1px var(--light-accent);
}
.mwl-table-detail:hover {
background-color: var(--light-accent);
color: var(--dark-gray);
}

View File

@ -181,61 +181,51 @@
},
/**
* Confirm and delete an item
* @param name The name of the item to be deleted
* @param url The URL to which the form should be posted
* Show messages that may have come with an htmx response
* @param messages The messages from the response
*/
deleteItem(name, url) {
if (confirm(`Are you sure you want to delete the ${name}? This action cannot be undone.`)) {
const form = document.getElementById("deleteForm")
form.action = url
form.submit()
showMessage(messages) {
const msgs = messages.split(", ")
msgs.forEach(msg => {
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]
const closeBtn = document.createElement("button")
closeBtn.type = "button"
closeBtn.className = "btn-close"
closeBtn.setAttribute("data-bs-dismiss", "alert")
closeBtn.setAttribute("aria-label", "Close")
msgDiv.appendChild(closeBtn)
if (parts.length === 3) {
msgDiv.innerHTML += `<hr>${parts[2]}`
}
return false
document.getElementById("msgContainer").appendChild(msgDiv)
})
},
/**
* Confirm and delete a category
* @param name The name of the category to be deleted
* @param url The URL to which the form should be posted
* Set all "success" alerts to close after 4 seconds
*/
deleteCategory(name, url) {
return this.deleteItem(`category "${name}"`, url)
},
/**
* Confirm and delete a custom RSS feed
* @param source The source for the feed to be deleted
* @param url The URL to which the form should be posted
*/
deleteCustomFeed(source, url) {
return this.deleteItem(`custom RSS feed based on ${source}`, url)
},
/**
* Confirm and delete a page
* @param title The title of the page to be deleted
* @param url The URL to which the form should be posted
*/
deletePage(title, url) {
return this.deleteItem(`page "${title}"`, url)
},
/**
* Confirm and delete a post
* @param title The title of the post to be deleted
* @param url The URL to which the form should be posted
*/
deletePost(title, url) {
return this.deleteItem(`post "${title}"`, url)
},
/**
* Confirm and delete a tag mapping
* @param tag The tag for which the mapping will be deleted
* @param url The URL to which the form should be posted
*/
deleteTagMapping(tag, url) {
return this.deleteItem(`mapping for "${tag}"`, url)
dismissSuccesses() {
[...document.querySelectorAll(".alert-success")].forEach(alert => {
setTimeout(() => {
(bootstrap.Alert.getInstance(alert) ?? new bootstrap.Alert(alert)).close()
}, 4000)
})
}
}
htmx.on("htmx:afterOnLoad", function (evt) {
const hdrs = evt.detail.xhr.getAllResponseHeaders()
// 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()
}
})