feat(admin): stabilize blog and admin patterns
TaxBaik CI/CD / build-and-deploy (push) Has been cancelled

This commit is contained in:
2026-07-02 10:46:27 +09:00
parent b3cab87539
commit cb47349a25
67 changed files with 1354 additions and 486 deletions
@@ -1,11 +1,9 @@
@page "/admin/blog/create"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@inject BlogService BlogService
@inject ICategoryRepository CategoryRepository
@using TaxBaik.Web.Components.Admin.Pages.Blog
@inject IBlogBrowserClient BlogClient
@inject ICategoryBrowserClient CategoryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@@ -21,62 +19,16 @@
</section>
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
</div>
</MudForm>
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" OnCancel="GoBack" />
</MudPaper>
@code {
private MudForm? form;
private List<Domain.Entities.Category> categories = [];
private CreatePostModel model = new();
private EasyMDE.Editor? editor;
[Inject]
private IJSRuntime JS { get; set; } = null!;
private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
protected override async Task OnInitializedAsync()
{
categories = (await CategoryRepository.GetAllAsync()).ToList();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
categories = await CategoryClient.GetAllAsync();
}
private void GoBack()
@@ -86,25 +38,9 @@
private async Task SavePost()
{
if (form == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
return;
try
{
await BlogService.CreateAsync(new CreateBlogPostDto
var result = await BlogClient.CreateAsync(new CreateBlogPostDto
{
Title = model.Title,
Content = model.Content,
@@ -115,6 +51,12 @@
IsPublished = model.IsPublished
});
if (result == null)
{
Snackbar.Add("포스트 저장에 실패했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
@@ -123,45 +65,4 @@
Snackbar.Add(ex.Message, Severity.Error);
}
}
private class CreatePostModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -1,11 +1,9 @@
@page "/admin/blog/{id:int}/edit"
@attribute [Authorize]
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using TaxBaik.Application.DTOs
@using TaxBaik.Application.Services
@using TaxBaik.Domain.Interfaces
@inject BlogService BlogService
@inject ICategoryRepository CategoryRepository
@using TaxBaik.Web.Components.Admin.Pages.Blog
@inject IBlogBrowserClient BlogClient
@inject ICategoryBrowserClient CategoryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@@ -32,42 +30,10 @@ else if (post == null)
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<MudForm @ref="form">
<MudTextField @bind-Value="model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<div class="mb-4">
<label class="d-block mb-2" style="font-weight: 500;">본문 내용 (마크다운) *</label>
<textarea id="markdown-editor" @bind="model.Content" style="display: none;"></textarea>
<div id="editor-container" style="border: 1px solid #d0d0d0; border-radius: 4px; min-height: 400px;"></div>
</div>
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="model.IsPublished" Label="발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
@onclick="SavePost">저장</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Error"
@onclick="DeletePost">삭제</MudButton>
</div>
</MudForm>
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" />
<div class="mt-4">
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeletePost">삭제</MudButton>
</div>
</MudPaper>
}
@@ -75,23 +41,19 @@ else
[Parameter]
public int Id { get; set; }
[Inject]
private IJSRuntime JS { get; set; } = null!;
private MudForm? form;
private Domain.Entities.BlogPost? post;
private List<Domain.Entities.Category> categories = [];
private EditPostModel model = new();
private TaxBaik.Application.DTOs.BlogPostResponseDto? post;
private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
private bool isLoading = true;
protected override async Task OnInitializedAsync()
{
try
{
post = await BlogService.GetByIdAsync(Id);
post = await BlogClient.GetByIdAsync(Id);
if (post != null)
{
categories = (await CategoryRepository.GetAllAsync()).ToList();
categories = await CategoryClient.GetAllAsync();
MapPostToModel(post);
}
}
@@ -105,15 +67,7 @@ else
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && post != null)
{
await JS.InvokeVoidAsync("window.initMarkdownEditor", "markdown-editor", model.Content ?? "");
}
}
private void MapPostToModel(Domain.Entities.BlogPost post)
private void MapPostToModel(TaxBaik.Application.DTOs.BlogPostResponseDto post)
{
model.Title = post.Title;
model.Content = post.Content;
@@ -131,25 +85,12 @@ else
private async Task SavePost()
{
if (form == null || post == null)
return;
// 에디터에서 최신 내용 가져오기
model.Content = await JS.InvokeAsync<string>("window.getMarkdownContent");
if (string.IsNullOrWhiteSpace(model.Content))
{
Snackbar.Add("본문 내용을 입력하세요.", Severity.Error);
return;
}
await form.Validate();
if (!form.IsValid)
if (post == null)
return;
try
{
await BlogService.UpdateAsync(post.Id, new CreateBlogPostDto
var result = await BlogClient.UpdateAsync(post.Id, new CreateBlogPostDto
{
Title = model.Title,
Content = model.Content,
@@ -160,6 +101,12 @@ else
IsPublished = model.IsPublished
});
if (result == null)
{
Snackbar.Add("저장 실패: 포스트를 저장하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
@@ -188,7 +135,12 @@ else
try
{
await BlogService.DeleteAsync(post.Id);
var deleted = await BlogClient.DeleteAsync(post.Id);
if (!deleted)
{
Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
@@ -197,45 +149,4 @@ else
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
}
}
private class EditPostModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}
<!-- EasyMDE 초기화 스크립트 -->
<script>
window.initMarkdownEditor = function(editorId, initialContent) {
if (!window.easyMDEInstance) {
window.easyMDEInstance = new EasyMDE({
element: document.getElementById(editorId),
spellChecker: false,
autoDownloadFontAwesome: false,
initialValue: initialContent || "",
toolbar: [
"bold", "italic", "strikethrough", "|",
"heading", "code", "|",
"unordered-list", "ordered-list", "|",
"link", "image", "table", "|",
"quote", "horizontal-rule", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
],
previewRender: function(plainText) {
return marked.parse(plainText);
}
});
}
};
window.getMarkdownContent = function() {
return window.easyMDEInstance ? window.easyMDEInstance.value() : "";
};
</script>
@@ -0,0 +1,80 @@
@using TaxBaik.Application.DTOs
@using TaxBaik.Domain.Entities
<MudForm @ref="form">
<MudTextField @bind-Value="Model.Title" Label="제목 *"
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
<MudSelect T="int?" @bind-Value="Model.CategoryId" Label="카테고리"
Variant="Variant.Outlined" Class="mb-4">
@foreach (var category in Categories)
{
<MudSelectItem T="int?" Value="@((int?)category.Id)">@category.Name</MudSelectItem>
}
</MudSelect>
<MudTextField @bind-Value="Model.Content" Label="본문 내용 *"
Variant="Variant.Outlined" Lines="16" Required="true" RequiredError="본문 내용을 입력하세요."
Class="mb-4" />
<MudTextField @bind-Value="Model.Tags" Label="태그 (쉼표로 구분)"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoTitle" Label="SEO 제목"
Variant="Variant.Outlined" Class="mb-4" />
<MudTextField @bind-Value="Model.SeoDescription" Label="SEO 설명"
Variant="Variant.Outlined" Lines="3" Class="mb-4" />
<MudCheckBox @bind-Checked="Model.IsPublished" Label="즉시 발행" Class="mb-4" />
<div class="d-flex gap-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" @onclick="HandleSubmit">@SubmitText</MudButton>
@if (OnCancel.HasDelegate)
{
<MudButton Variant="Variant.Outlined" @onclick="OnCancel">취소</MudButton>
}
</div>
</MudForm>
@code {
[Parameter, EditorRequired]
public BlogFormModel Model { get; set; } = new();
[Parameter]
public IReadOnlyList<Category> Categories { get; set; } = [];
[Parameter]
public string SubmitText { get; set; } = "저장";
[Parameter]
public EventCallback OnSubmit { get; set; }
[Parameter]
public EventCallback OnCancel { get; set; }
private MudForm? form;
private async Task HandleSubmit()
{
if (form == null)
return;
await form.Validate();
if (!form.IsValid)
return;
await OnSubmit.InvokeAsync();
}
public class BlogFormModel
{
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public int? CategoryId { get; set; }
public string? Tags { get; set; }
public string? SeoTitle { get; set; }
public string? SeoDescription { get; set; }
public bool IsPublished { get; set; }
}
}