Fix blog category selection and empty state rendering
TaxBaik CI/CD / build-and-deploy (push) Successful in 53s

This commit is contained in:
2026-07-09 15:32:13 +09:00
parent c5e6e4ba57
commit 2f72834317
9 changed files with 91 additions and 10 deletions
@@ -4,7 +4,7 @@ public class CreateBlogPostDto
{ {
public required string Title { get; set; } public required string Title { get; set; }
public required string Content { get; set; } public required string Content { get; set; }
public int? CategoryId { get; set; } public int CategoryId { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
public string? SeoTitle { get; set; } public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; } public string? SeoDescription { get; set; }
@@ -18,7 +18,7 @@ public class BlogPostResponseDto
public int Id { get; set; } public int Id { get; set; }
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty; public string Content { get; set; } = string.Empty;
public int? CategoryId { get; set; } public int CategoryId { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
public string? SeoTitle { get; set; } public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; } public string? SeoDescription { get; set; }
@@ -8,6 +8,7 @@ public sealed class CreateBlogPostDtoValidator : AbstractValidator<CreateBlogPos
{ {
RuleFor(x => x.Title).NotEmpty().MaximumLength(ValidationRules.TitleMaxLength); RuleFor(x => x.Title).NotEmpty().MaximumLength(ValidationRules.TitleMaxLength);
RuleFor(x => x.Content).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.ContentMaxLength); RuleFor(x => x.Content).NotEmpty().MinimumLength(ValidationRules.MessageMinLength).MaximumLength(ValidationRules.ContentMaxLength);
RuleFor(x => x.CategoryId).GreaterThan(0);
RuleFor(x => x.Tags).MaximumLength(ValidationRules.TagsMaxLength); RuleFor(x => x.Tags).MaximumLength(ValidationRules.TagsMaxLength);
RuleFor(x => x.SeoTitle).MaximumLength(ValidationRules.SeoTitleMaxLength); RuleFor(x => x.SeoTitle).MaximumLength(ValidationRules.SeoTitleMaxLength);
RuleFor(x => x.SeoDescription).MaximumLength(ValidationRules.SeoDescriptionMaxLength); RuleFor(x => x.SeoDescription).MaximumLength(ValidationRules.SeoDescriptionMaxLength);
@@ -2,6 +2,7 @@
@model TaxBaik.Web.Pages.Admin.Blog.CreateModel @model TaxBaik.Web.Pages.Admin.Blog.CreateModel
@{ @{
ViewData["Title"] = "블로그 등록"; ViewData["Title"] = "블로그 등록";
ViewData["BlogCategories"] = Model.Categories;
} }
@await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel( @await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel(
@@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs; using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog; namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] [Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class CreateModel(BlogService blogService) : PageModel public class CreateModel(BlogService blogService, CategoryService categoryService) : PageModel
{ {
[BindProperty] [BindProperty]
public CreateBlogPostDto Input { get; set; } = new() public CreateBlogPostDto Input { get; set; } = new()
@@ -17,14 +18,47 @@ public class CreateModel(BlogService blogService) : PageModel
Title = string.Empty Title = string.Empty
}; };
public IActionResult OnGet() => Page(); public IReadOnlyList<Category> Categories { get; private set; } = [];
public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (Categories.Count > 0)
Input.CategoryId = Categories[0].Id;
return Page();
}
public async Task<IActionResult> OnPostAsync(CancellationToken ct) public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{ {
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Page(); return Page();
await blogService.CreateAsync(Input, ct); await blogService.CreateAsync(Input, ct);
return RedirectToPage("/Admin/Blog/Index"); return RedirectToPage("/Admin/Blog/Index");
} }
private async Task<IEnumerable<Category>> EnsureCategoriesAsync(CancellationToken ct)
{
var categories = (await categoryService.GetAllAsync(ct)).ToList();
if (categories.Count > 0)
return categories;
var defaults = new[]
{
("사업자 세무", "business-tax", 1),
("부동산 세금", "real-estate-tax", 2),
("종합소득세", "income-tax", 3),
("부가가치세", "vat", 4),
("가족자산·증여", "family-asset", 5)
};
foreach (var (name, slug, sortOrder) in defaults)
{
await categoryService.CreateAsync(name, null, ct);
}
return await categoryService.GetAllAsync(ct);
}
} }
@@ -3,6 +3,7 @@
@{ @{
ViewData["Title"] = "블로그 수정"; ViewData["Title"] = "블로그 수정";
ViewData["BlogEditorId"] = Model.Id; ViewData["BlogEditorId"] = Model.Id;
ViewData["BlogCategories"] = Model.Categories;
} }
@await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel( @await Html.PartialAsync("_BlogEditor", new TaxBaik.Web.Pages.Shared.BlogEditorModel(
@@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using TaxBaik.Application.DTOs; using TaxBaik.Application.DTOs;
using TaxBaik.Application.Services; using TaxBaik.Application.Services;
using TaxBaik.Domain.Entities;
using TaxBaik.Web.Services; using TaxBaik.Web.Services;
namespace TaxBaik.Web.Pages.Admin.Blog; namespace TaxBaik.Web.Pages.Admin.Blog;
[Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)] [Authorize(AuthenticationSchemes = AdminAuthDefaults.Scheme)]
public class EditModel(BlogService blogService) : PageModel public class EditModel(BlogService blogService, CategoryService categoryService) : PageModel
{ {
[BindProperty(SupportsGet = true)] [BindProperty(SupportsGet = true)]
public int Id { get; set; } public int Id { get; set; }
@@ -20,17 +21,20 @@ public class EditModel(BlogService blogService) : PageModel
Title = string.Empty Title = string.Empty
}; };
public IReadOnlyList<Category> Categories { get; private set; } = [];
public async Task<IActionResult> OnGetAsync(CancellationToken ct) public async Task<IActionResult> OnGetAsync(CancellationToken ct)
{ {
var post = await blogService.GetByIdAsync(Id, ct); var post = await blogService.GetByIdAsync(Id, ct);
if (post is null) if (post is null)
return NotFound(); return NotFound();
Categories = (await EnsureCategoriesAsync(ct)).ToList();
Input = new CreateBlogPostDto Input = new CreateBlogPostDto
{ {
Title = post.Title, Title = post.Title,
Content = post.Content, Content = post.Content,
CategoryId = post.CategoryId, CategoryId = post.CategoryId ?? 0,
Tags = post.Tags, Tags = post.Tags,
SeoTitle = post.SeoTitle, SeoTitle = post.SeoTitle,
SeoDescription = post.SeoDescription, SeoDescription = post.SeoDescription,
@@ -44,6 +48,7 @@ public class EditModel(BlogService blogService) : PageModel
public async Task<IActionResult> OnPostAsync(CancellationToken ct) public async Task<IActionResult> OnPostAsync(CancellationToken ct)
{ {
Categories = (await EnsureCategoriesAsync(ct)).ToList();
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Page(); return Page();
@@ -59,4 +64,27 @@ public class EditModel(BlogService blogService) : PageModel
await blogService.DeleteAsync(Id, ct); await blogService.DeleteAsync(Id, ct);
return RedirectToPage("/Admin/Blog/Index"); return RedirectToPage("/Admin/Blog/Index");
} }
private async Task<IEnumerable<Category>> EnsureCategoriesAsync(CancellationToken ct)
{
var categories = (await categoryService.GetAllAsync(ct)).ToList();
if (categories.Count > 0)
return categories;
var defaults = new[]
{
("사업자 세무", "business-tax", 1),
("부동산 세금", "real-estate-tax", 2),
("종합소득세", "income-tax", 3),
("부가가치세", "vat", 4),
("가족자산·증여", "family-asset", 5)
};
foreach (var (name, slug, sortOrder) in defaults)
{
await categoryService.CreateAsync(name, null, ct);
}
return await categoryService.GetAllAsync(ct);
}
} }
@@ -3,7 +3,7 @@ namespace TaxBaik.Web.Pages.Shared;
public sealed record BlogEditorModel( public sealed record BlogEditorModel(
string Title, string Title,
string Content, string Content,
int? CategoryId, int CategoryId,
bool IsPublished, bool IsPublished,
string? ThumbnailUrl, string? ThumbnailUrl,
string? SeoTitle, string? SeoTitle,
@@ -43,7 +43,20 @@
<div class="row g-3 blog-meta-grid"> <div class="row g-3 blog-meta-grid">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="Input_CategoryId">카테고리</label> <label class="form-label" for="Input_CategoryId">카테고리</label>
<input class="form-control" id="Input_CategoryId" name="Input.CategoryId" value="@Model.CategoryId" /> <select class="form-select" id="Input_CategoryId" name="Input.CategoryId" required>
@foreach (var category in (ViewData["BlogCategories"] as IEnumerable<TaxBaik.Domain.Entities.Category>) ?? [])
{
if (category.Id == Model.CategoryId)
{
<option value="@category.Id" selected>@category.Name</option>
}
else
{
<option value="@category.Id">@category.Name</option>
}
}
</select>
<span class="text-danger small" data-valmsg-for="Input.CategoryId" data-valmsg-replace="true"></span>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label d-block" for="Input_IsPublished">발행 여부</label> <label class="form-label d-block" for="Input_IsPublished">발행 여부</label>
@@ -85,7 +98,7 @@
<div> <div>
<div class="text-muted small text-uppercase mb-1">불러온 원본</div> <div class="text-muted small text-uppercase mb-1">불러온 원본</div>
<div class="fw-semibold">@Model.Title</div> <div class="fw-semibold">@Model.Title</div>
<div class="text-muted small">카테고리: @(Model.CategoryId?.ToString() ?? "미지정")</div> <div class="text-muted small">카테고리: @Model.CategoryId</div>
<div class="text-muted small">발행: @(Model.IsPublished ? "예" : "아니오")</div> <div class="text-muted small">발행: @(Model.IsPublished ? "예" : "아니오")</div>
</div> </div>
<article class="content-prose blog-preview-pane" data-blog-preview>@Html.Raw(Model.Content)</article> <article class="content-prose blog-preview-pane" data-blog-preview>@Html.Raw(Model.Content)</article>
@@ -1,4 +1,7 @@
@model string @model string
@{
var emptyMessage = string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model;
}
<div class="empty py-5"> <div class="empty py-5">
<div class="empty-img"> <div class="empty-img">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-database-off" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-database-off" width="48" height="48" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
@@ -8,5 +11,5 @@
<path d="M3 3l18 18" /> <path d="M3 3l18 18" />
</svg> </svg>
</div> </div>
<p class="empty-title">@string.IsNullOrWhiteSpace(Model) ? "데이터가 없습니다." : Model</p> <p class="empty-title">@emptyMessage</p>
</div> </div>