Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f5b843a4 | ||
|
|
b2d0c72541 | ||
|
|
941759f7c3 | ||
|
|
2443b92c23 | ||
|
|
d8015f54ef |
@@ -0,0 +1,206 @@
|
||||
using Media.JoshHeaps.Net.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Api;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/blog")]
|
||||
public class BlogApi(BlogService blogService, DbExecutor dbExecutor, IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger<BlogApi> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("posts")]
|
||||
public async Task<IActionResult> GetPosts()
|
||||
{
|
||||
var posts = await blogService.GetAllPostsAsync();
|
||||
return Ok(posts.Select(MapToPublicDto));
|
||||
}
|
||||
|
||||
[HttpGet("posts/{slug}")]
|
||||
public async Task<IActionResult> GetPostBySlug(string slug)
|
||||
{
|
||||
var post = await blogService.GetPostBySlugAsync(slug);
|
||||
if (post is null) return NotFound();
|
||||
return Ok(MapToPublicDto(post));
|
||||
}
|
||||
|
||||
[HttpGet("posts/tags/{tag}")]
|
||||
public async Task<IActionResult> GetPostsByTag(string tag)
|
||||
{
|
||||
var posts = await blogService.GetPostsByTagAsync(tag);
|
||||
return Ok(posts.Select(MapToPublicDto));
|
||||
}
|
||||
|
||||
[HttpGet("posts/admin/{id}")]
|
||||
public async Task<IActionResult> GetPostForAdmin(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
var post = await blogService.GetPostByIdAsync(id);
|
||||
if (post is null) return NotFound();
|
||||
return Ok(MapToAdminDto(post));
|
||||
}
|
||||
|
||||
[HttpPost("posts")]
|
||||
public async Task<IActionResult> CreatePost([FromBody] CreateBlogPostRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
|
||||
return BadRequest(new { error = "Content is required" });
|
||||
|
||||
var post = await blogService.CreatePostAsync(
|
||||
request.Title.Trim(),
|
||||
request.Summary?.Trim() ?? "",
|
||||
request.MarkdownContent,
|
||||
request.Tags ?? [],
|
||||
userId.Value,
|
||||
request.PublishedDate ?? DateTime.UtcNow);
|
||||
|
||||
if (post is null)
|
||||
return StatusCode(500, new { error = "Failed to create post" });
|
||||
|
||||
_ = InvalidateFrontendCacheAsync();
|
||||
return Ok(MapToAdminDto(post));
|
||||
}
|
||||
|
||||
[HttpPut("posts/{id}")]
|
||||
public async Task<IActionResult> UpdatePost(long id, [FromBody] UpdateBlogPostRequest request)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.MarkdownContent))
|
||||
return BadRequest(new { error = "Content is required" });
|
||||
|
||||
var post = await blogService.UpdatePostAsync(
|
||||
id,
|
||||
request.Title.Trim(),
|
||||
request.Summary?.Trim() ?? "",
|
||||
request.MarkdownContent,
|
||||
request.Tags ?? [],
|
||||
request.PublishedDate ?? DateTime.UtcNow);
|
||||
|
||||
if (post is null)
|
||||
return NotFound(new { error = "Post not found" });
|
||||
|
||||
_ = InvalidateFrontendCacheAsync();
|
||||
return Ok(MapToAdminDto(post));
|
||||
}
|
||||
|
||||
[HttpDelete("posts/{id}")]
|
||||
public async Task<IActionResult> DeletePost(long id)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
var deleted = await blogService.DeletePostAsync(id);
|
||||
if (!deleted) return NotFound(new { error = "Post not found" });
|
||||
|
||||
_ = InvalidateFrontendCacheAsync();
|
||||
return Ok(new { success = true });
|
||||
}
|
||||
|
||||
[HttpPost("images")]
|
||||
public async Task<IActionResult> UploadImage(IFormFile file)
|
||||
{
|
||||
var userId = GetUserIdFromAuth();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (!await IsAdmin(userId.Value)) return Forbid();
|
||||
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { error = "No file provided" });
|
||||
|
||||
var (url, error) = await blogService.SaveImageAsync(file);
|
||||
if (url is null)
|
||||
return BadRequest(new { error });
|
||||
|
||||
return Ok(new { url });
|
||||
}
|
||||
|
||||
[HttpGet("images/{fileName}")]
|
||||
public IActionResult GetImage(string fileName)
|
||||
{
|
||||
var (filePath, mimeType) = blogService.GetImagePath(fileName);
|
||||
if (filePath is null)
|
||||
return NotFound();
|
||||
|
||||
return PhysicalFile(filePath, mimeType!);
|
||||
}
|
||||
|
||||
private static object MapToPublicDto(Models.BlogPost post) => new
|
||||
{
|
||||
post.Id,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.Tags,
|
||||
post.PublishedDate,
|
||||
post.HtmlContent
|
||||
};
|
||||
|
||||
private static object MapToAdminDto(Models.BlogPost post) => new
|
||||
{
|
||||
post.Id,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.MarkdownContent,
|
||||
post.Tags,
|
||||
post.PublishedDate,
|
||||
post.CreatedAt,
|
||||
post.UpdatedAt
|
||||
};
|
||||
|
||||
private async Task InvalidateFrontendCacheAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = configuration["BlogCache:InvalidateUrl"];
|
||||
var key = configuration["BlogCache:InvalidateKey"];
|
||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(key)) return;
|
||||
|
||||
var client = httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
request.Headers.Add("X-Invalidate-Key", key);
|
||||
await client.SendAsync(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to invalidate frontend blog cache");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsAdmin(long userId)
|
||||
{
|
||||
return await dbExecutor.ExecuteAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM app.user_roles ur JOIN app.roles r ON ur.role_id = r.id WHERE ur.user_id = @UserId AND r.name = 'admin')",
|
||||
new { UserId = userId });
|
||||
}
|
||||
|
||||
private long? GetUserIdFromAuth()
|
||||
{
|
||||
var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrEmpty(userIdClaim) && long.TryParse(userIdClaim, out var jwtUserId))
|
||||
return jwtUserId;
|
||||
|
||||
var userIdString = HttpContext.Session.GetString("UserId");
|
||||
if (!string.IsNullOrEmpty(userIdString) && long.TryParse(userIdString, out var sessionUserId))
|
||||
return sessionUserId;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
|
||||
public record UpdateBlogPostRequest(string Title, string? Summary, string MarkdownContent, List<string>? Tags, DateTime? PublishedDate);
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS app.blog_posts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
slug VARCHAR(200) UNIQUE NOT NULL,
|
||||
title VARCHAR(500) NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
markdown_content TEXT NOT NULL,
|
||||
html_content TEXT NOT NULL,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
author_id BIGINT NOT NULL REFERENCES app.users(id),
|
||||
published_date TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_posts_slug ON app.blog_posts(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_blog_posts_published_date ON app.blog_posts(published_date DESC);
|
||||
|
||||
-- Seed existing blog post
|
||||
INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date)
|
||||
SELECT
|
||||
'my-first-post',
|
||||
'My First Post',
|
||||
'Welcome to my blog! Here I''ll share thoughts on software development, projects I''m working on, and things I find interesting.',
|
||||
E'# Welcome to My Blog\n\nI''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.\n\n## What to Expect\n\nI plan to write about:\n\n- **Projects** I''m working on, like this website and the other things on my portfolio\n- **Software development** tips and patterns I find useful\n- **Problem solving** approaches that have helped me grow as a developer\n\n## Why a Blog?\n\nBuilding things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.\n\nStay tuned for more posts!',
|
||||
E'<h1>Welcome to My Blog</h1>\n<p>I''ve been meaning to start writing about the things I build and learn, and I''m finally getting around to it.</p>\n<h2>What to Expect</h2>\n<p>I plan to write about:</p>\n<ul>\n<li><strong>Projects</strong> I''m working on, like this website and the other things on my portfolio</li>\n<li><strong>Software development</strong> tips and patterns I find useful</li>\n<li><strong>Problem solving</strong> approaches that have helped me grow as a developer</li>\n</ul>\n<h2>Why a Blog?</h2>\n<p>Building things is great, but explaining <em>how</em> and <em>why</em> you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.</p>\n<p>Stay tuned for more posts!</p>',
|
||||
ARRAY['dev', 'personal'],
|
||||
(SELECT id FROM app.users WHERE id = 1),
|
||||
'2026-03-06T00:00:00Z'
|
||||
WHERE EXISTS (SELECT 1 FROM app.users WHERE id = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM app.blog_posts WHERE slug = 'my-first-post');
|
||||
@@ -10,6 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="MailKit" Version="4.8.0" />
|
||||
<PackageReference Include="Markdig" Version="1.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||
<PackageReference Include="Npgsql" Version="9.0.4" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Media.JoshHeaps.Net.Models;
|
||||
|
||||
public class BlogPost
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public string MarkdownContent { get; set; } = string.Empty;
|
||||
public string HtmlContent { get; set; } = string.Empty;
|
||||
public List<string> Tags { get; set; } = [];
|
||||
public long AuthorId { get; set; }
|
||||
public DateTime PublishedDate { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@page
|
||||
@model Media.JoshHeaps.Net.Pages.BlogAdminModel
|
||||
@{
|
||||
ViewData["Title"] = "Blog Admin";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<link rel="stylesheet" href="~/css/blog-admin.css" asp-append-version="true" />
|
||||
}
|
||||
|
||||
<div class="dashboard-container">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-left">
|
||||
<a href="/Admin" class="back-button" title="Back to Admin">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
<h1>Blog Admin</h1>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button id="newPostBtn" class="btn btn-primary">New Post</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="postsList" class="admin-section">
|
||||
<div class="section-header">
|
||||
<h2>Posts</h2>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Tags</th>
|
||||
<th>Published</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="postsTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="postEditor" class="admin-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h2 id="editorTitle">New Post</h2>
|
||||
</div>
|
||||
<input type="hidden" id="editPostId" />
|
||||
<div class="form-group">
|
||||
<label for="postTitle">Title</label>
|
||||
<input type="text" id="postTitle" class="form-input" placeholder="Post title" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postSummary">Summary</label>
|
||||
<input type="text" id="postSummary" class="form-input" placeholder="Brief summary" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postTags">Tags (comma-separated)</label>
|
||||
<input type="text" id="postTags" class="form-input" placeholder="dev, personal" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postDate">Published Date</label>
|
||||
<input type="date" id="postDate" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="postContent">Markdown Content</label>
|
||||
<div class="content-toolbar">
|
||||
<button type="button" id="uploadImageBtn" class="btn btn-secondary btn-sm">Upload Image</button>
|
||||
<input type="file" id="imageFileInput" accept="image/jpeg,image/png,image/gif,image/webp" style="display: none;" />
|
||||
<span id="uploadStatus" class="upload-status"></span>
|
||||
</div>
|
||||
<textarea id="postContent" class="form-input form-textarea" placeholder="Write your post in markdown..."></textarea>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="savePostBtn" class="btn btn-primary">Save</button>
|
||||
<button id="cancelEditBtn" class="btn btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/blog-admin.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Pages;
|
||||
|
||||
public class BlogAdminModel(DbExecutor dbExecutor) : AuthenticatedPageModel
|
||||
{
|
||||
private readonly DbExecutor _dbExecutor = dbExecutor;
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
RequireAuthentication();
|
||||
LoadUserSession();
|
||||
|
||||
var denied = await RequireRole("admin", _dbExecutor);
|
||||
if (denied != null) return denied;
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,31 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/BlogAdmin" class="landing-card">
|
||||
<div class="card-content">
|
||||
<div class="card-icon-wrapper">
|
||||
<div class="card-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-text">
|
||||
<h2>Blog</h2>
|
||||
<p class="card-description">Create and manage blog posts for your website</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-link-text">Open workspace</span>
|
||||
<div class="card-arrow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (Model.HasMedicalRole)
|
||||
|
||||
@@ -19,6 +19,8 @@ builder.Services.AddScoped<FolderService>();
|
||||
builder.Services.AddScoped<GraphService>();
|
||||
builder.Services.AddScoped<MedicalDocsService>();
|
||||
builder.Services.AddSingleton<MedicalAiService>();
|
||||
builder.Services.AddScoped<BlogService>();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// Add session support
|
||||
builder.Services.AddDistributedMemoryCache();
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
using Media.JoshHeaps.Net.Models;
|
||||
|
||||
namespace Media.JoshHeaps.Net.Services;
|
||||
|
||||
public partial class BlogService(DbExecutor db, IWebHostEnvironment environment, ILogger<BlogService> logger)
|
||||
{
|
||||
private static readonly HashSet<string> AllowedMimeTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||
private const long MaxImageSize = 10 * 1024 * 1024;
|
||||
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
|
||||
.UseAdvancedExtensions()
|
||||
.Build();
|
||||
|
||||
public async Task<List<BlogPost>> GetAllPostsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteListReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
ORDER BY published_date DESC",
|
||||
MapBlogPost);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get all blog posts");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetPostByIdAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
WHERE id = @Id",
|
||||
MapBlogPost,
|
||||
new { Id = id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get blog post by id {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
WHERE slug = @Slug",
|
||||
MapBlogPost,
|
||||
new { Slug = slug });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get blog post by slug {Slug}", slug);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await db.ExecuteListReaderAsync(
|
||||
@"SELECT id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at
|
||||
FROM app.blog_posts
|
||||
WHERE @Tag = ANY(tags)
|
||||
ORDER BY published_date DESC",
|
||||
MapBlogPost,
|
||||
new { Tag = tag });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to get blog posts by tag {Tag}", tag);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> CreatePostAsync(string title, string summary, string markdownContent, List<string> tags, long authorId, DateTime publishedDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var slug = GenerateSlug(title);
|
||||
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"INSERT INTO app.blog_posts (slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at)
|
||||
VALUES (@Slug, @Title, @Summary, @MarkdownContent, @HtmlContent, @Tags, @AuthorId, @PublishedDate, @CreatedAt, @UpdatedAt)
|
||||
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
|
||||
MapBlogPost,
|
||||
new
|
||||
{
|
||||
Slug = slug,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
MarkdownContent = markdownContent,
|
||||
HtmlContent = htmlContent,
|
||||
Tags = tags.ToArray(),
|
||||
AuthorId = authorId,
|
||||
PublishedDate = publishedDate,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create blog post '{Title}'", title);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BlogPost?> UpdatePostAsync(long id, string title, string summary, string markdownContent, List<string> tags, DateTime publishedDate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var slug = GenerateSlug(title);
|
||||
var htmlContent = Markdown.ToHtml(markdownContent, Pipeline);
|
||||
|
||||
return await db.ExecuteReaderAsync(
|
||||
@"UPDATE app.blog_posts
|
||||
SET slug = @Slug, title = @Title, summary = @Summary, markdown_content = @MarkdownContent,
|
||||
html_content = @HtmlContent, tags = @Tags, published_date = @PublishedDate, updated_at = @UpdatedAt
|
||||
WHERE id = @Id
|
||||
RETURNING id, slug, title, summary, markdown_content, html_content, tags, author_id, published_date, created_at, updated_at",
|
||||
MapBlogPost,
|
||||
new
|
||||
{
|
||||
Id = id,
|
||||
Slug = slug,
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
MarkdownContent = markdownContent,
|
||||
HtmlContent = htmlContent,
|
||||
Tags = tags.ToArray(),
|
||||
PublishedDate = publishedDate,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update blog post {Id}", id);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeletePostAsync(long id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await db.ExecuteNonQueryAsync(
|
||||
"DELETE FROM app.blog_posts WHERE id = @Id",
|
||||
new { Id = id });
|
||||
return rows > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to delete blog post {Id}", id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(string? url, string? error)> SaveImageAsync(IFormFile file)
|
||||
{
|
||||
if (!AllowedMimeTypes.Contains(file.ContentType))
|
||||
return (null, "Only JPEG, PNG, GIF, and WEBP images are allowed");
|
||||
|
||||
if (file.Length > MaxImageSize)
|
||||
return (null, "Image must be under 10MB");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(ext)) ext = ".jpg";
|
||||
|
||||
var fileName = $"{Guid.NewGuid()}{ext}";
|
||||
var folder = Path.Combine(environment.ContentRootPath, "App_Data", "blog");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var filePath = Path.Combine(folder, fileName);
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(filePath, FileMode.Create);
|
||||
await file.CopyToAsync(stream);
|
||||
return ($"/api/blog/images/{fileName}", null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to save blog image");
|
||||
if (File.Exists(filePath)) File.Delete(filePath);
|
||||
return (null, "Failed to save image");
|
||||
}
|
||||
}
|
||||
|
||||
public (string? filePath, string? mimeType) GetImagePath(string fileName)
|
||||
{
|
||||
if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\'))
|
||||
return (null, null);
|
||||
|
||||
var filePath = Path.Combine(environment.ContentRootPath, "App_Data", "blog", fileName);
|
||||
if (!File.Exists(filePath))
|
||||
return (null, null);
|
||||
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
var mimeType = ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
return (filePath, mimeType);
|
||||
}
|
||||
|
||||
private static BlogPost MapBlogPost(Npgsql.NpgsqlDataReader reader)
|
||||
{
|
||||
return new BlogPost
|
||||
{
|
||||
Id = reader.GetInt64(0),
|
||||
Slug = reader.GetString(1),
|
||||
Title = reader.GetString(2),
|
||||
Summary = reader.GetString(3),
|
||||
MarkdownContent = reader.GetString(4),
|
||||
HtmlContent = reader.GetString(5),
|
||||
Tags = reader.GetFieldValue<string[]>(6).ToList(),
|
||||
AuthorId = reader.GetInt64(7),
|
||||
PublishedDate = reader.GetDateTime(8),
|
||||
CreatedAt = reader.GetDateTime(9),
|
||||
UpdatedAt = reader.GetDateTime(10)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateSlug(string title)
|
||||
{
|
||||
var slug = title.ToLowerInvariant();
|
||||
slug = SlugInvalidChars().Replace(slug, "");
|
||||
slug = SlugWhitespace().Replace(slug, "-");
|
||||
slug = SlugMultipleDashes().Replace(slug, "-");
|
||||
return slug.Trim('-');
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[^a-z0-9\s-]")]
|
||||
private static partial Regex SlugInvalidChars();
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex SlugWhitespace();
|
||||
|
||||
[GeneratedRegex(@"-{2,}")]
|
||||
private static partial Regex SlugMultipleDashes();
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
"FromName": "Media App",
|
||||
"EnableSsl": "true"
|
||||
},
|
||||
"BlogCache": {
|
||||
"InvalidateUrl": "https://joshheaps.net/api/blog/invalidate",
|
||||
"InvalidateKey": "CHANGE_ME"
|
||||
},
|
||||
"FileUpload": {
|
||||
"MaxFileSizeMB": 10,
|
||||
"AllowedImageTypes": ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
.dashboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
padding: 32px 40px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.welcome-section h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: #fff;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
border-color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.admin-table code {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Editor form */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.content-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.upload-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.upload-success {
|
||||
color: var(--success, #22c55e);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 400px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-section {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
(function () {
|
||||
let posts = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
async function init() {
|
||||
await loadPosts();
|
||||
|
||||
document.getElementById('newPostBtn').addEventListener('click', showNewPostEditor);
|
||||
document.getElementById('savePostBtn').addEventListener('click', savePost);
|
||||
document.getElementById('cancelEditBtn').addEventListener('click', hideEditor);
|
||||
|
||||
const imageInput = document.getElementById('imageFileInput');
|
||||
document.getElementById('uploadImageBtn').addEventListener('click', () => imageInput.click());
|
||||
imageInput.addEventListener('change', uploadImage);
|
||||
}
|
||||
|
||||
async function uploadImage() {
|
||||
const input = document.getElementById('imageFileInput');
|
||||
const status = document.getElementById('uploadStatus');
|
||||
if (!input.files.length) return;
|
||||
|
||||
const file = input.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
status.textContent = 'Uploading...';
|
||||
status.className = 'upload-status';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/blog/images', { method: 'POST', body: formData });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
status.textContent = err.error || 'Upload failed';
|
||||
status.className = 'upload-status upload-error';
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json();
|
||||
const textarea = document.getElementById('postContent');
|
||||
const markdown = ``;
|
||||
const pos = textarea.selectionStart;
|
||||
const before = textarea.value.substring(0, pos);
|
||||
const after = textarea.value.substring(pos);
|
||||
const needsNewline = before.length > 0 && !before.endsWith('\n') ? '\n' : '';
|
||||
textarea.value = before + needsNewline + markdown + '\n' + after;
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = pos + needsNewline.length + markdown.length + 1;
|
||||
status.textContent = 'Uploaded!';
|
||||
status.className = 'upload-status upload-success';
|
||||
setTimeout(() => { status.textContent = ''; }, 2000);
|
||||
} catch {
|
||||
status.textContent = 'Upload failed';
|
||||
status.className = 'upload-status upload-error';
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
const res = await fetch('/api/blog/posts');
|
||||
if (!res.ok) return;
|
||||
posts = await res.json();
|
||||
renderPostsTable();
|
||||
}
|
||||
|
||||
function renderPostsTable() {
|
||||
const tbody = document.getElementById('postsTableBody');
|
||||
if (posts.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="empty-message">No posts yet. Create your first post!</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = posts.map(post => {
|
||||
const tags = post.tags.map(t => `<span class="tag-pill">${escapeHtml(t)}</span>`).join('');
|
||||
const date = new Date(post.publishedDate).toLocaleDateString();
|
||||
return `<tr>
|
||||
<td>${escapeHtml(post.title)}</td>
|
||||
<td><code>${escapeHtml(post.slug)}</code></td>
|
||||
<td><div class="tag-list">${tags}</div></td>
|
||||
<td>${date}</td>
|
||||
<td class="action-cell">
|
||||
<button class="btn btn-secondary btn-sm" onclick="blogEditPost(${post.id})">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="blogDeletePost(${post.id})">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showNewPostEditor() {
|
||||
document.getElementById('editorTitle').textContent = 'New Post';
|
||||
document.getElementById('editPostId').value = '';
|
||||
document.getElementById('postTitle').value = '';
|
||||
document.getElementById('postSummary').value = '';
|
||||
document.getElementById('postTags').value = '';
|
||||
document.getElementById('postContent').value = '';
|
||||
document.getElementById('postDate').value = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('postsList').style.display = 'none';
|
||||
document.getElementById('postEditor').style.display = '';
|
||||
}
|
||||
|
||||
function hideEditor() {
|
||||
document.getElementById('postEditor').style.display = 'none';
|
||||
document.getElementById('postsList').style.display = '';
|
||||
}
|
||||
|
||||
async function savePost() {
|
||||
const id = document.getElementById('editPostId').value;
|
||||
const title = document.getElementById('postTitle').value.trim();
|
||||
const summary = document.getElementById('postSummary').value.trim();
|
||||
const tagsStr = document.getElementById('postTags').value.trim();
|
||||
const markdownContent = document.getElementById('postContent').value;
|
||||
const dateStr = document.getElementById('postDate').value;
|
||||
|
||||
if (!title) { alert('Title is required'); return; }
|
||||
if (!markdownContent) { alert('Content is required'); return; }
|
||||
|
||||
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : [];
|
||||
const publishedDate = dateStr ? new Date(dateStr + 'T00:00:00Z').toISOString() : null;
|
||||
|
||||
const body = { title, summary, markdownContent, tags, publishedDate };
|
||||
const url = id ? `/api/blog/posts/${id}` : '/api/blog/posts';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to save post');
|
||||
return;
|
||||
}
|
||||
|
||||
hideEditor();
|
||||
await loadPosts();
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
window.blogEditPost = async function (id) {
|
||||
const res = await fetch(`/api/blog/posts`);
|
||||
if (!res.ok) return;
|
||||
const allPosts = await res.json();
|
||||
|
||||
// Need markdown content, so fetch the admin-specific data
|
||||
// The public API doesn't return markdownContent, so we fetch all and find by id
|
||||
// Actually, we need to get it from the create/update response pattern
|
||||
// For editing, we'll fetch via a dedicated admin endpoint or use stored data
|
||||
// Since the public GET returns htmlContent but not markdownContent,
|
||||
// let's make a specific request. But our API only has public routes returning htmlContent.
|
||||
// We need the admin to get markdownContent too.
|
||||
|
||||
// Workaround: find in local posts array (which has htmlContent), but we need markdown.
|
||||
// The cleanest fix is to have the GET endpoints also return markdownContent for admins,
|
||||
// but for now let's add an admin-specific detail endpoint.
|
||||
// Actually, looking at the API, the GET /posts/{slug} returns the public DTO.
|
||||
// Let me use a different approach: store markdownContent in memory from the list.
|
||||
|
||||
// For now, we need to get the post with markdownContent.
|
||||
// Let's fetch by id from the admin-aware endpoint.
|
||||
const detailRes = await fetch(`/api/blog/posts/admin/${id}`);
|
||||
if (!detailRes.ok) {
|
||||
alert('Failed to load post for editing');
|
||||
return;
|
||||
}
|
||||
const post = await detailRes.json();
|
||||
|
||||
document.getElementById('editorTitle').textContent = 'Edit Post';
|
||||
document.getElementById('editPostId').value = post.id;
|
||||
document.getElementById('postTitle').value = post.title;
|
||||
document.getElementById('postSummary').value = post.summary || '';
|
||||
document.getElementById('postTags').value = (post.tags || []).join(', ');
|
||||
document.getElementById('postContent').value = post.markdownContent || '';
|
||||
document.getElementById('postDate').value = new Date(post.publishedDate).toISOString().split('T')[0];
|
||||
document.getElementById('postsList').style.display = 'none';
|
||||
document.getElementById('postEditor').style.display = '';
|
||||
};
|
||||
|
||||
window.blogDeletePost = async function (id) {
|
||||
if (!confirm('Are you sure you want to delete this post?')) return;
|
||||
|
||||
const res = await fetch(`/api/blog/posts/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Failed to delete post');
|
||||
return;
|
||||
}
|
||||
await loadPosts();
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user