feat: standalone Blazor WebAssembly admin + SEO enhancements

Architecture:
- Admin UI: /admin (Standalone Blazor WebAssembly, 219 WASM files)
- Portal: /portal (Razor Pages, Cookie/OAuth auth)
- Homepage: / (Razor Pages, SSR)
- API: /api (FastEndpoints + JWT)

SEO:
- Sitemap: Public content only (blog, FAQ, announcements, contact)
- robots.txt: Exclude /admin and /portal, reference production domain
- Naver verification: naverb1813cd79ddc2ded5c5291fca5cb46c2.html ready

Technical:
- TaxBaik.Web.Client: StaticWebAssetBasePath=admin
- Server Program.cs: UseBlazorFrameworkFiles + MapFallback for SPA routing
- base href="/admin/" for client-side navigation
- blazor.webassembly.js (standalone, not web.js)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 04:03:18 +09:00
parent 64e462e57e
commit 54367696dc
112 changed files with 2701 additions and 207 deletions
@@ -0,0 +1,155 @@
@page "/admin/blog/{id:int}/edit"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@attribute [Authorize]
@using TaxBaik.Application.DTOs
@using TaxBaik.WasmClient.Components.Admin.Pages.Blog
@inject IBlogBrowserClient BlogClient
@inject ICategoryBrowserClient CategoryClient
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
@inject IDialogService DialogService
<PageTitle>포스트 수정</PageTitle>
<AdminCrudPageShell Title="포스트 수정"
Eyebrow="Content"
Subtitle="블로그 포스트를 수정합니다."
Loading="@isLoading"
SkeletonContent="@EditorSkeleton"
OnCancel="@GoBack">
@if (post == null)
{
<MudAlert Severity="Severity.Error" Class="mt-4">포스트를 찾을 수 없습니다.</MudAlert>
}
else
{
<MudPaper Class="pa-4 mt-4" Elevation="1">
<BlogForm Model="model" Categories="categories" SubmitText="저장" OnSubmit="SavePost" />
<div class="mt-4">
<MudButton Variant="Variant.Outlined" Color="Color.Error" @onclick="DeletePost">삭제</MudButton>
</div>
</MudPaper>
}
</AdminCrudPageShell>
@code {
[Parameter]
public int Id { get; set; }
private TaxBaik.Application.DTOs.BlogPostResponseDto? post;
private IReadOnlyList<Domain.Entities.Category> categories = [];
private BlogForm.BlogFormModel model = new();
private bool isLoading = true;
private RenderFragment EditorSkeleton => builder =>
{
builder.OpenComponent<AdminSkeletonRows>(0);
builder.AddAttribute(1, "Rows", 5);
builder.AddAttribute(2, "Columns", 3);
builder.CloseComponent();
};
protected override async Task OnInitializedAsync()
{
try
{
post = await BlogClient.GetByIdAsync(Id);
if (post != null)
{
categories = await CategoryClient.GetAllAsync();
MapPostToModel(post);
}
}
catch (Exception ex)
{
Snackbar.Add($"포스트 로드 실패: {ex.Message}", Severity.Error);
}
finally
{
isLoading = false;
}
}
private void MapPostToModel(TaxBaik.Application.DTOs.BlogPostResponseDto post)
{
model.Title = post.Title;
model.Content = post.Content;
model.CategoryId = post.CategoryId;
model.Tags = post.Tags;
model.SeoTitle = post.SeoTitle;
model.SeoDescription = post.SeoDescription;
model.IsPublished = post.IsPublished;
}
private void GoBack()
{
Navigation.NavigateTo("/taxbaik/admin/blog");
}
private async Task SavePost()
{
if (post == null)
return;
try
{
var result = await BlogClient.UpdateAsync(post.Id, new CreateBlogPostDto
{
Title = model.Title,
Content = model.Content,
CategoryId = model.CategoryId,
Tags = model.Tags,
SeoTitle = model.SeoTitle,
SeoDescription = model.SeoDescription,
IsPublished = model.IsPublished
});
if (result == null)
{
Snackbar.Add("저장 실패: 포스트를 저장하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 저장되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (ValidationException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"저장 실패: {ex.Message}", Severity.Error);
}
}
private async Task DeletePost()
{
if (post == null)
return;
var result = await DialogService.ShowMessageBox(
"포스트 삭제",
"정말 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
"삭제", "취소");
if (result != true)
return;
try
{
var deleted = await BlogClient.DeleteAsync(post.Id);
if (!deleted)
{
Snackbar.Add("삭제 실패: 포스트를 삭제하지 못했습니다.", Severity.Error);
return;
}
Snackbar.Add("포스트가 삭제되었습니다.", Severity.Success);
Navigation.NavigateTo("/taxbaik/admin/blog");
}
catch (Exception ex)
{
Snackbar.Add($"삭제 실패: {ex.Message}", Severity.Error);
}
}
}