First cut of admin dashboard

Add web log details to common model
This commit is contained in:
Daniel J. Summers 2022-02-27 20:01:53 -05:00
parent 39e0d5ec8b
commit 8f94d7ddfe
21 changed files with 390 additions and 41 deletions

View File

@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
namespace MyWebLog.Data;
public static class CategoryEtensions
{
/// <summary>
/// Count all categories
/// </summary>
/// <returns>A count of all categories</returns>
public static async Task<int> CountAll(this DbSet<Category> db) =>
await db.CountAsync().ConfigureAwait(false);
}

View File

@ -4,11 +4,26 @@ namespace MyWebLog.Data;
public static class PageExtensions public static class PageExtensions
{ {
/// <summary>
/// Count the number of pages
/// </summary>
/// <returns>The number of pages</returns>
public static async Task<int> CountAll(this DbSet<Page> db) =>
await db.CountAsync().ConfigureAwait(false);
/// <summary> /// <summary>
/// Retrieve a page by its ID (non-tracked) /// Retrieve a page by its ID (non-tracked)
/// </summary> /// </summary>
/// <param name="id">The ID of the page to retrieve</param> /// <param name="id">The ID of the page to retrieve</param>
/// <returns>The requested page (or null if it is not found)</returns> /// <returns>The requested page (or null if it is not found)</returns>
public static async Task<Page?> FindById(this DbSet<Page> db, string id) => public static async Task<Page?> FindById(this DbSet<Page> db, string id) =>
await db.FirstOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); await db.SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false);
/// <summary>
/// Retrieve a page by its permalink (non-tracked)
/// </summary>
/// <param name="permalink">The permalink</param>
/// <returns>The requested page (or null if it is not found)</returns>
public static async Task<Page?> FindByPermalink(this DbSet<Page> db, string permalink) =>
await db.SingleOrDefaultAsync(p => p.Permalink == permalink).ConfigureAwait(false);
} }

View File

@ -4,6 +4,22 @@ namespace MyWebLog.Data;
public static class PostExtensions public static class PostExtensions
{ {
/// <summary>
/// Count the posts in the given status
/// </summary>
/// <param name="status">The status for which posts should be counted</param>
/// <returns>A count of the posts in the given status</returns>
public static async Task<int> CountByStatus(this DbSet<Post> db, PostStatus status) =>
await db.CountAsync(p => p.Status == status).ConfigureAwait(false);
/// <summary>
/// Retrieve a post by its permalink (non-tracked)
/// </summary>
/// <param name="permalink">The possible post permalink</param>
/// <returns>The post matching the permalink, or null if none is found</returns>
public static async Task<Post?> FindByPermalink(this DbSet<Post> db, string permalink) =>
await db.SingleOrDefaultAsync(p => p.Id == permalink).ConfigureAwait(false);
/// <summary> /// <summary>
/// Retrieve a page of published posts (non-tracked) /// Retrieve a page of published posts (non-tracked)
/// </summary> /// </summary>

View File

@ -12,8 +12,12 @@ public class AdminController : MyWebLogController
public AdminController(WebLogDbContext db) : base(db) { } public AdminController(WebLogDbContext db) : base(db) { }
[HttpGet("")] [HttpGet("")]
public IActionResult Index() public async Task<IActionResult> Index() =>
View(new DashboardModel(WebLog)
{ {
return View(); Posts = await Db.Posts.CountByStatus(PostStatus.Published),
} Drafts = await Db.Posts.CountByStatus(PostStatus.Draft),
Pages = await Db.Pages.CountAll(),
Categories = await Db.Categories.CountAll()
});
} }

View File

@ -0,0 +1,30 @@
namespace MyWebLog.Features.Admin;
/// <summary>
/// The model used to display the dashboard
/// </summary>
public class DashboardModel : MyWebLogModel
{
/// <summary>
/// The number of published posts
/// </summary>
public int Posts { get; set; } = 0;
/// <summary>
/// The number of post drafts
/// </summary>
public int Drafts { get; set; } = 0;
/// <summary>
/// The number of pages
/// </summary>
public int Pages { get; set; } = 0;
/// <summary>
/// The number of categories
/// </summary>
public int Categories { get; set; } = 0;
/// <inheritdoc />
public DashboardModel(WebLogDetails webLog) : base(webLog) { }
}

View File

@ -1,5 +1,35 @@
@{ @model DashboardModel
@{
Layout = "_AdminLayout"; Layout = "_AdminLayout";
ViewBag.Title = Resources.Dashboard; ViewBag.Title = Resources.Dashboard;
} }
<p>You're logged on!</p> <article class="container">
<div class="row">
<section class="col col-sm-4">
<h3>@Resources.Posts</h3>
<p>@string.Format(Resources.ThereAreXPublishedPostsAndYDrafts, Model.Posts, Model.Drafts)</p>
<p>
<a asp-action="All" asp-controller="Post" class="btn btn-secondary me-2">View All</a>
<a asp-action="Edit" asp-controller="Post" asp-route-id="new" class="btn btn-primary">Write a New Post</a>
</p>
</section>
<section class="col col-sm-4">
<h3>@Resources.Pages</h3>
<p>@string.Format(Resources.ThereAreXPages, Model.Pages)</p>
<p>
<a asp-action="All" asp-controller="Page" class="btn btn-secondary me-2">View All</a>
<a asp-action="Edit" asp-controller="Page" asp-route-id="new" class="btn btn-primary">Create a New Page</a>
</p>
</section>
<section class="col col-sm-4">
<h3>@Resources.Categories</h3>
<p>@string.Format(Resources.ThereAreXCategories, Model.Categories)</p>
<p>
<a asp-action="All" asp-controller="Category" class="btn btn-secondary me-2">View All</a>
<a asp-action="Edit" asp-controller="Category" asp-route-id="new" class="btn btn-secondary">
Add a New Category
</a>
</p>
</section>
</div>
</article>

View File

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MyWebLog.Features.Categories;
/// <summary>
/// Handle routes for categories
/// </summary>
[Route("/category")]
[Authorize]
public class CategoryController : MyWebLogController
{
/// <inheritdoc />
public CategoryController(WebLogDbContext db) : base(db) { }
[HttpGet("all")]
public async Task<IActionResult> All()
{
await Task.CompletedTask;
throw new NotImplementedException();
}
[HttpGet("{id}/edit")]
public async Task<IActionResult> Edit(string id)
{
await Task.CompletedTask;
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MyWebLog.Features.Pages;
/// <summary>
/// Handle routes for pages
/// </summary>
[Route("/page")]
[Authorize]
public class PageController : MyWebLogController
{
/// <inheritdoc />
public PageController(WebLogDbContext db) : base(db) { }
[HttpGet("all")]
public async Task<IActionResult> All()
{
await Task.CompletedTask;
throw new NotImplementedException();
}
[HttpGet("{id}/edit")]
public async Task<IActionResult> Edit(string id)
{
await Task.CompletedTask;
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,22 @@
namespace MyWebLog.Features.Pages;
/// <summary>
/// The model used to render a single page
/// </summary>
public class SinglePageModel : MyWebLogModel
{
/// <summary>
/// The page to be rendered
/// </summary>
public Page Page { get; init; }
/// <summary>
/// Constructor
/// </summary>
/// <param name="page">The page to be rendered</param>
/// <param name="webLog">The details for the web log</param>
public SinglePageModel(Page page, WebLogDetails webLog) : base(webLog)
{
Page = page;
}
}

View File

@ -0,0 +1,22 @@
namespace MyWebLog.Features.Posts;
/// <summary>
/// The model used to render multiple posts
/// </summary>
public class MultiplePostModel : MyWebLogModel
{
/// <summary>
/// The posts to be rendered
/// </summary>
public IEnumerable<Post> Posts { get; init; }
/// <summary>
/// Constructor
/// </summary>
/// <param name="posts">The posts to be rendered</param>
/// <param name="webLog">The details for the web log</param>
public MultiplePostModel(IEnumerable<Post> posts, WebLogDetails webLog) : base(webLog)
{
Posts = posts;
}
}

View File

@ -1,25 +1,68 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MyWebLog.Features.Pages;
namespace MyWebLog.Features.Posts; namespace MyWebLog.Features.Posts;
/// <summary> /// <summary>
/// Handle post-related requests /// Handle post-related requests
/// </summary> /// </summary>
[Route("/post")]
[Authorize]
public class PostController : MyWebLogController public class PostController : MyWebLogController
{ {
/// <inheritdoc /> /// <inheritdoc />
public PostController(WebLogDbContext db) : base(db) { } public PostController(WebLogDbContext db) : base(db) { }
[HttpGet("~/")] [HttpGet("~/")]
[AllowAnonymous]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var webLog = WebLogCache.Get(HttpContext); if (WebLog.DefaultPage == "posts") return await PageOfPosts(1);
if (webLog.DefaultPage == "posts")
var page = await Db.Pages.FindById(WebLog.DefaultPage);
return page is null ? NotFound() : ThemedView("SinglePage", new SinglePageModel(page, WebLog));
}
[HttpGet("~/page/{pageNbr:int}")]
[AllowAnonymous]
public async Task<IActionResult> PageOfPosts(int pageNbr) =>
ThemedView("Index",
new MultiplePostModel(await Db.Posts.FindPageOfPublishedPosts(pageNbr, WebLog.PostsPerPage), WebLog));
[HttpGet("~/{*permalink}")]
public async Task<IActionResult> CatchAll(string permalink)
{ {
var posts = await Db.Posts.FindPageOfPublishedPosts(1, webLog.PostsPerPage); Console.Write($"Got permalink |{permalink}|");
return ThemedView("Index", posts); var post = await Db.Posts.FindByPermalink(permalink);
if (post != null)
{
// TODO: return via single-post action
} }
var page = await Db.Pages.FindById(webLog.DefaultPage);
return page is null ? NotFound() : ThemedView("SinglePage", page); var page = await Db.Pages.FindByPermalink(permalink);
if (page != null)
{
return ThemedView("SinglePage", new SinglePageModel(page, WebLog));
}
// TOOD: search prior permalinks for posts and pages
await Task.CompletedTask;
throw new NotImplementedException();
}
[HttpGet("all")]
public async Task<IActionResult> All()
{
await Task.CompletedTask;
throw new NotImplementedException();
}
[HttpGet("{id}/edit")]
public async Task<IActionResult> Edit(string id)
{
await Task.CompletedTask;
throw new NotImplementedException();
} }
} }

View File

@ -9,11 +9,16 @@ public abstract class MyWebLogController : Controller
/// </summary> /// </summary>
protected WebLogDbContext Db { get; init; } protected WebLogDbContext Db { get; init; }
/// <summary>
/// The details for the current web log
/// </summary>
protected WebLogDetails WebLog => WebLogCache.Get(HttpContext);
/// <summary> /// <summary>
/// Constructor /// Constructor
/// </summary> /// </summary>
/// <param name="db">The data context to use to fulfil this request</param> /// <param name="db">The data context to use to fulfil this request</param>
protected MyWebLogController(WebLogDbContext db) protected MyWebLogController(WebLogDbContext db) : base()
{ {
Db = db; Db = db;
} }

View File

@ -0,0 +1,21 @@
namespace MyWebLog.Features.Shared;
/// <summary>
/// Base model class for myWebLog views
/// </summary>
public class MyWebLogModel
{
/// <summary>
/// The details for the web log
/// </summary>
public WebLogDetails WebLog { get; init; }
/// <summary>
/// Constructor
/// </summary>
/// <param name="webLog">The details for the web log</param>
protected MyWebLogModel(WebLogDetails webLog)
{
WebLog = webLog;
}
}

View File

@ -1,12 +1,9 @@
@inject IHttpContextAccessor ctxAcc @model MyWebLogModel
@{
var details = WebLogCache.Get(ctxAcc.HttpContext!);
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<title>@ViewBag.Title &laquo; @Resources.Admin &laquo; @details.Name</title> <title>@ViewBag.Title &laquo; @Resources.Admin &laquo; @Model.WebLog.Name</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" href="~/css/admin.css"> <link rel="stylesheet" href="~/css/admin.css">
@ -15,7 +12,7 @@
<header> <header>
<nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2"> <nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="~/">@details.Name</a> <a class="navbar-brand" href="~/">@Model.WebLog.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>

View File

@ -5,7 +5,7 @@ namespace MyWebLog.Features.Users;
/// <summary> /// <summary>
/// The model to use to allow a user to log on /// The model to use to allow a user to log on
/// </summary> /// </summary>
public class LogOnModel public class LogOnModel : MyWebLogModel
{ {
/// <summary> /// <summary>
/// The user's e-mail address /// The user's e-mail address
@ -21,4 +21,10 @@ public class LogOnModel
[Required(AllowEmptyStrings = false)] [Required(AllowEmptyStrings = false)]
[Display(ResourceType = typeof(Resources), Name = "Password")] [Display(ResourceType = typeof(Resources), Name = "Password")]
public string Password { get; set; } = ""; public string Password { get; set; } = "";
[Obsolete("Only used for model binding; use the WebLogDetails constructor")]
public LogOnModel() : base(new()) { }
/// <inheritdoc />
public LogOnModel(WebLogDetails webLog) : base(webLog) { }
} }

View File

@ -33,7 +33,7 @@ public class UserController : MyWebLogController
[HttpGet("log-on")] [HttpGet("log-on")]
public IActionResult LogOn() => public IActionResult LogOn() =>
View(new LogOnModel()); View(new LogOnModel(WebLog));
[HttpPost("log-on")] [HttpPost("log-on")]
public async Task<IActionResult> DoLogOn(LogOnModel model) public async Task<IActionResult> DoLogOn(LogOnModel model)

View File

@ -69,6 +69,15 @@ namespace MyWebLog.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Categories.
/// </summary>
public static string Categories {
get {
return ResourceManager.GetString("Categories", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Dashboard. /// Looks up a localized string similar to Dashboard.
/// </summary> /// </summary>
@ -105,6 +114,15 @@ namespace MyWebLog.Properties {
} }
} }
/// <summary>
/// Looks up a localized string similar to Pages.
/// </summary>
public static string Pages {
get {
return ResourceManager.GetString("Pages", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Password. /// Looks up a localized string similar to Password.
/// </summary> /// </summary>
@ -113,5 +131,41 @@ namespace MyWebLog.Properties {
return ResourceManager.GetString("Password", resourceCulture); return ResourceManager.GetString("Password", resourceCulture);
} }
} }
/// <summary>
/// Looks up a localized string similar to Posts.
/// </summary>
public static string Posts {
get {
return ResourceManager.GetString("Posts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are {0} categories.
/// </summary>
public static string ThereAreXCategories {
get {
return ResourceManager.GetString("ThereAreXCategories", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are {0} pages.
/// </summary>
public static string ThereAreXPages {
get {
return ResourceManager.GetString("ThereAreXPages", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are {0} published posts and {1} drafts.
/// </summary>
public static string ThereAreXPublishedPostsAndYDrafts {
get {
return ResourceManager.GetString("ThereAreXPublishedPostsAndYDrafts", resourceCulture);
}
}
} }
} }

View File

@ -120,6 +120,9 @@
<data name="Admin" xml:space="preserve"> <data name="Admin" xml:space="preserve">
<value>Admin</value> <value>Admin</value>
</data> </data>
<data name="Categories" xml:space="preserve">
<value>Categories</value>
</data>
<data name="Dashboard" xml:space="preserve"> <data name="Dashboard" xml:space="preserve">
<value>Dashboard</value> <value>Dashboard</value>
</data> </data>
@ -132,7 +135,22 @@
<data name="LogOn" xml:space="preserve"> <data name="LogOn" xml:space="preserve">
<value>Log On</value> <value>Log On</value>
</data> </data>
<data name="Pages" xml:space="preserve">
<value>Pages</value>
</data>
<data name="Password" xml:space="preserve"> <data name="Password" xml:space="preserve">
<value>Password</value> <value>Password</value>
</data> </data>
<data name="Posts" xml:space="preserve">
<value>Posts</value>
</data>
<data name="ThereAreXCategories" xml:space="preserve">
<value>There are {0} categories</value>
</data>
<data name="ThereAreXPages" xml:space="preserve">
<value>There are {0} pages</value>
</data>
<data name="ThereAreXPublishedPostsAndYDrafts" xml:space="preserve">
<value>There are {0} published posts and {1} drafts</value>
</data>
</root> </root>

View File

@ -1,19 +1,16 @@
@inject IHttpContextAccessor ctxAcc @model MyWebLogModel
@{
var details = WebLogCache.Get(ctxAcc.HttpContext!);
}
<header> <header>
<nav class="navbar navbar-light bg-light navbar-expand-md justify-content-start px-2"> <nav class="navbar navbar-light bg-light navbar-expand-md justify-content-start px-2">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="~/">@details.Name</a> <a class="navbar-brand" href="~/">@Model.WebLog.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>
</button> </button>
<div class="collapse navbar-collapse" id="navbarText"> <div class="collapse navbar-collapse" id="navbarText">
@if (details.Subtitle is not null) @if (Model.WebLog.Subtitle is not null)
{ {
<span class="navbar-text">@details.Subtitle</span> <span class="navbar-text">@Model.WebLog.Subtitle</span>
} }
@* TODO: list pages for current web log *@ @* TODO: list pages for current web log *@
@await Html.PartialAsync("_LogOnOffPartial") @await Html.PartialAsync("_LogOnOffPartial")

View File

@ -1,7 +1,4 @@
@inject IHttpContextAccessor ctxAcc @model MyWebLogModel
@{
var details = WebLogCache.Get(ctxAcc.HttpContext!);
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -9,9 +6,9 @@
<meta name="generator" content="myWebLog 2"> <meta name="generator" content="myWebLog 2">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous"> integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" href="~/css/@details.ThemePath/style.css"> <link rel="stylesheet" href="~/css/@Model.WebLog.ThemePath/style.css">
@await RenderSectionAsync("Style", false) @await RenderSectionAsync("Style", false)
<title>@ViewBag.Title &laquo; @details.Name</title> <title>@ViewBag.Title &laquo; @Model.WebLog.Name</title>
</head> </head>
<body> <body>
@if (IsSectionDefined("Header")) @if (IsSectionDefined("Header"))
@ -20,7 +17,7 @@
} }
else else
{ {
@await Html.PartialAsync("_DefaultHeader") @await Html.PartialAsync("_DefaultHeader", Model)
} }
<main> <main>
@RenderBody() @RenderBody()

View File

@ -1,9 +1,10 @@
@model Page @using MyWebLog.Features.Pages
@model SinglePageModel
@{ @{
Layout = "_Layout"; Layout = "_Layout";
ViewBag.Title = Model.Title; ViewBag.Title = Model.Page.Title;
} }
<h2>@Model.Title</h2> <h2>@Model.Page.Title</h2>
<article> <article>
@Html.Raw(Model.Text) @Html.Raw(Model.Page.Text)
</article> </article>