Add post list and category manipulation

This commit is contained in:
2022-04-23 17:53:40 -04:00
parent e8b903b33b
commit a58cc25bbb
14 changed files with 603 additions and 71 deletions

View File

@@ -262,6 +262,77 @@ module Admin =
}
/// Handlers to manipulate categories
module Category =
// GET /categories
let all : HttpHandler = requireUser >=> fun next ctx -> task {
let! cats = Data.Category.findAllForView (webLogId ctx) (conn ctx)
return!
Hash.FromAnonymousObject {| categories = cats; page_title = "Categories"; csrf = csrfToken ctx |}
|> viewForTheme "admin" "category-list" next ctx
}
// GET /category/{id}/edit
let edit catId : HttpHandler = requireUser >=> fun next ctx -> task {
let webLogId = webLogId ctx
let conn = conn ctx
let! result = task {
match catId with
| "new" -> return Some ("Add a New Category", { Category.empty with id = CategoryId "new" })
| _ ->
match! Data.Category.findById (CategoryId catId) webLogId conn with
| Some cat -> return Some ("Edit Category", cat)
| None -> return None
}
let! allCats = Data.Category.findAllForView webLogId conn
match result with
| Some (title, cat) ->
return!
Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditCategoryModel.fromCategory cat
page_title = title
categories = allCats
|}
|> viewForTheme "admin" "category-edit" next ctx
| None -> return! Error.notFound next ctx
}
// POST /category/save
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditCategoryModel> ()
let webLogId = webLogId ctx
let conn = conn ctx
let! category = task {
match model.categoryId with
| "new" -> return Some { Category.empty with id = CategoryId.create (); webLogId = webLogId }
| catId -> return! Data.Category.findById (CategoryId catId) webLogId conn
}
match category with
| Some cat ->
let cat =
{ cat with
name = model.name
slug = model.slug
description = match model.description with "" -> None | it -> Some it
parentId = match model.parentId with "" -> None | it -> Some (CategoryId it)
}
do! (match model.categoryId with "new" -> Data.Category.add | _ -> Data.Category.update) cat conn
do! addMessage ctx { UserMessage.success with message = "Category saved successfully" }
return! redirectToGet $"/category/{CategoryId.toString cat.id}/edit" next ctx
| None -> return! Error.notFound next ctx
}
// POST /category/{id}/delete
let delete catId : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
match! Data.Category.delete (CategoryId catId) (webLogId ctx) (conn ctx) with
| true -> 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 "/categories" next ctx
}
/// Handlers to manipulate pages
module Page =
@@ -301,7 +372,7 @@ module Page =
| None -> return! Error.notFound next ctx
}
// POST /page/{id}/edit
// POST /page/save
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditPageModel> ()
let webLogId = webLogId ctx
@@ -408,6 +479,44 @@ module Post =
return! Error.notFound next ctx
}
// GET /posts
// GET /posts/page/{pageNbr}
let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task {
let webLog = WebLogCache.get ctx
let conn = conn ctx
let! posts = Data.Post.findPageOfPosts webLog.id pageNbr 25 conn
let! authors =
Data.WebLogUser.findNames (posts |> List.map (fun p -> p.authorId) |> List.distinct) webLog.id conn
let! cats =
Data.Category.findNames (posts |> List.map (fun c -> c.categoryIds) |> List.concat |> List.distinct)
webLog.id conn
let tags = posts
|> List.map (fun p -> PostId.toString p.id, p.tags |> List.fold (fun t tag -> $"{t}, {tag}") "")
|> dict
let model =
{ posts = posts |> Seq.ofList |> Seq.truncate 25 |> Seq.map PostListItem.fromPost |> Array.ofSeq
authors = authors
categories = cats
hasNewer = pageNbr <> 1
hasOlder = posts |> List.length > webLog.postsPerPage
}
return!
Hash.FromAnonymousObject {| model = model; tags = tags; page_title = "Posts" |}
|> viewForTheme "admin" "post-list" next ctx
}
// GET /post/{id}/edit
let edit _ : HttpHandler = requireUser >=> fun next ctx -> task {
// TODO: write handler
return! Error.notFound next ctx
}
// POST /post/{id}/edit
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
// TODO: write handler
return! Error.notFound next ctx
}
/// Handlers to manipulate users
module User =
@@ -482,6 +591,16 @@ let endpoints = [
route "/settings" Admin.saveSettings
]
]
subRoute "/categor" [
GET [
route "ies" Category.all
routef "y/%s/edit" Category.edit
]
POST [
route "y/save" Category.save
routef "y/%s/delete" Category.delete
]
]
subRoute "/page" [
GET [
routef "/%d" Post.pageOfPosts
@@ -493,6 +612,16 @@ let endpoints = [
route "/save" Page.save
]
]
subRoute "/post" [
GET [
routef "/%s/edit" Post.edit
route "s" (Post.all 1)
routef "s/page/%d" Post.all
]
POST [
route "/save" Post.save
]
]
subRoute "/user" [
GET [
route "/log-on" User.logOn

View File

@@ -199,9 +199,13 @@ let main args =
Template.RegisterSafeType (typeof<WebLog>, all)
Template.RegisterSafeType (typeof<DashboardModel>, all)
Template.RegisterSafeType (typeof<DisplayCategory>, all)
Template.RegisterSafeType (typeof<DisplayPage>, all)
Template.RegisterSafeType (typeof<SettingsModel>, all)
Template.RegisterSafeType (typeof<EditCategoryModel>, all)
Template.RegisterSafeType (typeof<EditPageModel>, all)
Template.RegisterSafeType (typeof<PostDisplay>, all)
Template.RegisterSafeType (typeof<PostListItem>, all)
Template.RegisterSafeType (typeof<SettingsModel>, all)
Template.RegisterSafeType (typeof<UserMessage>, all)
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)

View File

@@ -0,0 +1,56 @@
<h2 class="my-3">{{ page_title }}</h2>
<article>
<form action="/category/save" method="post">
<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="form-floating">
<input type="text" name="name" id="name" class="form-control" placeholder="Name" autofocus required
value="{{ model.name }}">
<label for="name">Name</label>
</div>
</div>
<div class="col-6 col-lg-4 pb-3">
<div class="form-floating">
<input type="text" name="slug" id="slug" class="form-control" placeholder="Slug" required
value="{{ model.slug }}">
<label for="slug">Slug</label>
</div>
</div>
<div class="col-12 col-lg-4 pb-3">
<div class="form-floating">
<select name="parentId" id="parentId" class="form-control">
<option value=""{% if model.parent_id == "" %} selected="selected"{% endif %}>
&ndash; None &ndash;
</option>
{% for cat in categories -%}
{%- unless cat.id == model.category_id %}
<option value="{{ cat.id }}"{% if model.parent_id == cat.id %} selected="selected"{% endif %}>
{% for it in cat.parent_names %} &nbsp; &raquo; {% endfor %}{{ cat.name }}
</option>
{% endunless -%}
{%- endfor %}
</select>
<label for="parentId">Parent Category</label>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col">
<div class="form-floating">
<input name="description" id="description" class="form-control"
placeholder="A short description of this category" value="{{ model.description }}">
<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>
</div>
</form>
</article>

View File

@@ -0,0 +1,39 @@
<h2 class="my-3">{{ page_title }}</h2>
<article class="container">
<a href="/category/new/edit" 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">Actions</th>
<th scope="col">Category</th>
<th scope="col">Description</th>
</tr>
</thead>
<tbody>
{% for cat in categories -%}
<tr>
<td class="action-button-column">
<a class="btn btn-secondary btn-sm" href="/category/{{ cat.id }}/edit">Edit</a>
<a class="btn btn-danger btn-sm" href="/category/{{ cat.id }}/delete"
onclick="return Admin.deleteCategory('{{ cat.id }}', '{{ cat.name }}')">
Delete
</a>
</td>
<td>
{%- if cat.parent_names %}
<small class="text-muted">{% for name in cat.parent_names %}{{ name }} &rang; {% endfor %}</small>
{% endif -%}
{{ cat.name }} &nbsp;
<small><a href="/posts/category/{{ cat.slug }}" target="_blank">View Posts</a></small>
</td>
<td>
{%- if cat.description %}{{ cat.description.value }}{% else %}<em class="text-muted">none</em>{% endif -%}
</td>
</tr>
{%- endfor %}
</tbody>
</table>
<form method="post" id="deleteForm">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
</form>
</article>

View File

@@ -9,7 +9,7 @@
Published <span class="badge rounded-pill bg-secondary">{{ model.posts }}</span>
&nbsp; Drafts <span class="badge rounded-pill bg-secondary">{{ model.drafts }}</span>
</h6>
<a href="/posts/list" class="btn btn-secondary me-2">View All</a>
<a href="/posts" class="btn btn-secondary me-2">View All</a>
<a href="/post/new/edit" class="btn btn-primary">Write a New Post</a>
</div>
</div>
@@ -37,7 +37,7 @@
All <span class="badge rounded-pill bg-secondary">{{ model.categories }}</span>
&nbsp; Top Level <span class="badge rounded-pill bg-secondary">{{ model.top_level_categories }}</span>
</h6>
<a href="/categories/list" class="btn btn-secondary me-2">View All</a>
<a href="/categories" class="btn btn-secondary me-2">View All</a>
<a href="/category/new/edit" class="btn btn-secondary">Add a New Category</a>
</div>
</div>

View File

@@ -8,60 +8,61 @@
<link rel="stylesheet" href="/themes/admin/admin.css">
</head>
<body>
<header>
<nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2">
<div class="container-fluid">
<a class="navbar-brand" href="/">{{ 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" | nav_link: "Dashboard" }}
{{ "pages" | nav_link: "Pages" }}
{{ "posts" | nav_link: "Posts" }}
{{ "categories" | nav_link: "Categories" }}
</ul>
{%- endif %}
<ul class="navbar-nav flex-grow-1 justify-content-end">
<header>
<nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2">
<div class="container-fluid">
<a class="navbar-brand" href="/">{{ 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 -%}
{{ "user/log-off" | nav_link: "Log Off" }}
{%- else -%}
{{ "user/log-on" | nav_link: "Log On" }}
<ul class="navbar-nav">
{{ "admin" | nav_link: "Dashboard" }}
{{ "pages" | nav_link: "Pages" }}
{{ "posts" | nav_link: "Posts" }}
{{ "categories" | nav_link: "Categories" }}
</ul>
{%- endif %}
</ul>
<ul class="navbar-nav flex-grow-1 justify-content-end">
{% if logged_on -%}
{{ "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>
<div class="container-fluid">
<div class="row">
<div class="col-xs-12 text-end"><img src="/img/logo-light.png" alt="myWebLog"></div>
</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>
<div class="container-fluid">
<div class="row">
<div class="col-xs-12 text-end"><img src="/img/logo-light.png" alt="myWebLog"></div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"></script>
</footer>
<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>
<script src="/themes/admin/admin.js"></script>
</body>
</html>

View File

@@ -48,7 +48,7 @@
</div>
<div class="row mb-3">
<div class="col">
<textarea name="Text" id="text" class="form-control" rows="10">{{ model.text }}</textarea>
<textarea name="text" id="text" class="form-control" rows="10">{{ model.text }}</textarea>
</div>
</div>
<div class="row mb-3">

View File

@@ -0,0 +1,41 @@
<h2 class="my-3">{{ page_title }}</h2>
<article class="container">
<a href="/post/new/edit" 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">Title</th>
<th scope="col">Author</th>
<th scope="col">Status</th>
<th scope="col">Tags</th>
</tr>
</thead>
<tbody>
{% for post in model.posts -%}
<tr>
<td>
{% if post.published_on.has_value -%}
{{ post.published_on | date: "MMMM d, yyyy" }}
{%- else -%}
{{ post.updated_on | date: "MMMM d, yyyy" }}
{%- endif %}
</td>
<td>
{{ post.title }}<br>
<small>
<a href="/{{ post.permalink }}" target="_blank">View Post</a>
<span class="text-muted"> &bull; </span>
<a href="/post/{{ post.id }}/edit">Edit</a>
<span class="text-muted"> &bull; </span>
<a href="#" class="text-danger">Delete</a>
</small>
</td>
<td>{{ model.authors[post.author_id] }}</td>
<td>{{ post.status }}</td>
<td>{{ tags[post.id] }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
</article>

View File

@@ -7,3 +7,7 @@
max-width: 60rem;
margin: auto;
}
.action-button-column {
width: 1rem;
white-space: nowrap;
}

View File

@@ -0,0 +1,15 @@
const Admin = {
/**
* Confirm and delete a category
* @param id The ID of the category to be deleted
* @param name The name of the category to be deleted
*/
deleteCategory(id, name) {
if (confirm(`Are you sure you want to delete the category "${name}"? This action cannot be undone.`)) {
const form = document.getElementById("deleteForm")
form.action = `/category/${id}/delete`
form.submit()
}
return false
}
}