feat: add blog post editing

This commit is contained in:
Oliver Booth 2024-02-26 02:50:48 +00:00
parent aae7f504e9
commit 4b2223634e
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
15 changed files with 389 additions and 15 deletions

View File

@ -40,6 +40,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "admin", "admin", "{183CDB1F-371D-4A24-8F96-1DF0967995E4}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "admin", "admin", "{183CDB1F-371D-4A24-8F96-1DF0967995E4}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
src\ts\admin\admin.ts = src\ts\admin\admin.ts src\ts\admin\admin.ts = src\ts\admin\admin.ts
src\ts\admin\EditBlogPost.ts = src\ts\admin\EditBlogPost.ts
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{A6590915-CB40-43EA-B0A3-EDEC63769780}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "app", "app", "{A6590915-CB40-43EA-B0A3-EDEC63769780}"

View File

@ -0,0 +1,53 @@
using System.Text;
using System.Text.Json.Serialization;
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Controllers.Api.v1;
[ApiController]
[Route("api/v{version:apiVersion}/post")]
[ApiVersion(1)]
[Produces("application/json")]
public sealed class BlogPostController : ControllerBase
{
private readonly ILogger<BlogPostController> _logger;
private readonly ISessionService _sessionService;
private readonly IBlogPostService _blogPostService;
public BlogPostController(ILogger<BlogPostController> logger,
ISessionService sessionService,
IBlogPostService blogPostService)
{
_logger = logger;
_sessionService = sessionService;
_blogPostService = blogPostService;
}
[HttpPatch("{id:guid}")]
public async Task<IActionResult> OnPatch([FromRoute] Guid id)
{
if (!_sessionService.TryGetCurrentUser(Request, Response, out IUser? user))
{
Response.StatusCode = 401;
return new JsonResult(new { status = 401, message = "Unauthorized" });
}
if (!_blogPostService.TryGetPost(id, out IBlogPost? post))
{
Response.StatusCode = 404;
return new JsonResult(new { status = 404, message = "Not Found" });
}
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
string content = await reader.ReadToEndAsync();
post.Body = content;
_blogPostService.UpdatePost(post);
return new JsonResult(new { status = 200, message = "OK" });
}
}

View File

@ -11,7 +11,7 @@ internal sealed class BlogPost : IBlogPost
public IBlogAuthor Author { get; internal set; } = null!; public IBlogAuthor Author { get; internal set; } = null!;
/// <inheritdoc /> /// <inheritdoc />
public string Body { get; internal set; } = string.Empty; public string Body { get; set; } = string.Empty;
/// <inheritdoc /> /// <inheritdoc />
public bool EnableComments { get; internal set; } public bool EnableComments { get; internal set; }

View File

@ -12,10 +12,10 @@ public interface IBlogPost
IBlogAuthor Author { get; } IBlogAuthor Author { get; }
/// <summary> /// <summary>
/// Gets the body of the post. /// Gets or sets the body of the post.
/// </summary> /// </summary>
/// <value>The body of the post.</value> /// <value>The body of the post.</value>
string Body { get; } string Body { get; set; }
/// <summary> /// <summary>
/// Gets a value indicating whether comments are enabled for the post. /// Gets a value indicating whether comments are enabled for the post.

View File

@ -0,0 +1,86 @@
@page "/admin/blog-posts"
@using System.Diagnostics
@using OliverBooth.Data
@using OliverBooth.Data.Blog
@using OliverBooth.Data.Web
@using OliverBooth.Services
@model OliverBooth.Pages.Admin.BlogPosts
@inject IBlogPostService BlogPostService
@inject IUserService UserService
@inject ISessionService SessionService
@{
ViewData["Title"] = "Blog Posts";
Layout = "Shared/_AdminLayout";
HttpRequest request = HttpContext.Request;
SessionService.TryGetSession(request, out ISession? session);
IUser? user = null;
if (session is not null)
{
UserService.TryGetUser(session.UserId, out user);
}
Debug.Assert(user is not null);
}
<div class="row">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
<i class="fa-solid fa-globe fa-fw"></i>
Total Blog Posts
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
@BlogPostService.GetBlogPostCount()
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Posted</th>
<th>Author</th>
<th>Options</th>
</tr>
</thead>
<tbody>
@foreach (IBlogPost post in BlogPostService.GetAllBlogPosts(visibility: (BlogPostVisibility)(-1)))
{
if (post.Visibility != BlogPostVisibility.Published && post.Author.Id != user.Id && !user.HasPermission(Permission.Administrator))
{
continue;
}
string icon = post.Visibility switch
{
BlogPostVisibility.Private => "key text-danger",
BlogPostVisibility.Unlisted => "unlock text-warning",
BlogPostVisibility.Published => "circle-check text-success"
};
<tr data-post-id="@post.Id.ToString("N")">
<td><i class="fa-solid fa-fw fa-@icon" title="@post.Visibility"></i> @post.Title</td>
<td>@post.Published</td>
<td><img src="@post.Author.AvatarUrl" class="rounded-circle me-2"> @post.Author.DisplayName</td>
<td>
<a asp-page="EditBlogPost" asp-route-id="@post.Id" class="btn btn-info">
<i class="fa-solid fa-pen-to-square"></i>
</a>
<a asp-controller="BlogAdmin" asp-action="DeletePost" asp-route-pid="@post.Id" class="btn btn-danger">
<i class="fa-solid fa-trash"></i>
</a>
</td>
</tr>
}
</tbody>
</table>

View File

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Pages.Admin;
public class BlogPosts : PageModel
{
private readonly ISessionService _sessionService;
public BlogPosts(ISessionService sessionService)
{
_sessionService = sessionService;
}
public IUser CurrentUser { get; private set; } = null!;
public IActionResult OnGet()
{
if (!_sessionService.TryGetCurrentUser(Request, Response, out IUser? user))
{
return RedirectToPage("/admin/login");
}
CurrentUser = user;
return Page();
}
}

View File

@ -0,0 +1,27 @@
@page "/admin/blog-posts/edit/{id}"
@using Markdig
@using OliverBooth.Data.Blog
@model OliverBooth.Pages.Admin.EditBlogPost
@inject MarkdownPipeline MarkdownPipeline
@{
Layout = "Shared/_AdminLayout";
ViewData["Title"] = "Edit Post";
IBlogPost post = Model.BlogPost;
}
<input type="hidden" data-blog-pid="@post.Id">
<button id="save-button" class="btn btn-primary"><i class="fa-solid fa-floppy-disk fa-fw"></i> Save <span class="text-muted">(Ctrl+S)</span></button>
<a href="/blog/@post.Published.ToString(@"yyyy\/MM\/dd")/@post.Slug" target="_blank" class="btn btn-info"><i class="fa-solid fa-magnifying-glass"></i> Preview</a>
<div class="row" style="margin-top: 20px;">
<div class="col-md-6 col-sm-12">
<textarea id="content" style="width: 100%; font-family: monospace; min-height: calc(100vh - 80px); max-height: 100%">@post.Body</textarea>
</div>
<div class="col-md-6 col-sm-12" style="overflow-y: scroll; background: #1E1E1E">
<article id="article-preview" style="background: #333; max-width: 700px; margin: 20px auto; padding: 20px;">
@Html.Raw(Markdown.ToHtml(post.Body, MarkdownPipeline))
</article>
</div>
</div>

View File

@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Pages.Admin;
public class EditBlogPost : PageModel
{
private readonly IBlogPostService _blogPostService;
private readonly ISessionService _sessionService;
public EditBlogPost(IBlogPostService blogPostService, ISessionService sessionService)
{
_blogPostService = blogPostService;
_sessionService = sessionService;
}
public IUser CurrentUser { get; private set; } = null!;
public IBlogPost BlogPost { get; private set; } = null!;
public IActionResult OnGet([FromRoute(Name = "id")] Guid postId)
{
if (!_sessionService.TryGetCurrentUser(Request, Response, out IUser? user))
{
return RedirectToPage("/admin/login");
}
if (_blogPostService.TryGetPost(postId, out IBlogPost? post))
{
BlogPost = post;
}
CurrentUser = user;
return Page();
}
}

View File

@ -148,6 +148,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.8/handlebars.min.js" integrity="sha512-E1dSFxg+wsfJ4HKjutk/WaCzK7S2wv1POn1RRPGh8ZK+ag9l244Vqxji3r6wgz9YBf6+vhQEYJZpSjqWFPg9gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.8/handlebars.min.js" integrity="sha512-E1dSFxg+wsfJ4HKjutk/WaCzK7S2wv1POn1RRPGh8ZK+ag9l244Vqxji3r6wgz9YBf6+vhQEYJZpSjqWFPg9gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="~/js/prism.min.js" asp-append-version="true" data-manual></script> <script src="~/js/prism.min.js" asp-append-version="true" data-manual></script>
<script src="~/js/app.min.js" asp-append-version="true"></script> <script src="~/js/app.min.js" asp-append-version="true"></script>
<script src="~/js/admin.min.js" asp-append-version="true"></script>
<script id="loading-spinner-template" type="text/x-handlebars-template"> <script id="loading-spinner-template" type="text/x-handlebars-template">
@await Html.PartialAsync("_LoadingSpinner") @await Html.PartialAsync("_LoadingSpinner")

View File

@ -161,6 +161,19 @@ internal sealed class BlogPostService : IBlogPostService
return true; return true;
} }
/// <inheritdoc />
public void UpdatePost(IBlogPost post)
{
if (post is null)
{
throw new ArgumentNullException(nameof(post));
}
using BlogContext context = _dbContextFactory.CreateDbContext();
context.Update(post);
context.SaveChanges();
}
private BlogPost CacheAuthor(BlogPost post) private BlogPost CacheAuthor(BlogPost post)
{ {
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract

View File

@ -110,4 +110,11 @@ public interface IBlogPostService
/// </returns> /// </returns>
/// <exception cref="ArgumentNullException"><paramref name="slug" /> is <see langword="null" />.</exception> /// <exception cref="ArgumentNullException"><paramref name="slug" /> is <see langword="null" />.</exception>
bool TryGetPost(DateOnly publishDate, string slug, [NotNullWhen(true)] out IBlogPost? post); bool TryGetPost(DateOnly publishDate, string slug, [NotNullWhen(true)] out IBlogPost? post);
/// <summary>
/// Updates the specified post.
/// </summary>
/// <param name="post">The post to edit.</param>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
void UpdatePost(IBlogPost post);
} }

View File

@ -1,4 +1,43 @@
.form-signin { body {
max-width: 330px; min-height: 100vh;
padding: 1rem; }
html {
min-height: 100vh;
}
main {
height: 100vh;
max-height: 100vh;
overflow-x: auto;
overflow-y: hidden;
}
.card {
background: #FFFFFF;
color: #1E1E1E;
}
.btn.btn-orange {
background: orange;
color: #000;
}
table {
td, th {
vertical-align: middle;
}
}
pre {
background: #1e1e1e;
code mark, code mark span {
background: #d8ba76 !important;
color: #000 !important;
}
}
code[class*="language-"] {
background: none !important;
} }

View File

@ -0,0 +1,58 @@
import BlogPost from "../app/BlogPost";
import API from "../app/API";
(() => {
getCurrentBlogPost().then(post => {
if (!post) {
return;
}
const saveButton = document.getElementById("save-button");
const preview = document.getElementById("article-preview");
const content = document.getElementById("content") as HTMLTextAreaElement;
saveButton.addEventListener("click", async (e: MouseEvent) => {
await savePost();
});
document.addEventListener("keydown", async (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "s") {
e.preventDefault();
await savePost();
preview.innerHTML = post.content;
}
});
async function savePost(): Promise<void> {
saveButton.classList.add("btn-primary");
saveButton.classList.remove("btn-success");
saveButton.setAttribute("disabled", "disabled");
saveButton.innerHTML = '<i class="fa-solid fa-spinner fa-spin fa-fw"></i> Saving ...';
post = await API.updatePost(post, content.value);
saveButton.classList.add("btn-success");
saveButton.classList.remove("btn-primary");
saveButton.removeAttribute("disabled");
saveButton.innerHTML = '<i class="fa-solid fa-circle-check fa-fw"></i> Saved';
setTimeout(() => {
saveButton.classList.add("btn-primary");
saveButton.classList.remove("btn-success");
saveButton.innerHTML = '<i class="fa-solid fa-floppy-disk fa-fw"></i> Save <span class="text-muted">(Ctrl+S)</span>';
}, 2000);
}
});
async function getCurrentBlogPost(): Promise<BlogPost> {
const blogPostRef: Element = document.querySelector('input[type="hidden"][data-blog-pid]');
if (blogPostRef) {
const pid: string = blogPostRef.getAttribute("data-blog-pid");
return await API.getBlogPost(pid);
}
return null;
}
})();

View File

@ -0,0 +1 @@
import "./EditBlogPost"

View File

@ -6,36 +6,56 @@ class API {
private static readonly BLOG_URL: string = "/blog"; private static readonly BLOG_URL: string = "/blog";
static async getBlogPostCount(): Promise<number> { static async getBlogPostCount(): Promise<number> {
const response = await API.getResponse(`count`); const response = await API.get(`${API.BLOG_URL}/count`);
return response.count; return response.count;
} }
static async getBlogPost(id: string): Promise<BlogPost> { static async getBlogPost(id: string): Promise<BlogPost> {
const response = await API.getResponse(`post/${id}`); const response = await API.get(`${API.BLOG_URL}/post/${id}`);
return new BlogPost(response); return new BlogPost(response);
} }
static async getBlogPosts(page: number): Promise<BlogPost[]> { static async getBlogPosts(page: number): Promise<BlogPost[]> {
const response = await API.getResponse(`posts/${page}`); const response = await API.get(`${API.BLOG_URL}/posts/${page}`);
return response.map(obj => new BlogPost(obj)); return response.map(obj => new BlogPost(obj));
} }
static async getBlogPostsByTag(tag: string, page: number): Promise<BlogPost[]> { static async getBlogPostsByTag(tag: string, page: number): Promise<BlogPost[]> {
const response = await API.getResponse(`posts/tagged/${tag}/${page}`); const response = await API.get(`${API.BLOG_URL}/posts/tagged/${tag}/${page}`);
return response.map(obj => new BlogPost(obj)); return response.map(obj => new BlogPost(obj));
} }
static async getAuthor(id: string): Promise<Author> { static async getAuthor(id: string): Promise<Author> {
const response = await API.getResponse(`author/${id}`); const response = await API.get(`${API.BLOG_URL}/author/${id}`);
return new Author(response); return new Author(response);
} }
private static async getResponse(url: string): Promise<any> { static async updatePost(post: BlogPost, content: string): Promise<BlogPost> {
const response = await fetch(`${API.BASE_URL + API.BLOG_URL}/${url}`); try {
await API.patch(`/post/${post.id}`, {body: content});
} catch {
return post;
}
return await API.getBlogPost(post.id);
}
private static get(url: string, options?: any): Promise<any> {
return API.perform(url, "GET", options);
}
private static patch(url: string, options?: any): Promise<any> {
return API.perform(url, "PATCH", options);
}
private static async perform(url: string, method: string, options?: any): Promise<any> {
const opt = Object.assign({}, options, {method: method});
console.log("Sending options", opt);
const response = await fetch(`${API.BASE_URL}${url}`, opt);
if (response.status !== 200) { if (response.status !== 200) {
throw new Error("Invalid response from server"); throw new Error("Invalid response from server");
} }
const text = await response.text(); const text = await response.text();
return JSON.parse(text); return JSON.parse(text);
} }