KIM-JINO5 3 ماه پیش
والد
کامیت
eaa02f49c6
100فایلهای تغییر یافته به همراه7943 افزوده شده و 844 حذف شده
  1. 12 8
      .claude/settings.local.json
  2. 6 4
      Admin/Pages/Faq/List/Edit.cshtml
  3. 5 3
      Admin/Pages/Faq/List/Write.cshtml
  4. 191 0
      Admin/Pages/Forum/Attachments/CommentFile/Index.cshtml
  5. 147 0
      Admin/Pages/Forum/Attachments/CommentFile/Index.cshtml.cs
  6. 194 0
      Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml
  7. 149 0
      Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml.cs
  8. 188 0
      Admin/Pages/Forum/Attachments/PostFile/Index.cshtml
  9. 134 0
      Admin/Pages/Forum/Attachments/PostFile/Index.cshtml.cs
  10. 191 0
      Admin/Pages/Forum/Attachments/PostImage/Index.cshtml
  11. 146 0
      Admin/Pages/Forum/Attachments/PostImage/Index.cshtml.cs
  12. 76 79
      Admin/Pages/Forum/Board/Group.cshtml
  13. 111 0
      Admin/Pages/Forum/Board/Group.cshtml.cs
  14. 32 26
      Admin/Pages/Forum/Board/List/Edit.cshtml
  15. 113 0
      Admin/Pages/Forum/Board/List/Edit.cshtml.cs
  16. 4 6
      Admin/Pages/Forum/Board/List/Index.cshtml
  17. 118 0
      Admin/Pages/Forum/Board/List/Index.cshtml.cs
  18. 29 24
      Admin/Pages/Forum/Board/List/Write.cshtml
  19. 87 0
      Admin/Pages/Forum/Board/List/Write.cshtml.cs
  20. 55 40
      Admin/Pages/Forum/Board/Manager.cshtml
  21. 147 0
      Admin/Pages/Forum/Board/Manager.cshtml.cs
  22. 70 66
      Admin/Pages/Forum/Board/Meta/Comment.cshtml
  23. 126 0
      Admin/Pages/Forum/Board/Meta/Comment.cshtml.cs
  24. 100 96
      Admin/Pages/Forum/Board/Meta/Exp.cshtml
  25. 206 0
      Admin/Pages/Forum/Board/Meta/Exp.cshtml.cs
  26. 35 31
      Admin/Pages/Forum/Board/Meta/General.cshtml
  27. 96 0
      Admin/Pages/Forum/Board/Meta/General.cshtml.cs
  28. 53 49
      Admin/Pages/Forum/Board/Meta/List.cshtml
  29. 115 0
      Admin/Pages/Forum/Board/Meta/List.cshtml.cs
  30. 23 19
      Admin/Pages/Forum/Board/Meta/Notify.cshtml
  31. 95 0
      Admin/Pages/Forum/Board/Meta/Notify.cshtml.cs
  32. 29 26
      Admin/Pages/Forum/Board/Meta/NotifyTemplate.cshtml
  33. 86 0
      Admin/Pages/Forum/Board/Meta/NotifyTemplate.cshtml.cs
  34. 38 35
      Admin/Pages/Forum/Board/Meta/Permission.cshtml
  35. 103 0
      Admin/Pages/Forum/Board/Meta/Permission.cshtml.cs
  36. 59 55
      Admin/Pages/Forum/Board/Meta/View.cshtml
  37. 113 0
      Admin/Pages/Forum/Board/Meta/View.cshtml.cs
  38. 83 79
      Admin/Pages/Forum/Board/Meta/Write.cshtml
  39. 131 0
      Admin/Pages/Forum/Board/Meta/Write.cshtml.cs
  40. 14 25
      Admin/Pages/Forum/Board/Meta/_Header.cshtml
  41. 68 36
      Admin/Pages/Forum/Board/Prefix.cshtml
  42. 179 0
      Admin/Pages/Forum/Board/Prefix.cshtml.cs
  43. 11 22
      Admin/Pages/Forum/Board/_Header.cshtml
  44. 31 0
      Admin/Pages/Forum/Board/_NavTabs.cshtml
  45. 79 0
      Admin/Pages/Forum/Comments/List/Edit.cshtml
  46. 68 0
      Admin/Pages/Forum/Comments/List/Edit.cshtml.cs
  47. 203 0
      Admin/Pages/Forum/Comments/List/Index.cshtml
  48. 129 0
      Admin/Pages/Forum/Comments/List/Index.cshtml.cs
  49. 22 26
      Admin/Pages/Forum/Posts/List/Edit.cshtml
  50. 123 0
      Admin/Pages/Forum/Posts/List/Edit.cshtml.cs
  51. 254 0
      Admin/Pages/Forum/Posts/List/Index.cshtml
  52. 154 0
      Admin/Pages/Forum/Posts/List/Index.cshtml.cs
  53. 24 56
      Admin/Pages/Forum/Posts/List/Write.cshtml
  54. 92 0
      Admin/Pages/Forum/Posts/List/Write.cshtml.cs
  55. 173 0
      Admin/Pages/Forum/Reactions/Comment/Index.cshtml
  56. 112 0
      Admin/Pages/Forum/Reactions/Comment/Index.cshtml.cs
  57. 170 0
      Admin/Pages/Forum/Reactions/Post/Index.cshtml
  58. 109 0
      Admin/Pages/Forum/Reactions/Post/Index.cshtml.cs
  59. 242 0
      Admin/Pages/Forum/Reports/Comment/Index.cshtml
  60. 144 0
      Admin/Pages/Forum/Reports/Comment/Index.cshtml.cs
  61. 237 0
      Admin/Pages/Forum/Reports/Post/Index.cshtml
  62. 132 0
      Admin/Pages/Forum/Reports/Post/Index.cshtml.cs
  63. 159 0
      Admin/Pages/Forum/Trash/Comment/Index.cshtml
  64. 122 0
      Admin/Pages/Forum/Trash/Comment/Index.cshtml.cs
  65. 155 0
      Admin/Pages/Forum/Trash/Post/Index.cshtml
  66. 121 0
      Admin/Pages/Forum/Trash/Post/Index.cshtml.cs
  67. 30 13
      Admin/Pages/Member/Grade/Write.cshtml
  68. 5 1
      Admin/Pages/Popup/Edit.cshtml
  69. 5 1
      Admin/Pages/Popup/Write.cshtml
  70. 1 0
      Admin/Pages/Shared/_Pagination.cshtml
  71. 84 1
      Admin/using.cs
  72. BIN
      Admin/wwwroot/editors/post/1/5/41b090e3d9f84c14876ef87a71ea7b17.jpg
  73. 42 17
      Admin/wwwroot/js/site.js
  74. 41 0
      Application/Abstractions/Data/IAppDbContext.cs
  75. 13 0
      Application/Features/Forum/Board/Create/Command.cs
  76. 43 0
      Application/Features/Forum/Board/Create/Handler.cs
  77. 6 0
      Application/Features/Forum/Board/Delete/Command.cs
  78. 38 0
      Application/Features/Forum/Board/Delete/Handler.cs
  79. 29 0
      Application/Features/Forum/Board/Get/Handler.cs
  80. 6 0
      Application/Features/Forum/Board/Get/Query.cs
  81. 14 0
      Application/Features/Forum/Board/Get/Response.cs
  82. 69 0
      Application/Features/Forum/Board/Search/Handler.cs
  83. 6 0
      Application/Features/Forum/Board/Search/Query.cs
  84. 21 0
      Application/Features/Forum/Board/Search/Response.cs
  85. 14 0
      Application/Features/Forum/Board/Update/Command.cs
  86. 57 0
      Application/Features/Forum/Board/Update/Handler.cs
  87. 46 0
      Application/Features/Forum/BoardGroup/GetAll/Handler.cs
  88. 6 0
      Application/Features/Forum/BoardGroup/GetAll/Query.cs
  89. 19 0
      Application/Features/Forum/BoardGroup/GetAll/Response.cs
  90. 14 0
      Application/Features/Forum/BoardGroup/Save/Command.cs
  91. 84 0
      Application/Features/Forum/BoardGroup/Save/Handler.cs
  92. 4 0
      Application/Features/Forum/BoardGroup/Save/Response.cs
  93. 45 0
      Application/Features/Forum/BoardManager/GetAll/Handler.cs
  94. 5 0
      Application/Features/Forum/BoardManager/GetAll/Query.cs
  95. 16 0
      Application/Features/Forum/BoardManager/GetAll/Response.cs
  96. 23 0
      Application/Features/Forum/BoardManager/Save/Command.cs
  97. 70 0
      Application/Features/Forum/BoardManager/Save/Handler.cs
  98. 41 0
      Application/Features/Forum/BoardMeta/Get/Handler.cs
  99. 5 0
      Application/Features/Forum/BoardMeta/Get/Query.cs
  100. 18 0
      Application/Features/Forum/BoardMeta/Get/Response.cs

+ 12 - 8
.claude/settings.local.json

@@ -1,17 +1,21 @@
 {
   "permissions": {
     "allow": [
-      "Bash(dotnet build:*)",
-      "Bash(dotnet ef migrations add:*)",
-      "mcp__Desktop_Commander__start_process",
-	  "FileEdit(E:\\workspace\\bitforum.io:*)",
+      "Bash(dotnet:*)",
+      "Bash(powershell:*)",
+      "Bash(git:*)",
+      "Bash(ls:*)",
+      "Bash(dir:*)",
+      "Bash(cat:*)",
+      "Bash(echo:*)",
+      "FileEdit(E:\\workspace\\bitforum.io:*)",
       "FileRead(E:\\workspace\\bitforum.io:*)",
       "FileCreate(E:\\workspace\\bitforum.io:*)",
       "FileDelete(E:\\workspace\\bitforum.io:*)"
     ]
   },
-  "additionalDirectories": [
-	"E:\\workspace\\bitforum.io"
-  ],
-  "defaultMode": "acceptEdits"
+  "dangerouslySkipPermissions": true,
+  "allowedPaths": [
+    "E:/workspace/bitforum.io"
+  ]
 }

+ 6 - 4
Admin/Pages/Faq/List/Edit.cshtml

@@ -44,11 +44,13 @@
         </div>
         <div class="row mb-2">
             <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span>*</span> ¼ø¼­</label>
-            <div class="col-sm-10 align-content-center">
-                <div class="form-check-inline">
-                    <input type="number" asp-for="Input.Order" class="form-control" min="-9999" max="9999" required />
-                    <span asp-validation-for="Input.Order" class="text-danger"></span>
+            <div class="col-sm-10">
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
                 </div>
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">

+ 5 - 3
Admin/Pages/Faq/List/Write.cshtml

@@ -42,10 +42,12 @@
         <div class="row mb-2">
             <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span>*</span> ¼ø¼­</label>
             <div class="col-sm-10 align-content-center">
-                <div class="form-check-inline">
-                    <input type="number" asp-for="Input.Order" class="form-control" min="-9999" max="9999" required />
-                    <span asp-validation-for="Input.Order" class="text-danger"></span>
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
                 </div>
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">

+ 191 - 0
Admin/Pages/Forum/Attachments/CommentFile/Index.cshtml

@@ -0,0 +1,191 @@
+@page
+@model Admin.Pages.Forum.Attachments.CommentFile.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "댓글 파일 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-sm col-lg-auto">
+            <select name="isDisabled" id="isDisabled" class="form-select" form="fAdminSearch">
+                <option value="">- 상태 -</option>
+                <option value="false" selected="@(Model?.Query.IsDisabled == false)">활성</option>
+                <option value="true" selected="@(Model?.Query.IsDisabled == true)">비활성</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnEnable" class="btn btn-success" disabled>활성화</button>
+            <button type="button" id="btnDisable" class="btn btn-warning" disabled>비활성화</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 12%;" />
+                <col style="width: 5%;" />
+                <col style="width: 8%;" />
+                <col style="width: 8%;" />
+                <col style="width: 7%;" />
+                <col style="width: 7%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>파일명</th>
+                    <th>게시글</th>
+                    <th>댓글ID</th>
+                    <th>게시판</th>
+                    <th>크기</th>
+                    <th>다운로드</th>
+                    <th>상태</th>
+                    <th>등록일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr class="@(row.IsDisabled ? "table-secondary" : "")">
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">@row.FileName</td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 20 ? row.PostSubject[..20] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.CommentID</td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.Size.HasValue ? $"{row.Size.Value / 1024:N0} KB" : "-")</td>
+                            <td>@row.Downloads</td>
+                            <td>
+                                @if (row.IsDisabled) {
+                                    <span class="badge text-bg-secondary">비활성</span>
+                                } else {
+                                    <span class="badge text-bg-success">활성</span>
+                                }
+                            </td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="isDisabled" value="@Model.Query.IsDisabled" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+
+        // 활성화
+        document.getElementById("btnEnable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Enable";
+            form.submit();
+        });
+
+        // 비활성화
+        document.getElementById("btnDisable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Disable";
+            form.submit();
+        });
+    </script>
+}

+ 147 - 0
Admin/Pages/Forum/Attachments/CommentFile/Index.cshtml.cs

@@ -0,0 +1,147 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Attachments.CommentFile
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? CommentID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public bool? IsDisabled { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            int CommentID,
+            string FileName,
+            string Url,
+            string? Extension,
+            long? Size,
+            int Downloads,
+            bool IsDisabled,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchCommentFiles.Query(
+                Query.BoardID, Query.PostID, Query.CommentID, Query.IsDisabled,
+                Query.StartAt, Query.EndAt, Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.CommentID,
+                c.FileName,
+                c.Url,
+                c.Extension,
+                c.Size,
+                c.Downloads,
+                c.IsDisabled,
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteCommentFile.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/CommentFile/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDisableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisableCommentFile.Command(ids, true), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 비활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/CommentFile/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostEnableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisableCommentFile.Command(ids, false), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/CommentFile/Index", Query);
+        }
+    }
+}

+ 194 - 0
Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml

@@ -0,0 +1,194 @@
+@page
+@model Admin.Pages.Forum.Attachments.CommentImage.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "댓글 이미지 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-sm col-lg-auto">
+            <select name="isDisabled" id="isDisabled" class="form-select" form="fAdminSearch">
+                <option value="">- 상태 -</option>
+                <option value="false" selected="@(Model?.Query.IsDisabled == false)">활성</option>
+                <option value="true" selected="@(Model?.Query.IsDisabled == true)">비활성</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnEnable" class="btn btn-success" disabled>활성화</button>
+            <button type="button" id="btnDisable" class="btn btn-warning" disabled>비활성화</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 12%;" />
+                <col style="width: 8%;" />
+                <col style="width: 9%;" />
+                <col style="width: 8%;" />
+                <col style="width: 7%;" />
+                <col style="width: 7%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>파일명</th>
+                    <th>게시글</th>
+                    <th>댓글ID</th>
+                    <th>게시판</th>
+                    <th>크기</th>
+                    <th>해상도</th>
+                    <th>상태</th>
+                    <th>등록일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr class="@(row.IsDisabled ? "table-secondary" : "")">
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td>
+                                <img src="@row.Url" alt="@row.FileName" style="width:40px;height:40px;object-fit:cover;border-radius:.25rem;" /><br/>
+                                @row.FileName
+                            </td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 20 ? row.PostSubject[..20] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.CommentID</td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.Size.HasValue ? $"{row.Size.Value / 1024:N0} KB" : "-")</td>
+                            <td>@(row.Width.HasValue && row.Height.HasValue ? $"{row.Width}x{row.Height}" : "-")</td>
+                            <td>
+                                @if (row.IsDisabled) {
+                                    <span class="badge text-bg-secondary">비활성</span>
+                                } else {
+                                    <span class="badge text-bg-success">활성</span>
+                                }
+                            </td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="isDisabled" value="@Model.Query.IsDisabled" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+
+        // 활성화
+        document.getElementById("btnEnable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Enable";
+            form.submit();
+        });
+
+        // 비활성화
+        document.getElementById("btnDisable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Disable";
+            form.submit();
+        });
+    </script>
+}

+ 149 - 0
Admin/Pages/Forum/Attachments/CommentImage/Index.cshtml.cs

@@ -0,0 +1,149 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Attachments.CommentImage
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? CommentID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public bool? IsDisabled { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            int CommentID,
+            string FileName,
+            string Url,
+            string? Extension,
+            long? Size,
+            short? Width,
+            short? Height,
+            bool IsDisabled,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchCommentImages.Query(
+                Query.BoardID, Query.PostID, Query.CommentID, Query.IsDisabled,
+                Query.StartAt, Query.EndAt, Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.CommentID,
+                c.FileName,
+                c.Url,
+                c.Extension,
+                c.Size,
+                c.Width,
+                c.Height,
+                c.IsDisabled,
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteCommentImage.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/CommentImage/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDisableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisableCommentImage.Command(ids, true), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 비활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/CommentImage/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostEnableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisableCommentImage.Command(ids, false), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/CommentImage/Index", Query);
+        }
+    }
+}

+ 188 - 0
Admin/Pages/Forum/Attachments/PostFile/Index.cshtml

@@ -0,0 +1,188 @@
+@page
+@model Admin.Pages.Forum.Attachments.PostFile.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "게시글 파일 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-sm col-lg-auto">
+            <select name="isDisabled" id="isDisabled" class="form-select" form="fAdminSearch">
+                <option value="">- 상태 -</option>
+                <option value="false" selected="@(Model?.Query.IsDisabled == false)">활성</option>
+                <option value="true" selected="@(Model?.Query.IsDisabled == true)">비활성</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnEnable" class="btn btn-success" disabled>활성화</button>
+            <button type="button" id="btnDisable" class="btn btn-warning" disabled>비활성화</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 15%;" />
+                <col style="width: 10%;" />
+                <col style="width: 8%;" />
+                <col style="width: 7%;" />
+                <col style="width: 7%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>파일명</th>
+                    <th>게시글</th>
+                    <th>게시판</th>
+                    <th>크기</th>
+                    <th>다운로드</th>
+                    <th>상태</th>
+                    <th>등록일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr class="@(row.IsDisabled ? "table-secondary" : "")">
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">@row.FileName</td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 25 ? row.PostSubject[..25] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.Size.HasValue ? $"{row.Size.Value / 1024:N0} KB" : "-")</td>
+                            <td>@row.Downloads</td>
+                            <td>
+                                @if (row.IsDisabled) {
+                                    <span class="badge text-bg-secondary">비활성</span>
+                                } else {
+                                    <span class="badge text-bg-success">활성</span>
+                                }
+                            </td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="isDisabled" value="@Model.Query.IsDisabled" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+
+        // 활성화
+        document.getElementById("btnEnable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Enable";
+            form.submit();
+        });
+
+        // 비활성화
+        document.getElementById("btnDisable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Disable";
+            form.submit();
+        });
+    </script>
+}

+ 134 - 0
Admin/Pages/Forum/Attachments/PostFile/Index.cshtml.cs

@@ -0,0 +1,134 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Attachments.PostFile
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public bool? IsDisabled { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            string FileName,
+            string Url,
+            string? Extension,
+            long? Size,
+            int Downloads,
+            bool IsDisabled,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchPostFiles.Query(
+                Query.BoardID, Query.PostID, Query.IsDisabled,
+                Query.StartAt, Query.EndAt, Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num, c.ID, c.BoardID, c.BoardName, c.PostID, c.PostSubject,
+                c.FileName, c.Url, c.Extension, c.Size,
+                c.Downloads, c.IsDisabled, c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePostFile.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/PostFile/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDisableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisablePostFile.Command(ids, true), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 비활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/PostFile/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostEnableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisablePostFile.Command(ids, false), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/PostFile/Index", Query);
+        }
+    }
+}

+ 191 - 0
Admin/Pages/Forum/Attachments/PostImage/Index.cshtml

@@ -0,0 +1,191 @@
+@page
+@model Admin.Pages.Forum.Attachments.PostImage.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "게시글 이미지 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-sm col-lg-auto">
+            <select name="isDisabled" id="isDisabled" class="form-select" form="fAdminSearch">
+                <option value="">- 상태 -</option>
+                <option value="false" selected="@(Model?.Query.IsDisabled == false)">활성</option>
+                <option value="true" selected="@(Model?.Query.IsDisabled == true)">비활성</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnEnable" class="btn btn-success" disabled>활성화</button>
+            <button type="button" id="btnDisable" class="btn btn-warning" disabled>비활성화</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 15%;" />
+                <col style="width: 8%;" />
+                <col style="width: 8%;" />
+                <col style="width: 7%;" />
+                <col style="width: 7%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>이미지</th>
+                    <th>게시글</th>
+                    <th>게시판</th>
+                    <th>크기</th>
+                    <th>해상도</th>
+                    <th>상태</th>
+                    <th>등록일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr class="@(row.IsDisabled ? "table-secondary" : "")">
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td>
+                                <img src="@row.Url" alt="@row.FileName" style="width:40px;height:40px;object-fit:cover;border-radius:.25rem;" /><br/>
+                                @row.FileName
+                            </td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 25 ? row.PostSubject[..25] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.Size.HasValue ? $"{row.Size.Value / 1024:N0} KB" : "-")</td>
+                            <td>@(row.Width.HasValue && row.Height.HasValue ? $"{row.Width}x{row.Height}" : "-")</td>
+                            <td>
+                                @if (row.IsDisabled) {
+                                    <span class="badge text-bg-secondary">비활성</span>
+                                } else {
+                                    <span class="badge text-bg-success">활성</span>
+                                }
+                            </td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="isDisabled" value="@Model.Query.IsDisabled" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+
+        // 활성화
+        document.getElementById("btnEnable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Enable";
+            form.submit();
+        });
+
+        // 비활성화
+        document.getElementById("btnDisable")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=Disable";
+            form.submit();
+        });
+    </script>
+}

+ 146 - 0
Admin/Pages/Forum/Attachments/PostImage/Index.cshtml.cs

@@ -0,0 +1,146 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Attachments.PostImage
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public bool? IsDisabled { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            string FileName,
+            string Url,
+            string? Extension,
+            long? Size,
+            short? Width,
+            short? Height,
+            bool IsDisabled,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchPostImages.Query(
+                Query.BoardID, Query.PostID, Query.IsDisabled,
+                Query.StartAt, Query.EndAt, Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.FileName,
+                c.Url,
+                c.Extension,
+                c.Size,
+                c.Width,
+                c.Height,
+                c.IsDisabled,
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePostImage.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/PostImage/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDisableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisablePostImage.Command(ids, true), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 비활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/PostImage/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostEnableAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new ToggleDisablePostImage.Command(ids, false), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 활성화되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Attachments/PostImage/Index", Query);
+        }
+    }
+}

+ 76 - 79
Admin/Pages/Forum/Board/Group.cshtml

@@ -1,5 +1,5 @@
-@model Admin.ViewModels.Forum.Board.Group.IndexViewModel
-@using Library.Extensions
+@page
+@model Admin.Pages.Forum.Board.GroupModel
 @{
     ViewData["Title"] = "게시판 분류";
 }
@@ -15,87 +15,85 @@
             Total : @Model.Total
         </div>
         <div class="col text-end">
-            <button type="button" id="btnAdd" class="btn btn-sm btn-primary" form="fAdminWrite">추가</button>
-            <button type="submit" id="btnSave" class="btn btn-sm btn-success" form="fAdminWrite">저장</button>
+            <button type="button" id="btnAdd" class="btn btn-primary" form="fAdminWrite">추가</button>
+            <button type="submit" id="btnSave" class="btn btn-success" form="fAdminWrite">저장</button>
         </div>
     </div>
 
     <div class="table-responsive">
-        <form id="fAdminWrite" asp-action="Save" method="post" accept-charset="utf-8" autocomplete="off">
-            <table class="table table-striped table-bordered table-hover mt-3">
-                <caption>
-                    게시판 분류에 등록된 게시판이 있다면 삭제가 불가합니다.<br/>
-                    게시판 분류를 삭제하려면 등록된 게시판을 먼저 삭제해주세요.
-                </caption>
-                <colgroup>
-                    <col width="5%" />
-                    <col width="*" />
-                    <col width="20%" />
-                    <col width="*" />
-                    <col width="*" />
-                    <col width="*" />
-                    <col width="*" />
-                    <col width="*" />
-                    <col width="*" />
-                    <col width="*" />
-                </colgroup>
-                <thead>
+        <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off"></form>
+
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <caption>
+                게시판 분류에 등록된 게시판이 있다면 삭제가 불가합니다.<br/>
+                게시판 분류를 삭제하려면 등록된 게시판을 먼저 삭제해주세요.
+            </caption>
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 20%;" />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+                <col />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>ID</th>
+                    <th>Code</th>
+                    <th>분류 명</th>
+                    <th>순서</th>
+                    <th>게시판 수</th>
+                    <th>게시글 수</th>
+                    <th>댓글 수</th>
+                    <th>등록일시</th>
+                    <th>수정일시</th>
+                    <th>비고</th>
+                </tr>
+            </thead>
+            <tbody id="boardGroupList">
+                @if (Model.List == null || Model.List.Count <= 0)
+                {
                     <tr>
-                        <th>ID</th>
-                        <th>Code</th>
-                        <th>분류 명</th>
-                        <th>순서</th>
-                        <th>게시판 수</th>
-                        <th>게시글 수</th>
-                        <th>댓글 수</th>
-                        <th>등록일시</th>
-                        <th>수정일시</th>
-                        <th>비고</th>
+                        <td colspan="10">No Data.</td>
                     </tr>
-                </thead>
-                <tbody id="boardGroupList">
-                    @if (Model.Data == null || !Model.Data.Any())
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
                     {
+                        var index = row.Index;
+
                         <tr>
-                            <td colspan="10">No Data.</td>
+                            <td>
+                                <input type="text" readonly class="form-control-plaintext text-center @(row.Boards > 0 ? "text-white bg-danger" : "")" value="@row.Num" />
+                                <input type="hidden" name="request[@index].ID" readonly class="form-control-plaintext text-center" required form="fAdminWrite" value="@row.ID" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Code" class="form-control" maxlength="30" required form="fAdminWrite" value="@row.Code" />
+                            </td>
+                            <td>
+                                <input type="text" name="request[@index].Name" class="form-control" maxlength="255" required form="fAdminWrite" value="@row.Name" />
+                            </td>
+                            <td>
+                                <input type="number" name="request[@index].Order" class="form-control" min="-9999" max="9999" required form="fAdminWrite" value="@row.Order" />
+                            </td>
+                            <td>@row.Boards.ToString("N0")</td>
+                            <td>@row.Posts.ToString("N0")</td>
+                            <td>@row.Comments.ToString("N0")</td>
+                            <td>@row.CreatedAt</td>
+                            <td>@row.UpdatedAt</td>
+                            <td>
+                                <button type="button" class="btn btn-sm btn-danger btn-delete">삭제</button>
+                            </td>
                         </tr>
                     }
-                    else
-                    {
-                        @foreach (var row in Model.Data)
-                        {
-                            var index = Model.Data.IndexOf(row);
-
-                            <tr>
-                                <td>
-                                    <input type="text" name="request[@index].ID" readonly class="form-control-plaintext text-center" required value="@row.ID" />
-                                    @if(row.Boards > 0) {
-                                        <span class="badge text-bg-danger">삭제 X</span>
-                                    }
-                                </td>
-                                <td>
-                                    <input type="text" name="request[@index].Code" class="form-control" maxlength="30" required value="@row.Code" />
-                                </td>
-                                <td>
-                                    <input type="text" name="request[@index].Name" class="form-control" maxlength="255" required value="@row.Name" />
-                                </td>
-                                <td>
-                                    <input type="number" name="request[@index].Order" class="form-control" min="-999" max="999" required value="@row.Order" />
-                                </td>
-                                <td>@row.Boards.ToString("N0")</td>
-                                <td>@row.Posts.ToString("N0")</td>
-                                <td>@row.Comments.ToString("N0")</td>
-                                <td>@row.CreatedAt.GetDateAt()</td>
-                                <td>@(row.UpdatedAt.GetDateAt() ?? "-")</td>
-                                <td>
-                                    <button type="button" class="btn btn-sm btn-danger btn-delete">삭제</button>
-                                </td>
-                            </tr>
-                        }
-                    }
-                </tbody>
-            </table>
-        </form>
+                }
+            </tbody>
+        </table>
     </div>
 </div>
 
@@ -121,7 +119,7 @@
                     <td>
                         <input type="text" name="request[${total}].Name" class="form-control" maxlength="255" required form="fAdminWrite" />
                     </td>
-                        <td>
+                    <td>
                         <input type="number" name="request[${total}].Order" class="form-control" min="-999" max="999" required form="fAdminWrite" />
                     </td>
                     <td>-</td>
@@ -130,7 +128,7 @@
                     <td>${now}</td>
                     <td>-</td>
                     <td>
-                        <button type="button" class="btn btn-danger btn-sm btn-delete">삭제</button>
+                        <button type="button" class="btn btn-danger btn-delete">삭제</button>
                     </td>
                 </tr>
             `;
@@ -150,16 +148,16 @@
                     `<tr><td colspan="10">No Data.</td></tr>`
                 );
                 total = 0;
-                } else {
+            } else {
                 recalculateIndices();
-                }
+            }
         });
 
         // 저장
         $(document).on("click", "#btnSave", function() {
             if (confirm("저장 하시겠습니까?")) {
                 let form = document.getElementById("fAdminWrite");
-                    if (form.checkValidity()) { // HTML5 폼 검증 수행
+                if (form.checkValidity()) {
                     form.submit();
                 } else {
                     form.reportValidity();
@@ -186,7 +184,6 @@
                         }
                     });
 
-                // 인덱스 기반으로 라벨의 `for` 속성도 수정
                 $(tr)
                     .find("label")
                     .each(function() {

+ 111 - 0
Admin/Pages/Forum/Board/Group.cshtml.cs

@@ -0,0 +1,111 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Board
+{
+    public class GroupModel(IMediator mediator) : PageModel
+    {
+        public int Total { get; private set; } = 0;
+
+        public List<(
+            int Num,
+            int ID,
+            int Index,
+            string Code,
+            string Name,
+            short Order,
+            short Boards,
+            int Posts,
+            int Comments,
+            string? UpdatedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        [BindProperty(Name = "request")]
+        public List<InputModel> Input { get; private set; } = [];
+
+        public List<InputModel> Data { get; private set; } = [];
+
+        public sealed class InputModel
+        {
+            public int? ID { get; set; }
+
+            [Required]
+            [StringLength(30)]
+            public required string Code { get; set; }
+
+            [Required]
+            [StringLength(255)]
+            public required string Name { get; set; }
+
+            [Range(-999, 999)]
+            public short Order { get; set; }
+        }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var result = await mediator.Send(new GetBoardGroups.Query(), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.Index,
+                c.Code,
+                c.Name,
+                c.Order,
+                c.Boards,
+                c.Posts,
+                c.Comments,
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Data = [..result.List.Select(x => new InputModel
+            {
+                ID = x.ID,
+                Code = x.Code,
+                Name = x.Name,
+                Order = x.Order
+            })];
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception();
+                }
+
+                var cmd = new SaveBoardGroups.Command(
+                    [..Input.Select(x => new SaveBoardGroups.Command.Row(
+                        x.ID,
+                        x.Code,
+                        x.Name,
+                        x.Order
+                    ))]
+                );
+
+                var response = await mediator.Send(cmd, ct);
+
+                TempData["SuccessMessage"] = $"저장 완료 (추가: {response.Inserted}, 수정: {response.Updated}, 삭제: {response.Deleted})";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Board/Group");
+        }
+    }
+}

+ 32 - 26
Admin/Pages/Forum/Board/List/Edit.cshtml

@@ -1,73 +1,79 @@
-@page
+@page "{id:int}"
 @model Admin.Pages.Forum.Board.List.EditModel
 @{
     ViewData["Title"] = "게시판 관리 - 기본";
+    ViewData["Sector"] = "Edit";
     ViewData["BoardID"] = Model.BoardID;
     ViewData["BoardList"] = Model.BoardList;
     ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
+    <partial name="../Meta/_Header" />
     <partial name="_StatusMessage" />
-    <partial name="_navTabs" />
+    <partial name="_NavTabs" />
 
     <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
-        <input type="hidden" asp-for="ID" />
+        <input type="hidden" asp-for="Input.ID" />
 
         <div class="row mb-2">
-            <label for="BoardGroupID" class="col-sm-2 col-form-label"><span>*</span> 게시판 분류</label>
+            <label for="Input_BoardGroupID" class="col-sm-2 col-form-label"><span>*</span> 게시판 분류</label>
             <div class="col-sm-10">
-                <select id="BoardGroupID" name="BoardGroupID" class="form-select w-auto" required asp-items="@Model.BoardGroupList">
-                    <option value="">게시판 분류 선택</option>
-                </select>
-                <span asp-validation-for="BoardGroupID" class="text-danger"></span>
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.BoardGroupID" class="form-select" required asp-items="@Model.BoardGroupList">
+                            <option value="">게시판 분류 선택</option>
+                        </select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.BoardGroupID" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="Code" class="col-sm-2 col-form-label"><span>*</span> 주소(Code)</label>
+            <label for="Input_Code" class="col-sm-2 col-form-label"><span>*</span> 주소(Code)</label>
             <div class="col-sm-10">
-                <input type="text" asp-for="Code" class="form-control" required maxlength="70" placeholder="중복 시 등록이 불가합니다. 70자 이내" />
-                <span asp-validation-for="Code" class="text-danger"></span>
+                <input type="text" asp-for="Input.Code" class="form-control" required maxlength="70" placeholder="중복 시 등록이 불가합니다. 70자 이내" />
+                <span asp-validation-for="Input.Code" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="Name" class="col-sm-2 col-form-label"><span>*</span> 게시판 명</label>
+            <label for="Input_Name" class="col-sm-2 col-form-label"><span>*</span> 게시판 명</label>
             <div class="col-sm-10">
-                <input type="text" asp-for="Name" class="form-control" required maxlength="70" />
-                <span asp-validation-for="Name" class="text-danger"></span>
+                <input type="text" asp-for="Input.Name" class="form-control" required maxlength="70" />
+                <span asp-validation-for="Input.Name" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="Order" class="col-sm-2 col-form-label"><span>*</span> 순서</label>
+            <label for="Input_Order" class="col-sm-2 col-form-label"><span>*</span> 순서</label>
             <div class="col-sm-10">
-                <input type="number" asp-for="Order" class="form-control" min="-999" max="999" required />
-                <span asp-validation-for="Order" class="text-danger"></span>
+                <input type="number" asp-for="Input.Order" class="form-control" min="-999" max="999" required />
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="IsSearch" class="col-sm-2 col-form-label">검색 여부</label>
+            <label for="Input_IsSearch" class="col-sm-2 col-form-label">검색 여부</label>
             <div class="col-sm-10 align-content-center">
                 <div class="form-check-inline">
-                    <input type="checkbox" asp-for="IsSearch" class="form-check-input" />
-                    <label class="form-check-label" for="IsSearch">검색에 포함합니다.</label>
-                    <span asp-validation-for="IsSearch" class="text-danger"></span>
+                    <input type="checkbox" asp-for="Input.IsSearch" class="form-check-input" />
+                    <label class="form-check-label" for="Input_IsSearch">검색에 포함합니다.</label>
+                    <span asp-validation-for="Input.IsSearch" class="text-danger"></span>
                 </div>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <label for="Input_IsActive" class="col-sm-2 col-form-label">사용 여부</label>
             <div class="col-sm-10 align-content-center">
                 <div class="form-check-inline">
-                    <input type="checkbox" asp-for="IsActive" class="form-check-input" />
-                    <label class="form-check-label" for="IsActive">사용합니다.</label>
-                    <span asp-validation-for="IsActive" class="text-danger"></span>
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" for="Input_IsActive">사용합니다.</label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
                 </div>
             </div>
         </div>
         <hr/>
         <div class="d-grid gap-2 text-center d-md-block">
             <button type="submit" class="btn btn-success">저장</button>
-            <a href="/Forum/Board/List?@Model.QueryString" class="btn btn-secondary">취소</a>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br/>
     </form>

+ 113 - 0
Admin/Pages/Forum/Board/List/Edit.cshtml.cs

@@ -0,0 +1,113 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Board.List;
+
+public class EditModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public string? QueryString { get; set; }
+    public List<SelectListItem> BoardGroupList { get; set; } = [];
+
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("ID")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        public int ID { get; set; }
+
+        [DisplayName("게시판 분류")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        public int BoardGroupID { get; set; }
+
+        [DisplayName("주소(Code)")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        [StringLength(70, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+        public string Code { get; set; } = null!;
+
+        [DisplayName("게시판 명")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        [StringLength(70, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+        public string Name { get; set; } = null!;
+
+        [DisplayName("순서")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        [Range(-999, 999, ErrorMessage = "{0} 범위는 {2} ~ {1} 입니다.")]
+        public short Order { get; set; } = 0;
+
+        [DisplayName("검색 여부")]
+        public bool IsSearch { get; set; } = false;
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardGroupList = [..(await mediator.Send(new GetBoardGroups.Query(), ct)).List.Select(c => new SelectListItem
+        {
+            Value = c.ID.ToString(),
+            Text = c.Name
+        })];
+
+        var result = await mediator.Send(new GetBoard.Query(id), ct);
+
+        BoardID = result.ID;
+
+        // 같은 분류의 게시판 목록 조회
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        Input = new InputModel
+        {
+            ID = result.ID,
+            BoardGroupID = result.BoardGroupID,
+            Code = result.Code,
+            Name = result.Name,
+            Order = result.Order,
+            IsSearch = result.IsSearch,
+            IsActive = result.IsActive
+        };
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoard.Command(
+                Input.ID,
+                Input.BoardGroupID,
+                Input.Code,
+                Input.Name,
+                Input.Order,
+                Input.IsSearch,
+                Input.IsActive
+            ), ct);
+
+            TempData["SuccessMessage"] = $"{Input.Name} 게시판이 수정되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/List/Edit/{Input.ID}{Request.QueryString}");
+    }
+}

+ 4 - 6
Admin/Pages/Forum/Board/List/Index.cshtml

@@ -15,14 +15,14 @@
             Total : @Model.Total
         </div>
         <div class="col text-end">
-            <button type="button" id="btnListDelete" class="btn btn-sm btn-danger" disabled>삭제</button>
-            <button type="button" id="btnListUpdate" class="btn btn-sm btn-primary" disabled>수정</button>
-            <a class="btn btn-sm btn-success" asp-page="/Forum/Board/List/Write">추가</a>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+            <button type="button" id="btnListUpdate" class="btn btn-primary" disabled>수정</button>
+            <a class="btn btn-success" asp-page="/Forum/Board/List/Write">추가</a>
         </div>
     </div>
 
     <div class="table-responsive">
-        <form name="f_admin_list" id="fAdminList" method="post" accept-charset="utf-8" autocomplete="off">
+        <form name="f_admin_list" id="fAdminList" method="post" accept-charset="utf-8" autocomplete="off"></form>
 
         <table class="table table-bordered mt-3">
             <colgroup>
@@ -119,7 +119,5 @@
                     }
                 }
         </table>
-
-        </form>
     </div>
 </div>

+ 118 - 0
Admin/Pages/Forum/Board/List/Index.cshtml.cs

@@ -0,0 +1,118 @@
+using SharedKernel.Extensions;
+using SharedKernel.Helpers;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Board.List;
+
+public class IndexModel(IMediator mediator) : PageModel
+{
+    [BindProperty(SupportsGet = true)]
+    public QueryParams Query { get; set; } = new();
+
+    public sealed class QueryParams
+    {
+        [Range(1, int.MaxValue)]
+        [DisplayName("페이지 번호")]
+        public int PageNum { get; set; } = 1;
+
+        [Range(1, 100)]
+        [DisplayName("페이지 목록 수")]
+        public ushort PerPage { get; set; } = 10;
+
+        [DisplayName("게시판 분류")]
+        public int? BoardGroupID { get; set; }
+
+        [DisplayName("검색어")]
+        public string? Keyword { get; set; }
+    }
+
+    public int Total { get; set; }
+
+    public List<(int ID, string Name)> BoardGroups { get; set; } = [];
+
+    public List<(
+        int Num,
+        int ID,
+        int BoardGroupID,
+        string BoardGroupName,
+        string Code,
+        string Name,
+        short Order,
+        bool IsSearch,
+        bool IsActive,
+        int Posts,
+        int Comments,
+        string? UpdatedAt,
+        string CreatedAt,
+        string EditURL,
+        List<SelectListItem> SelectBoardGroup
+    )> Data { get; set; } = [];
+
+    public Pagination? Pagination { get; set; }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        if (!ModelState.IsValid)
+        {
+            return;
+        }
+
+        var groups = await mediator.Send(new GetBoardGroups.Query(), ct);
+        BoardGroups = [..groups.List.Select(c => (c.ID, c.Name))];
+
+        var selectItems = groups.List.Select(c => new SelectListItem
+        {
+            Value = c.ID.ToString(),
+            Text = c.Name
+        }).ToList();
+
+        var result = await mediator.Send(new SearchBoards.Query(Query.BoardGroupID, Query.Keyword, Query.PageNum, Query.PerPage), ct);
+
+        Total = result.Total;
+        Data = [..result.List.Select(c => (
+            c.Num,
+            c.ID,
+            c.BoardGroupID,
+            c.BoardGroupName,
+            c.Code,
+            c.Name,
+            c.Order,
+            c.IsSearch,
+            c.IsActive,
+            c.Posts,
+            c.Comments,
+            c.UpdatedAt.GetDateAt() ?? "-",
+            c.CreatedAt.GetDateAt(),
+            EditURL: $"/Forum/Board/List/Edit/{c.ID}{Request.QueryString}",
+            SelectBoardGroup: selectItems.Select(s => new SelectListItem
+            {
+                Value = s.Value,
+                Text = s.Text,
+                Selected = s.Value == c.BoardGroupID.ToString()
+            }).ToList()
+        ))];
+
+        Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+    }
+
+    public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+    {
+        try
+        {
+            await mediator.Send(new DeleteBoard.Command(ids), ct);
+
+            TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return RedirectToPage("/Forum/Board/List/Index", Query);
+    }
+}

+ 29 - 24
Admin/Pages/Forum/Board/List/Write.cshtml

@@ -1,4 +1,5 @@
-@model Admin.ViewModels.Forum.Board.List.WriteViewModel
+@page
+@model Admin.Pages.Forum.Board.List.WriteModel
 @{
     ViewData["Title"] = "게시판 등록";
 }
@@ -9,54 +10,58 @@
 
     <partial name="_StatusMessage" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/List/Create" enctype="multipart/form-data">
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off">
         <div class="row mb-2">
-            <label for="BoardGroupID" class="col-sm-2 col-form-label"><span>*</span> 게시판 분류</label>
+            <label for="Input_BoardGroupID" class="col-sm-2 col-form-label"><span>*</span> 게시판 분류</label>
             <div class="col-sm-10">
-                <select id="BoardGroupID" name="BoardGroupID" class="form-select w-auto" required asp-items="@Model.BoardGroupList">
-                    <option value="">게시판 분류 선택</option>
-                </select>
-                <span asp-validation-for="BoardGroupID" class="text-danger"></span>
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <select asp-for="Input.BoardGroupID" class="form-select" required asp-items="@Model.BoardGroupList">
+                            <option value="">게시판 분류 선택</option>
+                        </select>
+                    </div>
+                </div>
+                <span asp-validation-for="Input.BoardGroupID" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="Code" class="col-sm-2 col-form-label"><span>*</span> 주소(Code)</label>
+            <label for="Input_Code" class="col-sm-2 col-form-label"><span>*</span> 주소(Code)</label>
             <div class="col-sm-10">
-                <input type="text" asp-for="Code" class="form-control" required maxlength="70" placeholder="중복 시 등록이 불가합니다. 70자 이내" />
-                <span asp-validation-for="Code" class="text-danger"></span>
+                <input type="text" asp-for="Input.Code" class="form-control" required maxlength="70" placeholder="중복 시 등록이 불가합니다. 70자 이내" />
+                <span asp-validation-for="Input.Code" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="Name" class="col-sm-2 col-form-label"><span>*</span> 게시판 명</label>
+            <label for="Input_Name" class="col-sm-2 col-form-label"><span>*</span> 게시판 명</label>
             <div class="col-sm-10">
-                <input type="text" asp-for="Name" class="form-control" required maxlength="70" />
-                <span asp-validation-for="Name" class="text-danger"></span>
+                <input type="text" asp-for="Input.Name" class="form-control" required maxlength="70" />
+                <span asp-validation-for="Input.Name" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="Order" class="col-sm-2 col-form-label"><span>*</span> 순서</label>
+            <label for="Input_Order" class="col-sm-2 col-form-label"><span>*</span> 순서</label>
             <div class="col-sm-10">
-                <input type="number" asp-for="Order" class="form-control" min="-9999" max="9999" required />
-                <span asp-validation-for="Order" class="text-danger"></span>
+                <input type="number" asp-for="Input.Order" class="form-control" min="-9999" max="9999" required />
+                <span asp-validation-for="Input.Order" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="IsSearch" class="col-sm-2 col-form-label">검색 여부</label>
+            <label for="Input_IsSearch" class="col-sm-2 col-form-label">검색 여부</label>
             <div class="col-sm-10 align-content-center">
                 <div class="form-check-inline">
-                    <input type="checkbox" asp-for="IsSearch" class="form-check-input" />
-                    <label class="form-check-label" for="IsSearch">검색에 포함합니다.</label>
-                    <span asp-validation-for="IsSearch" class="text-danger"></span>
+                    <input type="checkbox" asp-for="Input.IsSearch" class="form-check-input" />
+                    <label class="form-check-label" for="Input_IsSearch">검색에 포함합니다.</label>
+                    <span asp-validation-for="Input.IsSearch" class="text-danger"></span>
                 </div>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="IsActive" class="col-sm-2 col-form-label">사용 여부</label>
+            <label for="Input_IsActive" class="col-sm-2 col-form-label">사용 여부</label>
             <div class="col-sm-10 align-content-center">
                 <div class="form-check-inline">
-                    <input type="checkbox" asp-for="IsActive" class="form-check-input" />
-                    <label class="form-check-label" for="IsActive">사용합니다.</label>
-                    <span asp-validation-for="IsActive" class="text-danger"></span>
+                    <input type="checkbox" asp-for="Input.IsActive" class="form-check-input" />
+                    <label class="form-check-label" for="Input_IsActive">사용합니다.</label>
+                    <span asp-validation-for="Input.IsActive" class="text-danger"></span>
                 </div>
             </div>
         </div>

+ 87 - 0
Admin/Pages/Forum/Board/List/Write.cshtml.cs

@@ -0,0 +1,87 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Board.List;
+
+public class WriteModel(IMediator mediator) : PageModel
+{
+    public string? QueryString { get; set; }
+    public List<SelectListItem> BoardGroupList { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        [DisplayName("게시판 분류")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        public int BoardGroupID { get; set; }
+
+        [DisplayName("주소(Code)")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        [StringLength(70, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+        public string Code { get; set; } = null!;
+
+        [DisplayName("게시판 명")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        [StringLength(70, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+        public string Name { get; set; } = null!;
+
+        [DisplayName("순서")]
+        [Required(ErrorMessage = "{0}은 필수입니다.")]
+        [Range(-9999, 9999, ErrorMessage = "{0} 범위는 {2} ~ {1} 입니다.")]
+        public short Order { get; set; } = 0;
+
+        [DisplayName("검색 여부")]
+        public bool IsSearch { get; set; } = false;
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; } = false;
+    }
+
+    public async Task OnGetAsync(CancellationToken ct)
+    {
+        BoardGroupList = [..(await mediator.Send(new GetBoardGroups.Query(), ct)).List.Select(c => new SelectListItem
+        {
+            Value = c.ID.ToString(),
+            Text = c.Name
+        })];
+
+        QueryString = Request.QueryString.ToString();
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new CreateBoard.Command(
+                Input.BoardGroupID,
+                Input.Code,
+                Input.Name,
+                Input.Order,
+                Input.IsSearch,
+                Input.IsActive
+            ), ct);
+
+            TempData["SuccessMessage"] = $"{Input.Name} 게시판이 등록되었습니다.";
+
+            return RedirectToPage("/Forum/Board/List/Index");
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+
+            return Redirect($"/Forum/Board/List/Write?{Request.QueryString}");
+        }
+    }
+}

+ 55 - 40
Admin/Pages/Forum/Board/Manager.cshtml

@@ -1,48 +1,33 @@
-@model Admin.ViewModels.Forum.Board.Manager.IndexViewModel
-@using Library.Extensions
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.ManagerModel
 @{
     ViewData["Title"] = "게시판 관리 - 관리자";
+    ViewData["Sector"] = "Manager";
     ViewData["BoardID"] = Model.BoardID;
     ViewData["BoardList"] = Model.BoardList;
     ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/_Navbar.cshtml" />
-
-    <form id="fAdminWrite" asp-action="Create" method="post" accept-charset="utf-8" autocomplete="off">
-        <input type="hidden" name="boardID" value="@Model.BoardID" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
+    <form id="fAdminWrite" method="post" asp-page-handler="Create" accept-charset="utf-8" autocomplete="off">
         <div class="row g-2">
             <label class="col-lg-1 col-form-label">관리자</label>
             <div class="col-12 col-sm-auto">
-                @if (Model.AdminList != null)
-                {
-                    <select name="UserId" class="form-select" required>
-                        <option value="">선택하세요.</option>
-                        @if (Model.AdminList != null)
-                        {
-                            @foreach (var row in Model.AdminList)
-                            {
-                                <option value="@row.Id">
-                                    @row.Email - @row.FullName
-                                </option>
-                            }
-                        }
-                    </select>
-                }
+                <input type="number" name="Input.MemberID" class="form-control" required placeholder="회원 ID" />
             </div>
             <div class="col-auto col-sm-auto col-form-label">
                 <div class="form-check form-check-inline">
-                    <input type="checkbox" name="CanEdit" id="CanEdit" class="form-check-input" checked value="true" />
+                    <input type="checkbox" name="Input.CanEdit" id="CanEdit" class="form-check-input" checked value="true" />
                     <label for="CanEdit" class="form-check-label">수정</label>
                 </div>
             </div>
             <div class="col-auto col-sm-auto col-form-label">
                 <div class="form-check form-check-inline">
-                    <input type="checkbox" name="CanDelete" id="CanDelete" class="form-check-input" checked value="true" />
+                    <input type="checkbox" name="Input.CanDelete" id="CanDelete" class="form-check-input" checked value="true" />
                     <label for="CanDelete" class="form-check-label">삭제</label>
                 </div>
             </div>
@@ -51,7 +36,7 @@
             </div>
         </div>
     </form>
-    
+
     <hr/>
 
     <div class="row g-2 align-items-end">
@@ -59,15 +44,13 @@
             Total : @Model.Total.ToString("N0")
         </div>
         <div class="col text-end">
-            <button type="button" id="btnListDelete" class="btn btn-sm btn-danger" form="fAdminList" data-action="/Forum/Board/Manager/@Model.BoardID/Delete" disabled>삭제</button>
-            <button type="submit" id="btnListSave" class="btn btn-sm btn-success" form="fAdminList" @(Model.Total <= 0 ? "disabled" : "")> 저장</button>
+            <button type="button" id="btnFListDelete" class="btn btn-sm btn-danger" form="fAdminList" disabled>삭제</button>
+            <button type="submit" id="btnFListSave" class="btn btn-sm btn-success" form="fAdminList" @(Model.Total <= 0 ? "disabled" : "")> 저장</button>
         </div>
     </div>
 
     <div class="table-responsive">
-        <form id="fAdminList" asp-action="Update" method="post" accept-charset="utf-8" autocomplete="off">
-            <input type="hidden" name="BoardID" value="@Model.BoardID" />
-
+        <form id="fAdminList" method="post" asp-page-handler="Save" accept-charset="utf-8" autocomplete="off">
             <table class="table table-striped table-bordered table-hover mt-3">
                 <caption>
                     게시판을 운영할 수 있는 관리자를 등록합니다.
@@ -115,24 +98,23 @@
                                         <label for="CheckList_@index" class="form-check-label">@row.ID</label>
                                     </div>
 
-                                    <input type="hidden" name="Items[@index].ID" value="@row.ID" />
-                                    <input type="hidden" name="Items[@index].UserId" value="@row.UserId" />
+                                    <input type="hidden" name="UpdateItems[@index].ID" value="@row.ID" />
                                 </td>
-                                <td>@row.User.Email</td>
+                                <td>@row.MemberEmail</td>
                                 <td>
                                     <div class="form-check form-check-inline">
-                                        <input type="checkbox" name="Items[@index].CanEdit" id="Items_@(index)_CanEdit" class="form-check-input" checked="@row.CanEdit" value="true" />
-                                        <label for="Items_@(index)_CanEdit" class="form-check-label">수정</label>
+                                        <input type="checkbox" name="UpdateItems[@index].CanEdit" id="UpdateItems_@(index)_CanEdit" class="form-check-input" checked="@row.CanEdit" value="true" />
+                                        <label for="UpdateItems_@(index)_CanEdit" class="form-check-label">수정</label>
                                     </div>
                                 </td>
                                 <td>
                                     <div class="form-check form-check-inline">
-                                        <input type="checkbox" name="Items[@index].CanDelete" id="Items_@(index)_CanDelete" class="form-check-input" checked="@row.CanDelete" value="true" />
-                                        <label for="Items_@(index)_CanDelete" class="form-check-label">삭제</label>
+                                        <input type="checkbox" name="UpdateItems[@index].CanDelete" id="UpdateItems_@(index)_CanDelete" class="form-check-input" checked="@row.CanDelete" value="true" />
+                                        <label for="UpdateItems_@(index)_CanDelete" class="form-check-label">삭제</label>
                                     </div>
                                 </td>
-                                <td>@row.CreatedAt.GetDateAt()</td>
-                                <td>@(row.UpdatedAt.GetDateAt() ?? "-")</td>
+                                <td>@row.CreatedAt</td>
+                                <td>@(row.UpdatedAt ?? "-")</td>
                             </tr>
                         }
                     }
@@ -144,8 +126,41 @@
 
 @section Scripts {
 <script>
+    // 삭제
+    $(document).on("click", "#btnFListDelete", function() {
+        if (confirm("삭제 하시겠습니까?")) {
+            let checked = document.querySelectorAll(".list-check-box:checked");
+            if (checked.length === 0) return false;
+
+            checked.forEach(function(el) {
+                let form = document.createElement("form");
+                form.method = "post";
+                form.action = "?handler=Delete";
+
+                let input = document.createElement("input");
+                input.type = "hidden";
+                input.name = "DeleteID";
+                input.value = el.value;
+
+                let token = document.querySelector('input[name="__RequestVerificationToken"]');
+                if (token) {
+                    let tokenInput = document.createElement("input");
+                    tokenInput.type = "hidden";
+                    tokenInput.name = "__RequestVerificationToken";
+                    tokenInput.value = token.value;
+                    form.appendChild(tokenInput);
+                }
+
+                form.appendChild(input);
+                document.body.appendChild(form);
+                form.submit();
+            });
+        }
+        return false;
+    });
+
     // 저장
-    $(document).on("click", "#btnListSave", function() {
+    $(document).on("click", "#btnFListSave", function() {
         if (confirm("저장 하시겠습니까?")) {
             let form = document.getElementById("fAdminList");
             if (form.checkValidity()) { // HTML5 폼 검증 수행

+ 147 - 0
Admin/Pages/Forum/Board/Manager.cshtml.cs

@@ -0,0 +1,147 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board;
+
+public class ManagerModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+    public int Total { get; set; }
+
+    public List<(
+        int ID,
+        int MemberID,
+        string MemberEmail,
+        string? MemberFullName,
+        bool CanEdit,
+        bool CanDelete,
+        string? UpdatedAt,
+        string CreatedAt
+    )> Data { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int MemberID { get; set; }
+        public bool CanEdit { get; set; }
+        public bool CanDelete { get; set; }
+    }
+
+    [BindProperty]
+    public List<UpdateItemModel> UpdateItems { get; set; } = [];
+
+    public sealed class UpdateItemModel
+    {
+        public int ID { get; set; }
+        public bool CanEdit { get; set; }
+        public bool CanDelete { get; set; }
+    }
+
+    [BindProperty]
+    public int DeleteID { get; set; }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var result = await mediator.Send(new GetBoardManagers.Query(id), ct);
+        Total = result.Total;
+        Data = [..result.List.Select(c => (
+            c.ID,
+            c.MemberID,
+            c.MemberEmail,
+            c.MemberFullName,
+            c.CanEdit,
+            c.CanDelete,
+            c.UpdatedAt,
+            c.CreatedAt
+        ))];
+    }
+
+    public async Task<IActionResult> OnPostCreateAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid) throw new Exception(ModelState.GetErrorMessages());
+
+            await mediator.Send(new SaveBoardManagers.Command(
+                id,
+                new SaveBoardManagers.Command.Create(
+                    Input.MemberID,
+                    Input.CanEdit,
+                    Input.CanDelete
+                ),
+                null,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "관리자가 추가되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Manager/{id}{Request.QueryString}");
+    }
+
+    public async Task<IActionResult> OnPostSaveAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid) throw new Exception(ModelState.GetErrorMessages());
+
+            var updates = UpdateItems.Select(x => new SaveBoardManagers.Command.Update(
+                x.ID,
+                x.CanEdit,
+                x.CanDelete
+            )).ToList();
+
+            await mediator.Send(new SaveBoardManagers.Command(
+                id,
+                null,
+                updates,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "관리자 목록이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Manager/{id}{Request.QueryString}");
+    }
+
+    public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            await mediator.Send(new SaveBoardManagers.Command(
+                id,
+                null,
+                null,
+                [DeleteID]
+            ), ct);
+
+            TempData["SuccessMessage"] = "관리자가 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Manager/{id}{Request.QueryString}");
+    }
+}

+ 70 - 66
Admin/Pages/Forum/Board/Meta/Comment.cshtml

@@ -1,35 +1,39 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.CommentModel
 @{
     ViewData["Title"] = "게시판 관리 - 댓글";
+    ViewData["Sector"] = "Comment";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/Comment" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_EnableComment" class="col-md-3 col-form-label">댓글 사용</label>
+            <label for="Input_EnableComment" class="col-md-3 col-form-label">댓글 사용</label>
             <div class="col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.EnableComment" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.EnableComment" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.EnableComment" class="form-check-input" />
+                    <label asp-for="Input.EnableComment" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">댓글 목록, 사용을 활성화합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_PerPage" class="col-12 col-md-3 col-form-label">목록 표시</label>
+            <label for="Input_PerPage" class="col-12 col-md-3 col-form-label">목록 표시</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Comment.PerPage" class="form-control" min="10" max="100" required />
-                        <span asp-validation-for="BoardMeta.Comment.PerPage" class="text-danger"></span>
+                        <input type="number" asp-for="Input.PerPage" class="form-control" min="10" max="100" required />
+                        <span asp-validation-for="Input.PerPage" class="text-danger"></span>
                     </div>
                 </div>
                 <small class="text-muted form-text">한 페이지에 보이는 댓글 수, (최대 100개)</small>
@@ -37,164 +41,164 @@
         </div>
 
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_AllowLike" class="col-md-3">댓글 좋아요</label>
+            <label for="Input_AllowLike" class="col-md-3">댓글 좋아요</label>
             <div class="col-md-9 pt-2 pt-md-0">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.AllowLike" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.AllowLike" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowLike" class="form-check-input" />
+                    <label asp-for="Input.AllowLike" class="form-check-label">사용합니다.</label>
                 </div>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_AllowDisLike" class="col-md-3">댓글 싫어요</label>
+            <label for="Input_AllowDisLike" class="col-md-3">댓글 싫어요</label>
             <div class="col-md-9 pt-2 pt-md-0">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.AllowDisLike" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.AllowDisLike" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowDisLike" class="form-check-input" />
+                    <label asp-for="Input.AllowDisLike" class="form-check-label">사용합니다.</label>
                 </div>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_ShowMemberPhoto" class="col-md-3">회원 사진 공개</label>
+            <label for="Input_ShowMemberPhoto" class="col-md-3">회원 사진 공개</label>
             <div class="col-md-9 pt-2 pt-md-0">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.ShowMemberPhoto" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.ShowMemberPhoto" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberPhoto" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberPhoto" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">회원이 등록한 사진을 좌측에 보일지를 결정합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_ShowMemberIcon" class="col-md-3">회원 아이콘 공개</label>
+            <label for="Input_ShowMemberIcon" class="col-md-3">회원 아이콘 공개</label>
             <div class="col-md-9 pt-2 pt-md-0">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.ShowMemberIcon" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.ShowMemberIcon" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberIcon" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberIcon" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">회원의 등급/첨부 아이콘을 글쓴이명 좌측에 보일지를 결정합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_ContentPlaceholder" class="col-md-3 col-form-label">안내 문구</label>
+            <label for="Input_ContentPlaceholder" class="col-md-3 col-form-label">안내 문구</label>
             <div class="col-md-9">
-                <textarea asp-for="BoardMeta.Comment.ContentPlaceholder" class="form-control" rows="3"></textarea>
-                <span asp-validation-for="BoardMeta.Comment.ContentPlaceholder" class="text-danger"></span>
+                <textarea asp-for="Input.ContentPlaceholder" class="form-control" rows="3"></textarea>
+                <span asp-validation-for="Input.ContentPlaceholder" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_MinContentLength" class="col-md-3 col-form-label">최소 입력 글자</label>
+            <label for="Input_MinContentLength" class="col-md-3 col-form-label">최소 입력 글자</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Comment.MinContentLength" class="form-control" min="0" required />
-                        <span asp-validation-for="BoardMeta.Comment.MinContentLength" class="text-danger"></span>
+                        <input type="number" asp-for="Input.MinContentLength" class="form-control" min="0" required />
+                        <span asp-validation-for="Input.MinContentLength" class="text-danger"></span>
                     </div>
                 </div>
                 <small class="text-muted form-text">댓글 최소 입력 길이를 지정합니다. 0 입력시 제한 없음</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_MaxContentLength" class="col-md-3 col-form-label">최대 입력 글자</label>
+            <label for="Input_MaxContentLength" class="col-md-3 col-form-label">최대 입력 글자</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Comment.MaxContentLength" class="form-control" min="0" required />
-                        <span asp-validation-for="BoardMeta.Comment.MaxContentLength" class="text-danger"></span>
+                        <input type="number" asp-for="Input.MaxContentLength" class="form-control" min="0" required />
+                        <span asp-validation-for="Input.MaxContentLength" class="text-danger"></span>
                     </div>
                 </div>
                 <small class="text-muted form-text">댓글 최대 입력 길이를 지정합니다. 0 입력시 제한 없음</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_EnableEditor" class="col-md-3 col-form-label">웹 에디터 사용</label>
+            <label for="Input_EnableEditor" class="col-md-3 col-form-label">웹 에디터 사용</label>
             <div class="col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.EnableEditor" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.EnableEditor" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.EnableEditor" class="form-check-input" />
+                    <label asp-for="Input.EnableEditor" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">댓글을 웹 기반 에디터로 수정할 수 있도록합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_AllowSecret" class="col-md-3 col-form-label">비밀글 사용</label>
+            <label for="Input_AllowSecret" class="col-md-3 col-form-label">비밀글 사용</label>
             <div class="col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.AllowSecret" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.AllowSecret" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowSecret" class="form-check-input" />
+                    <label asp-for="Input.AllowSecret" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">비밀글 작성 기능을 활성화합니다. 비밀글은 작성자 본인과 게시판 관리자 이상만 열람 가능합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_BlameHideCount" class="col-md-3 col-form-label">댓글 신고 시 숨김</label>
+            <label for="Input_BlameHideCount" class="col-md-3 col-form-label">댓글 신고 시 숨김</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Comment.BlameHideCount" class="form-control" min="0" required />
-                        <span asp-validation-for="BoardMeta.Comment.BlameHideCount" class="text-danger"></span>
+                        <input type="number" asp-for="Input.BlameHideCount" class="form-control" min="0" required />
+                        <span asp-validation-for="Input.BlameHideCount" class="text-danger"></span>
                     </div>
                     <small class="text-muted form-text">댓글을 신고할 수 있도록 합니다. 숨김 횟수가 0이면 작동하지 않습니다.</small>
                 </div>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_AllowUpdateProtection" class="col-md-3 col-form-label">댓글 보호 기능 (수정 시)</label>
+            <label for="Input_AllowUpdateProtection" class="col-md-3 col-form-label">댓글 보호 기능 (수정 시)</label>
             <div class="col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.AllowUpdateProtection" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.AllowUpdateProtection" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowUpdateProtection" class="form-check-input" />
+                    <label asp-for="Input.AllowUpdateProtection" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">수정 시 댓글을 보호하는 기능을 활성화합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_UpdateProtectionDays" class="col-md-3 col-form-label">댓글 수정 금지 기간</label>
+            <label for="Input_UpdateProtectionDays" class="col-md-3 col-form-label">댓글 수정 금지 기간</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Comment.UpdateProtectionDays" class="form-control" min="0" max="365" />
-                        <span asp-validation-for="BoardMeta.Comment.UpdateProtectionDays" class="text-danger"></span>
+                        <input type="number" asp-for="Input.UpdateProtectionDays" class="form-control" min="0" max="365" />
+                        <span asp-validation-for="Input.UpdateProtectionDays" class="text-danger"></span>
                     </div>
                     <small class="text-muted form-text">댓글이 수정되지 않도록 보호하는 기간을 일 단위로 설정합니다.</small>
                 </div>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_AllowDeleteProtection" class="col-md-3 col-form-label">댓글 보호 기능 (삭제 시)</label>
+            <label for="Input_AllowDeleteProtection" class="col-md-3 col-form-label">댓글 보호 기능 (삭제 시)</label>
             <div class="col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.AllowDeleteProtection" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.AllowDeleteProtection" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowDeleteProtection" class="form-check-input" />
+                    <label asp-for="Input.AllowDeleteProtection" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">삭제 시 댓글을 보호하는 기능을 활성화합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_DeleteProtectionDays" class="col-md-3 col-form-label">댓글 삭제 금지 기간</label>
+            <label for="Input_DeleteProtectionDays" class="col-md-3 col-form-label">댓글 삭제 금지 기간</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Comment.DeleteProtectionDays" class="form-control" min="0" max="365" />
-                        <span asp-validation-for="BoardMeta.Comment.DeleteProtectionDays" class="text-danger"></span>
+                        <input type="number" asp-for="Input.DeleteProtectionDays" class="form-control" min="0" max="365" />
+                        <span asp-validation-for="Input.DeleteProtectionDays" class="text-danger"></span>
                     </div>
                     <small class="text-muted form-text">댓글이 삭제되지 않도록 보호하는 기간을 일 단위로 설정합니다.</small>
                 </div>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Comment_EnableCommentUpdateLog" class="col-md-3 col-form-label">댓글 변경 기록</label>
+            <label for="Input_EnableCommentUpdateLog" class="col-md-3 col-form-label">댓글 변경 기록</label>
             <div class="col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Comment.EnableCommentUpdateLog" class="form-check-input" />
-                    <label asp-for="BoardMeta.Comment.EnableCommentUpdateLog" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.EnableCommentUpdateLog" class="form-check-input" />
+                    <label asp-for="Input.EnableCommentUpdateLog" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">댓글 변경 시 기록을 남깁니다.</small>
             </div>
         </div>
         <hr/>
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
@@ -204,16 +208,16 @@
     <script>
         $("#fAdminWrite").validate({
             rules: {
-                "BoardMeta.Comment.UpdateProtectionDays": {
-                    required: "#BoardMeta_Comment_AllowUpdateProtection:checked",
+                "Input.UpdateProtectionDays": {
+                    required: "#Input_AllowUpdateProtection:checked",
                     min: function () {
-                        return $("#BoardMeta_Comment_AllowUpdateProtection").is(":checked") ? 1 : null;
+                        return $("#Input_AllowUpdateProtection").is(":checked") ? 1 : null;
                     }
                 },
-                "BoardMeta.Comment.DeleteProtectionDays": {
-                    required: "#BoardMeta_Comment_AllowDeleteProtection:checked",
+                "Input.DeleteProtectionDays": {
+                    required: "#Input_AllowDeleteProtection:checked",
                     min: function () {
-                        return $("#BoardMeta_Comment_AllowDeleteProtection").is(":checked") ? 1 : null;
+                        return $("#Input_AllowDeleteProtection").is(":checked") ? 1 : null;
                     }
                 }
             },

+ 126 - 0
Admin/Pages/Forum/Board/Meta/Comment.cshtml.cs

@@ -0,0 +1,126 @@
+using Domain.Entities.Forum.Boards;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class CommentModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public bool EnableComment { get; set; }
+        public ushort PerPage { get; set; }
+        public bool AllowLike { get; set; }
+        public bool AllowDisLike { get; set; }
+        public bool ShowMemberPhoto { get; set; }
+        public bool ShowMemberIcon { get; set; }
+        public string? ContentPlaceholder { get; set; }
+        public ushort MinContentLength { get; set; }
+        public ushort MaxContentLength { get; set; }
+        public bool EnableEditor { get; set; }
+        public bool AllowSecret { get; set; }
+        public ushort BlameHideCount { get; set; }
+        public bool AllowUpdateProtection { get; set; }
+        public ushort UpdateProtectionDays { get; set; }
+        public bool AllowDeleteProtection { get; set; }
+        public ushort DeleteProtectionDays { get; set; }
+        public bool EnableCommentUpdateLog { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            EnableComment = meta.Comment.EnableComment,
+            PerPage = meta.Comment.PerPage,
+            AllowLike = meta.Comment.AllowLike,
+            AllowDisLike = meta.Comment.AllowDisLike,
+            ShowMemberPhoto = meta.Comment.ShowMemberPhoto,
+            ShowMemberIcon = meta.Comment.ShowMemberIcon,
+            ContentPlaceholder = meta.Comment.ContentPlaceholder,
+            MinContentLength = meta.Comment.MinContentLength,
+            MaxContentLength = meta.Comment.MaxContentLength,
+            EnableEditor = meta.Comment.EnableEditor,
+            AllowSecret = meta.Comment.AllowSecret,
+            BlameHideCount = meta.Comment.BlameHideCount,
+            AllowUpdateProtection = meta.Comment.AllowUpdateProtection,
+            UpdateProtectionDays = meta.Comment.UpdateProtectionDays,
+            AllowDeleteProtection = meta.Comment.AllowDeleteProtection,
+            DeleteProtectionDays = meta.Comment.DeleteProtectionDays,
+            EnableCommentUpdateLog = meta.Comment.EnableCommentUpdateLog
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null,
+                null,
+                null,
+                new BoardMetaComment
+                {
+                    EnableComment = Input.EnableComment,
+                    PerPage = Input.PerPage,
+                    AllowLike = Input.AllowLike,
+                    AllowDisLike = Input.AllowDisLike,
+                    ShowMemberPhoto = Input.ShowMemberPhoto,
+                    ShowMemberIcon = Input.ShowMemberIcon,
+                    ContentPlaceholder = Input.ContentPlaceholder,
+                    MinContentLength = Input.MinContentLength,
+                    MaxContentLength = Input.MaxContentLength,
+                    EnableEditor = Input.EnableEditor,
+                    AllowSecret = Input.AllowSecret,
+                    BlameHideCount = Input.BlameHideCount,
+                    AllowUpdateProtection = Input.AllowUpdateProtection,
+                    UpdateProtectionDays = Input.UpdateProtectionDays,
+                    AllowDeleteProtection = Input.AllowDeleteProtection,
+                    DeleteProtectionDays = Input.DeleteProtectionDays,
+                    EnableCommentUpdateLog = Input.EnableCommentUpdateLog
+                },
+                null,
+                null,
+                null,
+                null,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "댓글 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/Comment/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 100 - 96
Admin/Pages/Forum/Board/Meta/Exp.cshtml

@@ -1,33 +1,37 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.ExpModel
 @{
     ViewData["Title"] = "게시판 관리 - 경험치";
+    ViewData["Sector"] = "Exp";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/Exp" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <div class="row mb-3">
-            <label for="BoardMeta_Exp_EnableExp" class="col-auto col-md-3">경험치 기능</label>
+            <label for="Input_EnableExp" class="col-auto col-md-3">경험치 기능</label>
             <div class="col col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Exp.EnableExp" class="form-check-input" />
-                    <label asp-for="BoardMeta.Exp.EnableExp" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.EnableExp" class="form-check-input" />
+                    <label asp-for="Input.EnableExp" class="form-check-label">사용합니다.</label>
                 </div>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Exp_ShowExpGuide" class="col-auto col-md-3">경험치 안내</label>
+            <label for="Input_ShowExpGuide" class="col-auto col-md-3">경험치 안내</label>
             <div class="col col-md-9">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Exp.ShowExpGuide" class="form-check-input" />
-                    <label asp-for="BoardMeta.Exp.ShowExpGuide" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowExpGuide" class="form-check-input" />
+                    <label asp-for="Input.ShowExpGuide" class="form-check-label">사용합니다.</label>
                 </div>
             </div>
         </div>
@@ -51,16 +55,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">게시글 작성</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.PostWriteExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.PostWriteExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.PostWriteExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.PostWriteExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.PostWriteUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.PostWriteUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.PostWriteUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.PostWriteUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.PostWriteExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.PostWriteExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.PostWriteExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.PostWriteExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -68,16 +72,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">댓글 작성</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.CommentWriteExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.CommentWriteExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.CommentWriteExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.CommentWriteExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.CommentWriteUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.CommentWriteUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.CommentWriteUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.CommentWriteUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.CommentWriteExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.CommentWriteExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.CommentWriteExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.CommentWriteExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -85,16 +89,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">파일 업로드</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.FileUploadExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.FileUploadExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.FileUploadExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.FileUploadExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.FileUploadUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.FileUploadUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.FileUploadUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.FileUploadUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.FileUploadExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.FileUploadExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.FileUploadExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.FileUploadExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -102,8 +106,8 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">파일 다운로드</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.FileDownloadExp" class="form-control" min="-10000" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.FileDownloadExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.FileDownloadExp" class="form-control" min="-10000" max="10000" required />
+                <span asp-validation-for="Input.FileDownloadExp" class="text-danger"></span>
             </div>
             <div class="col text-center">
                 -
@@ -117,16 +121,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">게시글 읽기</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostReadExp" class="form-control" min="-10000" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostReadExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostReadExp" class="form-control" min="-10000" max="10000" required />
+                <span asp-validation-for="Input.OtherPostReadExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostReadUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostReadUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostReadUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherPostReadUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostReadExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostReadExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostReadExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OtherPostReadExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -134,16 +138,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">게시글 좋아요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherPostLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherPostLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OtherPostLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -151,16 +155,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">게시글 싫어요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostDisLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostDisLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostDisLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherPostDisLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostDisLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostDisLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostDisLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherPostDisLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherPostDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherPostDisLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherPostDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OtherPostDisLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -168,16 +172,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">댓글 좋아요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherCommentLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherCommentLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherCommentLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherCommentLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherCommentLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherCommentLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherCommentLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherCommentLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherCommentLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherCommentLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherCommentLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OtherCommentLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -185,16 +189,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">댓글 싫어요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherCommentDisLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherCommentDisLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherCommentDisLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherCommentDisLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherCommentDisLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherCommentDisLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherCommentDisLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OtherCommentDisLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OtherCommentDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OtherCommentDisLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OtherCommentDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OtherCommentDisLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -202,16 +206,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">내 게시글 읽힘</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostReadExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostReadExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostReadExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnPostReadExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostReadUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostReadUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostReadUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnPostReadUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostReadExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostReadExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostReadExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OwnPostReadExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -219,16 +223,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">내 게시글 좋아요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnPostLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnPostLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OwnPostLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -236,16 +240,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">내 게시글 싫어요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostDisLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostDisLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostDisLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnPostDisLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostDisLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostDisLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostDisLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnPostDisLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnPostDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnPostDisLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnPostDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OwnPostDisLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -253,16 +257,16 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">내 댓글 좋아요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnCommentLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnCommentLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnCommentLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnCommentLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnCommentLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnCommentLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnCommentLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnCommentLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnCommentLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnCommentLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnCommentLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OwnCommentLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
@@ -270,24 +274,24 @@
         <div class="row mb-3">
             <label class="col-md-3 col-form-label">내 댓글 싫어요</label>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnCommentDisLikeExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnCommentDisLikeExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnCommentDisLikeExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnCommentDisLikeExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnCommentDisLikeUndoExp" class="form-control" min="0" max="10000" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnCommentDisLikeUndoExp" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnCommentDisLikeUndoExp" class="form-control" min="0" max="10000" required />
+                <span asp-validation-for="Input.OwnCommentDisLikeUndoExp" class="text-danger"></span>
             </div>
             <div class="col">
-                <input type="number" asp-for="BoardMeta.Exp.OwnCommentDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
-                <span asp-validation-for="BoardMeta.Exp.OwnCommentDisLikeExpWithinDays" class="text-danger"></span>
+                <input type="number" asp-for="Input.OwnCommentDisLikeExpWithinDays" class="form-control" min="0" max="365" required />
+                <span asp-validation-for="Input.OwnCommentDisLikeExpWithinDays" class="text-danger"></span>
             </div>
         </div>
 
         <hr/>
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
-</div>
+</div>

+ 206 - 0
Admin/Pages/Forum/Board/Meta/Exp.cshtml.cs

@@ -0,0 +1,206 @@
+using Domain.Entities.Forum.Boards;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class ExpModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public bool EnableExp { get; set; }
+        public bool ShowExpGuide { get; set; }
+
+        // 경험치 지급량
+        public ushort PostWriteExp { get; set; }
+        public ushort CommentWriteExp { get; set; }
+        public ushort FileUploadExp { get; set; }
+        public short FileDownloadExp { get; set; }
+        public short OtherPostReadExp { get; set; }
+        public ushort OtherPostLikeExp { get; set; }
+        public ushort OtherPostDisLikeExp { get; set; }
+        public ushort OtherCommentLikeExp { get; set; }
+        public ushort OtherCommentDisLikeExp { get; set; }
+        public ushort OwnPostReadExp { get; set; }
+        public ushort OwnPostLikeExp { get; set; }
+        public short OwnPostDisLikeExp { get; set; }
+        public ushort OwnCommentLikeExp { get; set; }
+        public short OwnCommentDisLikeExp { get; set; }
+
+        // 경험치 회수량
+        public ushort PostWriteUndoExp { get; set; }
+        public ushort CommentWriteUndoExp { get; set; }
+        public ushort FileUploadUndoExp { get; set; }
+        public ushort OtherPostReadUndoExp { get; set; }
+        public ushort OtherPostLikeUndoExp { get; set; }
+        public ushort OtherPostDisLikeUndoExp { get; set; }
+        public ushort OtherCommentLikeUndoExp { get; set; }
+        public ushort OtherCommentDisLikeUndoExp { get; set; }
+        public ushort OwnPostReadUndoExp { get; set; }
+        public ushort OwnPostLikeUndoExp { get; set; }
+        public ushort OwnPostDisLikeUndoExp { get; set; }
+        public ushort OwnCommentLikeUndoExp { get; set; }
+        public ushort OwnCommentDisLikeUndoExp { get; set; }
+
+        // 경험치 지급 기한
+        public ushort PostWriteExpWithinDays { get; set; }
+        public ushort CommentWriteExpWithinDays { get; set; }
+        public ushort FileUploadExpWithinDays { get; set; }
+        public ushort OtherPostReadExpWithinDays { get; set; }
+        public ushort OtherPostLikeExpWithinDays { get; set; }
+        public ushort OtherPostDisLikeExpWithinDays { get; set; }
+        public ushort OtherCommentLikeExpWithinDays { get; set; }
+        public ushort OtherCommentDisLikeExpWithinDays { get; set; }
+        public ushort OwnPostReadExpWithinDays { get; set; }
+        public ushort OwnPostLikeExpWithinDays { get; set; }
+        public ushort OwnPostDisLikeExpWithinDays { get; set; }
+        public ushort OwnCommentLikeExpWithinDays { get; set; }
+        public ushort OwnCommentDisLikeExpWithinDays { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            EnableExp = meta.Exp.EnableExp,
+            ShowExpGuide = meta.Exp.ShowExpGuide,
+            PostWriteExp = meta.Exp.PostWriteExp,
+            CommentWriteExp = meta.Exp.CommentWriteExp,
+            FileUploadExp = meta.Exp.FileUploadExp,
+            FileDownloadExp = meta.Exp.FileDownloadExp,
+            OtherPostReadExp = meta.Exp.OtherPostReadExp,
+            OtherPostLikeExp = meta.Exp.OtherPostLikeExp,
+            OtherPostDisLikeExp = meta.Exp.OtherPostDisLikeExp,
+            OtherCommentLikeExp = meta.Exp.OtherCommentLikeExp,
+            OtherCommentDisLikeExp = meta.Exp.OtherCommentDisLikeExp,
+            OwnPostReadExp = meta.Exp.OwnPostReadExp,
+            OwnPostLikeExp = meta.Exp.OwnPostLikeExp,
+            OwnPostDisLikeExp = meta.Exp.OwnPostDisLikeExp,
+            OwnCommentLikeExp = meta.Exp.OwnCommentLikeExp,
+            OwnCommentDisLikeExp = meta.Exp.OwnCommentDisLikeExp,
+            PostWriteUndoExp = meta.Exp.PostWriteUndoExp,
+            CommentWriteUndoExp = meta.Exp.CommentWriteUndoExp,
+            FileUploadUndoExp = meta.Exp.FileUploadUndoExp,
+            OtherPostReadUndoExp = meta.Exp.OtherPostReadUndoExp,
+            OtherPostLikeUndoExp = meta.Exp.OtherPostLikeUndoExp,
+            OtherPostDisLikeUndoExp = meta.Exp.OtherPostDisLikeUndoExp,
+            OtherCommentLikeUndoExp = meta.Exp.OtherCommentLikeUndoExp,
+            OtherCommentDisLikeUndoExp = meta.Exp.OtherCommentDisLikeUndoExp,
+            OwnPostReadUndoExp = meta.Exp.OwnPostReadUndoExp,
+            OwnPostLikeUndoExp = meta.Exp.OwnPostLikeUndoExp,
+            OwnPostDisLikeUndoExp = meta.Exp.OwnPostDisLikeUndoExp,
+            OwnCommentLikeUndoExp = meta.Exp.OwnCommentLikeUndoExp,
+            OwnCommentDisLikeUndoExp = meta.Exp.OwnCommentDisLikeUndoExp,
+            PostWriteExpWithinDays = meta.Exp.PostWriteExpWithinDays,
+            CommentWriteExpWithinDays = meta.Exp.CommentWriteExpWithinDays,
+            FileUploadExpWithinDays = meta.Exp.FileUploadExpWithinDays,
+            OtherPostReadExpWithinDays = meta.Exp.OtherPostReadExpWithinDays,
+            OtherPostLikeExpWithinDays = meta.Exp.OtherPostLikeExpWithinDays,
+            OtherPostDisLikeExpWithinDays = meta.Exp.OtherPostDisLikeExpWithinDays,
+            OtherCommentLikeExpWithinDays = meta.Exp.OtherCommentLikeExpWithinDays,
+            OtherCommentDisLikeExpWithinDays = meta.Exp.OtherCommentDisLikeExpWithinDays,
+            OwnPostReadExpWithinDays = meta.Exp.OwnPostReadExpWithinDays,
+            OwnPostLikeExpWithinDays = meta.Exp.OwnPostLikeExpWithinDays,
+            OwnPostDisLikeExpWithinDays = meta.Exp.OwnPostDisLikeExpWithinDays,
+            OwnCommentLikeExpWithinDays = meta.Exp.OwnCommentLikeExpWithinDays,
+            OwnCommentDisLikeExpWithinDays = meta.Exp.OwnCommentDisLikeExpWithinDays
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null,
+                new BoardMetaExp
+                {
+                    EnableExp = Input.EnableExp,
+                    ShowExpGuide = Input.ShowExpGuide,
+                    PostWriteExp = Input.PostWriteExp,
+                    CommentWriteExp = Input.CommentWriteExp,
+                    FileUploadExp = Input.FileUploadExp,
+                    FileDownloadExp = Input.FileDownloadExp,
+                    OtherPostReadExp = Input.OtherPostReadExp,
+                    OtherPostLikeExp = Input.OtherPostLikeExp,
+                    OtherPostDisLikeExp = Input.OtherPostDisLikeExp,
+                    OtherCommentLikeExp = Input.OtherCommentLikeExp,
+                    OtherCommentDisLikeExp = Input.OtherCommentDisLikeExp,
+                    OwnPostReadExp = Input.OwnPostReadExp,
+                    OwnPostLikeExp = Input.OwnPostLikeExp,
+                    OwnPostDisLikeExp = Input.OwnPostDisLikeExp,
+                    OwnCommentLikeExp = Input.OwnCommentLikeExp,
+                    OwnCommentDisLikeExp = Input.OwnCommentDisLikeExp,
+                    PostWriteUndoExp = Input.PostWriteUndoExp,
+                    CommentWriteUndoExp = Input.CommentWriteUndoExp,
+                    FileUploadUndoExp = Input.FileUploadUndoExp,
+                    OtherPostReadUndoExp = Input.OtherPostReadUndoExp,
+                    OtherPostLikeUndoExp = Input.OtherPostLikeUndoExp,
+                    OtherPostDisLikeUndoExp = Input.OtherPostDisLikeUndoExp,
+                    OtherCommentLikeUndoExp = Input.OtherCommentLikeUndoExp,
+                    OtherCommentDisLikeUndoExp = Input.OtherCommentDisLikeUndoExp,
+                    OwnPostReadUndoExp = Input.OwnPostReadUndoExp,
+                    OwnPostLikeUndoExp = Input.OwnPostLikeUndoExp,
+                    OwnPostDisLikeUndoExp = Input.OwnPostDisLikeUndoExp,
+                    OwnCommentLikeUndoExp = Input.OwnCommentLikeUndoExp,
+                    OwnCommentDisLikeUndoExp = Input.OwnCommentDisLikeUndoExp,
+                    PostWriteExpWithinDays = Input.PostWriteExpWithinDays,
+                    CommentWriteExpWithinDays = Input.CommentWriteExpWithinDays,
+                    FileUploadExpWithinDays = Input.FileUploadExpWithinDays,
+                    OtherPostReadExpWithinDays = Input.OtherPostReadExpWithinDays,
+                    OtherPostLikeExpWithinDays = Input.OtherPostLikeExpWithinDays,
+                    OtherPostDisLikeExpWithinDays = Input.OtherPostDisLikeExpWithinDays,
+                    OtherCommentLikeExpWithinDays = Input.OtherCommentLikeExpWithinDays,
+                    OtherCommentDisLikeExpWithinDays = Input.OtherCommentDisLikeExpWithinDays,
+                    OwnPostReadExpWithinDays = Input.OwnPostReadExpWithinDays,
+                    OwnPostLikeExpWithinDays = Input.OwnPostLikeExpWithinDays,
+                    OwnPostDisLikeExpWithinDays = Input.OwnPostDisLikeExpWithinDays,
+                    OwnCommentLikeExpWithinDays = Input.OwnCommentLikeExpWithinDays,
+                    OwnCommentDisLikeExpWithinDays = Input.OwnCommentDisLikeExpWithinDays
+                }), ct);
+
+            TempData["SuccessMessage"] = "경험치 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/Exp/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 35 - 31
Admin/Pages/Forum/Board/Meta/General.cshtml

@@ -1,97 +1,101 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.GeneralModel
 @{
     ViewData["Title"] = "게시판 관리 - 일반";
+    ViewData["Sector"] = "General";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/General" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <div class="row mb-3">
-            <label for="BoardMeta_General_AllowUpdateProtection" class="col-md-3 col-form-label">게시글 보호 기능 (수정 시)</label>
+            <label for="Input_AllowUpdateProtection" class="col-md-3 col-form-label">게시글 보호 기능 (수정 시)</label>
             <div class="col-md-9 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.General.AllowUpdateProtection" class="form-check-input" />
-                    <label asp-for="BoardMeta.General.AllowUpdateProtection" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowUpdateProtection" class="form-check-input" />
+                    <label asp-for="Input.AllowUpdateProtection" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">수정 시 게시글을 보호하는 기능을 활성화합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_General_UpdateProtectionDays" class="col-12 col-md-3 col-form-label">게시글 수정 금지 기간</label>
+            <label for="Input_UpdateProtectionDays" class="col-12 col-md-3 col-form-label">게시글 수정 금지 기간</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.General.UpdateProtectionDays" class="form-control" min="0" max="365" required />
-                        <span asp-validation-for="BoardMeta.General.UpdateProtectionDays" class="text-danger"></span>
+                        <input type="number" asp-for="Input.UpdateProtectionDays" class="form-control" min="0" max="365" required />
+                        <span asp-validation-for="Input.UpdateProtectionDays" class="text-danger"></span>
                     </div>
                 </div>
                 <small class="text-muted form-text">게시글이 수정되지 않도록 보호하는 기간을 일 단위로 설정합니다.<br />(몇 일이 지난 게시글)</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_General_AllowDeleteProtection" class="col-md-3 col-form-label">게시글 보호 기능 (삭제 시)</label>
+            <label for="Input_AllowDeleteProtection" class="col-md-3 col-form-label">게시글 보호 기능 (삭제 시)</label>
             <div class="col-md-9 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.General.AllowDeleteProtection" class="form-check-input" />
-                    <label asp-for="BoardMeta.General.AllowDeleteProtection" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowDeleteProtection" class="form-check-input" />
+                    <label asp-for="Input.AllowDeleteProtection" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">삭제 시 게시글을 보호하는 기능을 활성화합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_General_DeleteProtectionDays" class="col-12 col-md-3 col-form-label">게시글 삭제 금지 기간</label>
+            <label for="Input_DeleteProtectionDays" class="col-12 col-md-3 col-form-label">게시글 삭제 금지 기간</label>
             <div class="col-lg-9">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.General.DeleteProtectionDays" class="form-control" min="0" max="365" required />
-                        <span asp-validation-for="BoardMeta.General.DeleteProtectionDays" class="text-danger"></span>
+                        <input type="number" asp-for="Input.DeleteProtectionDays" class="form-control" min="0" max="365" required />
+                        <span asp-validation-for="Input.DeleteProtectionDays" class="text-danger"></span>
                     </div>
                 </div>
                 <small class="text-muted form-text">게시글이 삭제되지 않도록 보호하는 기간을 일 단위로 설정합니다.<br />(몇 일이 지난 게시글)</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_General_EnableFileDownLog" class="col-md-3 col-form-label">다운로드 기록</label>
+            <label for="Input_EnableFileDownLog" class="col-md-3 col-form-label">다운로드 기록</label>
             <div class="col-md-9 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.General.EnableFileDownLog" class="form-check-input" />
-                    <label asp-for="BoardMeta.General.EnableFileDownLog" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.EnableFileDownLog" class="form-check-input" />
+                    <label asp-for="Input.EnableFileDownLog" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">파일 다운로드 시 기록을 남깁니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_General_EnableLinkClickLog" class="col-md-3 col-form-label">링크 클릭 기록</label>
+            <label for="Input_EnableLinkClickLog" class="col-md-3 col-form-label">링크 클릭 기록</label>
             <div class="col-md-9 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.General.EnableLinkClickLog" class="form-check-input" />
-                    <label asp-for="BoardMeta.General.EnableLinkClickLog" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.EnableLinkClickLog" class="form-check-input" />
+                    <label asp-for="Input.EnableLinkClickLog" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">링크 클릭 시 기록을 남깁니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_General_EnablePostUpdateLog" class="col-md-3 col-form-label">게시글 변경 기록</label>
+            <label for="Input_EnablePostUpdateLog" class="col-md-3 col-form-label">게시글 변경 기록</label>
             <div class="col-md-9 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.General.EnablePostUpdateLog" class="form-check-input" />
-                    <label asp-for="BoardMeta.General.EnablePostUpdateLog" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.EnablePostUpdateLog" class="form-check-input" />
+                    <label asp-for="Input.EnablePostUpdateLog" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 변경 시 기록을 남깁니다.</small>
             </div>
         </div>
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
-</div>
+</div>

+ 96 - 0
Admin/Pages/Forum/Board/Meta/General.cshtml.cs

@@ -0,0 +1,96 @@
+using Domain.Entities.Forum.Boards;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class GeneralModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public bool AllowUpdateProtection { get; set; }
+        public ushort UpdateProtectionDays { get; set; }
+        public bool AllowDeleteProtection { get; set; }
+        public ushort DeleteProtectionDays { get; set; }
+        public bool EnableFileDownLog { get; set; }
+        public bool EnableLinkClickLog { get; set; }
+        public bool EnablePostUpdateLog { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            AllowUpdateProtection = meta.General.AllowUpdateProtection,
+            UpdateProtectionDays = meta.General.UpdateProtectionDays,
+            AllowDeleteProtection = meta.General.AllowDeleteProtection,
+            DeleteProtectionDays = meta.General.DeleteProtectionDays,
+            EnableFileDownLog = meta.General.EnableFileDownLog,
+            EnableLinkClickLog = meta.General.EnableLinkClickLog,
+            EnablePostUpdateLog = meta.General.EnablePostUpdateLog
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null,
+                null,
+                null,
+                null,
+                new BoardMetaGeneral
+                {
+                    AllowUpdateProtection = Input.AllowUpdateProtection,
+                    UpdateProtectionDays = Input.UpdateProtectionDays,
+                    AllowDeleteProtection = Input.AllowDeleteProtection,
+                    DeleteProtectionDays = Input.DeleteProtectionDays,
+                    EnableFileDownLog = Input.EnableFileDownLog,
+                    EnableLinkClickLog = Input.EnableLinkClickLog,
+                    EnablePostUpdateLog = Input.EnablePostUpdateLog
+                },
+                null,
+                null,
+                null,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "일반 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/General/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 53 - 49
Admin/Pages/Forum/Board/Meta/List.cshtml

@@ -1,128 +1,132 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
-@using Library.Constants
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.ListModel
+@using Domain.Entities.Forum.ValueObject
 @{
     ViewData["Title"] = "게시판 관리 - 목록";
+    ViewData["Sector"] = "List";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
     <partial name="_Editor" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/List" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <div class="row mb-3">
-            <label for="BoardMeta_List_ShowHeader" class="col-md-2 col-form-label">상단 내용</label>
+            <label for="Input_ShowHeader" class="col-md-2 col-form-label">상단 내용</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.ShowHeader" class="form-check-input" />
-                    <label for="BoardMeta_List_ShowHeader" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowHeader" class="form-check-input" />
+                    <label for="Input_ShowHeader" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시판 상단에 내용을 출력합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_HeaderContent" class="col-md-2 col-form-label"></label>
+            <label for="Input_HeaderContent" class="col-md-2 col-form-label"></label>
             <div class="col-md-10">
-                <textarea asp-for="BoardMeta.List.HeaderContent" class="ck-editor"></textarea>
-                <span asp-validation-for="BoardMeta.List.HeaderContent" class="text-danger"></span>
+                <textarea asp-for="Input.HeaderContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.HeaderContent" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_ShowFooter" class="col-sm-2 col-form-label">하단 내용</label>
+            <label for="Input_ShowFooter" class="col-sm-2 col-form-label">하단 내용</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.ShowFooter" class="form-check-input" />
-                    <label for="BoardMeta_List_ShowFooter" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowFooter" class="form-check-input" />
+                    <label for="Input_ShowFooter" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시판 하단에 내용을 출력합니다.</small>
             </div>
         </div>
         <div class="row mb-2">
-            <label for="BoardMeta_List_FooterContent" class="col-md-2 col-form-label"></label>
+            <label for="Input_FooterContent" class="col-md-2 col-form-label"></label>
             <div class="col-md-10">
-                <textarea asp-for="BoardMeta.List.FooterContent" class="ck-editor"></textarea>
-                <span asp-validation-for="BoardMeta.List.FooterContent" class="text-danger"></span>
+                <textarea asp-for="Input.FooterContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.FooterContent" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_Layout" class="col-md-2 col-form-label">게시판 종류</label>
+            <label for="Input_Layout" class="col-md-2 col-form-label">게시판 종류</label>
             <div class="col col-md-auto">
-                <select asp-for="BoardMeta.List.Layout" class="form-select" required asp-items="@Html.GetEnumSelectList<BoardConst.Layout>()"></select>
-                <span asp-validation-for="BoardMeta.List.Layout" class="text-danger"></span>
+                <select asp-for="Input.Layout" class="form-select" required asp-items="@Html.GetEnumSelectList<BoardLayout>()"></select>
+                <span asp-validation-for="Input.Layout" class="text-danger"></span>
                 <small class="text-muted form-text">지원하는 게시판 종류를 지정합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_Sort" class="col-md-2 col-form-label">기본 정렬</label>
+            <label for="Input_Sort" class="col-md-2 col-form-label">기본 정렬</label>
             <div class="col col-md-auto">
-                <select asp-for="BoardMeta.List.Sort" class="form-select" required asp-items="@Html.GetEnumSelectList<BoardConst.Sort>()"></select>
-                <span asp-validation-for="BoardMeta.List.Sort" class="text-danger"></span>
+                <select asp-for="Input.Sort" class="form-select" required asp-items="@Html.GetEnumSelectList<BoardSort>()"></select>
+                <span asp-validation-for="Input.Sort" class="text-danger"></span>
                 <small class="text-muted form-text">게시판 기본 정렬을 지정합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_PerPage" class="col-md-2 col-form-label">목록 표시 개수</label>
+            <label for="Input_PerPage" class="col-md-2 col-form-label">목록 표시 개수</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.List.PerPage" class="form-control" min="0" max="100" required />
-                        <span asp-validation-for="BoardMeta.List.PerPage" class="text-danger"></span>
+                        <input type="number" asp-for="Input.PerPage" class="form-control" min="0" max="100" required />
+                        <span asp-validation-for="Input.PerPage" class="text-danger"></span>
                     </div>
                 </div>
                 <small class="text-muted form-text">한 페이지에 보이는 게시물 수, 0이면 페이징 기능 사용하지 않음, (최대 100개)</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_AlwaysShowWriteButton" class="col-sm-2 col-form-label">글쓰기 버튼 보이기</label>
+            <label for="Input_AlwaysShowWriteButton" class="col-sm-2 col-form-label">글쓰기 버튼 보이기</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.AlwaysShowWriteButton" class="form-check-input" />
-                    <label for="BoardMeta_List_AlwaysShowWriteButton" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AlwaysShowWriteButton" class="form-check-input" />
+                    <label for="Input_AlwaysShowWriteButton" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">권한이 없는 사용자라도 글쓰기 버튼은 항상 보입니다</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_ShowFooterListView" class="col-sm-2 col-form-label">하단 목록 보이기</label>
+            <label for="Input_ShowFooterListView" class="col-sm-2 col-form-label">하단 목록 보이기</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.ShowFooterListView" class="form-check-input" />
-                    <label for="BoardMeta_List_ShowFooterListView" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowFooterListView" class="form-check-input" />
+                    <label for="Input_ShowFooterListView" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">글읽기 화면 하단에도 목록을 표시합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_IsNewIcon" class="col-sm-2 col-form-label">NEW 아이콘 사용</label>
+            <label for="Input_IsNewIcon" class="col-sm-2 col-form-label">NEW 아이콘 사용</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.IsNewIcon" class="form-check-input" />
-                    <label for="BoardMeta_List_IsNewIcon" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.IsNewIcon" class="form-check-input" />
+                    <label for="Input_IsNewIcon" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">글 작성 후 24시간 동안 NEW 아이콘이 노출됩니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_IsHotIcon" class="col-sm-2 col-form-label">HOT 아이콘 사용</label>
+            <label for="Input_IsHotIcon" class="col-sm-2 col-form-label">HOT 아이콘 사용</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.IsHotIcon" class="form-check-input" />
-                    <label for="BoardMeta_List_IsHotIcon" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.IsHotIcon" class="form-check-input" />
+                    <label for="Input_IsHotIcon" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">글 조회 수가 1000건 이상이면 7일간 아이콘이 노출됩니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_ExceptNotice" class="col-sm-2 col-form-label">공지사항 제외</label>
+            <label for="Input_ExceptNotice" class="col-sm-2 col-form-label">공지사항 제외</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.ExceptNotice" class="form-check-input" />
-                    <label for="BoardMeta_List_ExceptNotice" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ExceptNotice" class="form-check-input" />
+                    <label for="Input_ExceptNotice" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">
                     목록 상단에 늘 나타나는 공지사항을 일반 목록에서 나타나지 않도록 합니다.
@@ -131,11 +135,11 @@
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_List_ExceptSpeaker" class="col-sm-2 col-form-label">전체공지 제외</label>
+            <label for="Input_ExceptSpeaker" class="col-sm-2 col-form-label">전체공지 제외</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.List.ExceptSpeaker" class="form-check-input" />
-                    <label for="BoardMeta_List_ExceptSpeaker" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ExceptSpeaker" class="form-check-input" />
+                    <label for="Input_ExceptSpeaker" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">
                     설정하시면 다른 게시판에서 입력한 전체 공지가 이 게시판에서는 보이지 않습니다.
@@ -145,8 +149,8 @@
         </div>
         <hr/>
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br/>
     </form>

+ 115 - 0
Admin/Pages/Forum/Board/Meta/List.cshtml.cs

@@ -0,0 +1,115 @@
+using Domain.Entities.Forum.Boards;
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class ListModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public bool ShowHeader { get; set; }
+        public string? HeaderContent { get; set; }
+        public bool ShowFooter { get; set; }
+        public string? FooterContent { get; set; }
+        public BoardLayout? Layout { get; set; }
+        public BoardSort? Sort { get; set; }
+        public byte PerPage { get; set; }
+        public bool AlwaysShowWriteButton { get; set; }
+        public bool ShowFooterListView { get; set; }
+        public bool IsNewIcon { get; set; }
+        public bool IsHotIcon { get; set; }
+        public bool ExceptNotice { get; set; }
+        public bool ExceptSpeaker { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            ShowHeader = meta.List.ShowHeader,
+            HeaderContent = meta.List.HeaderContent,
+            ShowFooter = meta.List.ShowFooter,
+            FooterContent = meta.List.FooterContent,
+            Layout = meta.List.Layout,
+            Sort = meta.List.Sort,
+            PerPage = meta.List.PerPage,
+            AlwaysShowWriteButton = meta.List.AlwaysShowWriteButton,
+            ShowFooterListView = meta.List.ShowFooterListView,
+            IsNewIcon = meta.List.IsNewIcon,
+            IsHotIcon = meta.List.IsHotIcon,
+            ExceptNotice = meta.List.ExceptNotice,
+            ExceptSpeaker = meta.List.ExceptSpeaker
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                new BoardMetaList
+                {
+                    ShowHeader = Input.ShowHeader,
+                    HeaderContent = Input.HeaderContent,
+                    ShowFooter = Input.ShowFooter,
+                    FooterContent = Input.FooterContent,
+                    Layout = Input.Layout,
+                    Sort = Input.Sort,
+                    PerPage = Input.PerPage,
+                    AlwaysShowWriteButton = Input.AlwaysShowWriteButton,
+                    ShowFooterListView = Input.ShowFooterListView,
+                    IsNewIcon = Input.IsNewIcon,
+                    IsHotIcon = Input.IsHotIcon,
+                    ExceptNotice = Input.ExceptNotice,
+                    ExceptSpeaker = Input.ExceptSpeaker
+                },
+                null,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "목록 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/List/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 23 - 19
Admin/Pages/Forum/Board/Meta/Notify.cshtml

@@ -1,20 +1,24 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
-@using Library.Constants
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.NotifyModel
+@using Domain.Entities.Forum.ValueObject
 @{
     ViewData["Title"] = "게시판 관리 - 알림";
+    ViewData["Sector"] = "Notify";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 
     var notifyList = Model.NotifyList;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/Notify" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <h5>이메일 알림</h5>
         <p class="text-muted form-text">이메일 알림이 양식에 맞추어 아래 대상자에게 발송됩니다.</p>
@@ -25,9 +29,9 @@
                 @if (notifyList is not null) {
                     @foreach (var item in notifyList)
                     {
-                        var notifyValue = (BoardConst.Notify)Enum.Parse(typeof(BoardConst.Notify), item.Value);
+                        var notifyValue = (BoardNotify)Enum.Parse(typeof(BoardNotify), item.Value);
                         <div class="form-check form-check-inline">
-                            <input type="checkbox" name="PostWriteNotify[]" value="@item.Value" id="postWriteNotify_@item.Value" class="form-check-input" checked="@Model.BoardMeta.Notify.PostWriteNotifyEnum.HasFlag(notifyValue)" />
+                            <input type="checkbox" name="PostWriteNotify[]" value="@item.Value" id="postWriteNotify_@item.Value" class="form-check-input" checked="@(((BoardNotify)(Model.Input.PostWriteNotify ?? 0)).HasFlag(notifyValue))" />
                             <label for="postWriteNotify_@item.Value" class="form-check-label">@item.Text</label>
                         </div>
                     }
@@ -40,9 +44,9 @@
                 @if (notifyList is not null) {
                     @foreach (var item in notifyList)
                     {
-                        var notifyValue = (BoardConst.Notify)Enum.Parse(typeof(BoardConst.Notify), item.Value);
+                        var notifyValue = (BoardNotify)Enum.Parse(typeof(BoardNotify), item.Value);
                         <div class="form-check form-check-inline">
-                            <input type="checkbox" name="CommentWriteNotify[]" value="@item.Value" id="commentWriteNotify_@item.Value" class="form-check-input" checked="@Model.BoardMeta.Notify.CommentWriteNotifyEnum.HasFlag(notifyValue)" />
+                            <input type="checkbox" name="CommentWriteNotify[]" value="@item.Value" id="commentWriteNotify_@item.Value" class="form-check-input" checked="@(((BoardNotify)(Model.Input.CommentWriteNotify ?? 0)).HasFlag(notifyValue))" />
                             <label for="commentWriteNotify_@item.Value" class="form-check-label">@item.Text</label>
                         </div>
                     }
@@ -55,9 +59,9 @@
                 @if (notifyList is not null) {
                     @foreach (var item in notifyList)
                     {
-                        var notifyValue = (BoardConst.Notify)Enum.Parse(typeof(BoardConst.Notify), item.Value);
+                        var notifyValue = (BoardNotify)Enum.Parse(typeof(BoardNotify), item.Value);
                         <div class="form-check form-check-inline">
-                            <input type="checkbox" name="ReplyWriteNotify[]" value="@item.Value" id="replyWriteNotify_@item.Value" class="form-check-input" checked="@Model.BoardMeta.Notify.ReplyWriteNotifyEnum.HasFlag(notifyValue)" />
+                            <input type="checkbox" name="ReplyWriteNotify[]" value="@item.Value" id="replyWriteNotify_@item.Value" class="form-check-input" checked="@(((BoardNotify)(Model.Input.ReplyWriteNotify ?? 0)).HasFlag(notifyValue))" />
                             <label for="replyWriteNotify_@item.Value" class="form-check-label">@item.Text</label>
                         </div>
                     }
@@ -65,14 +69,14 @@
             </div>
         </div>
 
-        <input type="hidden" name="BoardMeta.Notify.PostWriteNotify" id="PostWriteNotifyValue" />
-        <input type="hidden" name="BoardMeta.Notify.CommentWriteNotify" id="CommentWriteNotifyValue" />
-        <input type="hidden" name="BoardMeta.Notify.ReplyWriteNotify" id="ReplyWriteNotifyValue" />
+        <input type="hidden" name="Input.PostWriteNotify" id="PostWriteNotifyValue" />
+        <input type="hidden" name="Input.CommentWriteNotify" id="CommentWriteNotifyValue" />
+        <input type="hidden" name="Input.ReplyWriteNotify" id="ReplyWriteNotifyValue" />
 
         <hr/>
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
     </form>
 </div>

+ 95 - 0
Admin/Pages/Forum/Board/Meta/Notify.cshtml.cs

@@ -0,0 +1,95 @@
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+using Domain.Entities.Forum.Boards;
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class NotifyModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+    public List<SelectListItem> NotifyList { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public byte? PostWriteNotify { get; set; }
+        public byte? CommentWriteNotify { get; set; }
+        public byte? ReplyWriteNotify { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        NotifyList = [..Enum.GetValues<BoardNotify>().Select(e => new SelectListItem
+        {
+            Value = ((byte)e).ToString(),
+            Text = e.GetType().GetMember(e.ToString()).FirstOrDefault() ?.GetCustomAttribute<DisplayAttribute>()?.Name ?? e.ToString()
+        })];
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            PostWriteNotify = meta.Notify.PostWriteNotify,
+            CommentWriteNotify = meta.Notify.CommentWriteNotify,
+            ReplyWriteNotify = meta.Notify.ReplyWriteNotify
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null,
+                null,
+                null,
+                null,
+                null,
+                null,
+                new BoardMetaNotify
+                {
+                    PostWriteNotify = Input.PostWriteNotify,
+                    CommentWriteNotify = Input.CommentWriteNotify,
+                    ReplyWriteNotify = Input.ReplyWriteNotify
+                },
+                null,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "알림 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/Notify/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 29 - 26
Admin/Pages/Forum/Board/Meta/NotifyTemplate.cshtml

@@ -1,58 +1,61 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
-@using Library.Constants
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.NotifyTemplateModel
 @{
     ViewData["Title"] = "게시판 관리 - 양식";
+    ViewData["Sector"] = "NotifyTemplate";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" /> 
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
     <partial name="_Editor" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/NotifyTemplate" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <div class="text-muted form-text mb-3">
             이메일 전송에 사용되는 알림 양식을 설정합니다. 양식 별로 치환 가능한 변수가 다릅니다. 변수를 확인해주세요.
         </div>
 
         <div class="row mb-3">
-            <label for="BoardMeta_NotifyTemplate_PostWriteEmailNotifySubject" class="col-md-2 col-form-label">게시글 작성 시</label>
+            <label for="Input_PostWriteEmailNotifySubject" class="col-md-2 col-form-label">게시글 작성 시</label>
             <div class="col-md-10">
-                <input asp-for="BoardMeta.NotifyTemplate.PostWriteEmailNotifySubject" class="form-control mb-2" maxlength="255" placeholder="제목" />
-                <span asp-validation-for="BoardMeta.NotifyTemplate.PostWriteEmailNotifySubject" class="text-danger"></span>
-                <textarea asp-for="BoardMeta.NotifyTemplate.PostWriteEmailNotifyContent" class="ck-editor"></textarea>
-                <span asp-validation-for="BoardMeta.NotifyTemplate.PostWriteEmailNotifyContent" class="text-danger"></span>
+                <input asp-for="Input.PostWriteEmailNotifySubject" class="form-control mb-2" maxlength="255" placeholder="제목" />
+                <span asp-validation-for="Input.PostWriteEmailNotifySubject" class="text-danger"></span>
+                <textarea asp-for="Input.PostWriteEmailNotifyContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.PostWriteEmailNotifyContent" class="text-danger"></span>
             </div>
         </div>
         <hr/>
         <div class="row mb-3">
-            <label for="BoardMeta_NotifyTemplate_CommentWriteEmailNotifySubject" class="col-md-2 col-form-label">댓글 작성 시</label>
+            <label for="Input_CommentWriteEmailNotifySubject" class="col-md-2 col-form-label">댓글 작성 시</label>
             <div class="col-md-10">
-                <input asp-for="BoardMeta.NotifyTemplate.CommentWriteEmailNotifySubject" class="form-control mb-2" maxlength="255" placeholder="제목" />
-                <span asp-validation-for="BoardMeta.NotifyTemplate.CommentWriteEmailNotifySubject" class="text-danger"></span>
-                <textarea asp-for="BoardMeta.NotifyTemplate.CommentWriteEmailNotifyContent" class="ck-editor"></textarea>
-                <span asp-validation-for="BoardMeta.NotifyTemplate.CommentWriteEmailNotifyContent" class="text-danger"></span>
+                <input asp-for="Input.CommentWriteEmailNotifySubject" class="form-control mb-2" maxlength="255" placeholder="제목" />
+                <span asp-validation-for="Input.CommentWriteEmailNotifySubject" class="text-danger"></span>
+                <textarea asp-for="Input.CommentWriteEmailNotifyContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.CommentWriteEmailNotifyContent" class="text-danger"></span>
             </div>
         </div>
         <hr/>
         <div class="row mb-3">
-            <label for="BoardMeta_NotifyTemplate_ReplyWriteEmailNotifySubject" class="col-md-2 col-form-label">답글 작성 시</label>
+            <label for="Input_ReplyWriteEmailNotifySubject" class="col-md-2 col-form-label">답글 작성 시</label>
             <div class="col-md-10">
-                <input asp-for="BoardMeta.NotifyTemplate.ReplyWriteEmailNotifySubject" class="form-control mb-2" maxlength="255" placeholder="제목" />
-                <span asp-validation-for="BoardMeta.NotifyTemplate.ReplyWriteEmailNotifySubject" class="text-danger"></span>
-                <textarea asp-for="BoardMeta.NotifyTemplate.ReplyWriteEmailNotifyContent" class="ck-editor"></textarea>
-                <span asp-validation-for="BoardMeta.NotifyTemplate.ReplyWriteEmailNotifyContent" class="text-danger"></span>
+                <input asp-for="Input.ReplyWriteEmailNotifySubject" class="form-control mb-2" maxlength="255" placeholder="제목" />
+                <span asp-validation-for="Input.ReplyWriteEmailNotifySubject" class="text-danger"></span>
+                <textarea asp-for="Input.ReplyWriteEmailNotifyContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.ReplyWriteEmailNotifyContent" class="text-danger"></span>
             </div>
         </div>
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
-</div>
+</div>

+ 86 - 0
Admin/Pages/Forum/Board/Meta/NotifyTemplate.cshtml.cs

@@ -0,0 +1,86 @@
+using Domain.Entities.Forum.Boards;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class NotifyTemplateModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public string? PostWriteEmailNotifySubject { get; set; }
+        public string? PostWriteEmailNotifyContent { get; set; }
+        public string? CommentWriteEmailNotifySubject { get; set; }
+        public string? CommentWriteEmailNotifyContent { get; set; }
+        public string? ReplyWriteEmailNotifySubject { get; set; }
+        public string? ReplyWriteEmailNotifyContent { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            PostWriteEmailNotifySubject = meta.NotifyTemplate.PostWriteEmailNotifySubject,
+            PostWriteEmailNotifyContent = meta.NotifyTemplate.PostWriteEmailNotifyContent,
+            CommentWriteEmailNotifySubject = meta.NotifyTemplate.CommentWriteEmailNotifySubject,
+            CommentWriteEmailNotifyContent = meta.NotifyTemplate.CommentWriteEmailNotifyContent,
+            ReplyWriteEmailNotifySubject = meta.NotifyTemplate.ReplyWriteEmailNotifySubject,
+            ReplyWriteEmailNotifyContent = meta.NotifyTemplate.ReplyWriteEmailNotifyContent
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null, null, null, null, null, null, null,
+                new BoardMetaNotifyTemplate
+                {
+                    PostWriteEmailNotifySubject = Input.PostWriteEmailNotifySubject,
+                    PostWriteEmailNotifyContent = Input.PostWriteEmailNotifyContent,
+                    CommentWriteEmailNotifySubject = Input.CommentWriteEmailNotifySubject,
+                    CommentWriteEmailNotifyContent = Input.CommentWriteEmailNotifyContent,
+                    ReplyWriteEmailNotifySubject = Input.ReplyWriteEmailNotifySubject,
+                    ReplyWriteEmailNotifyContent = Input.ReplyWriteEmailNotifyContent
+                },
+                null), ct);
+
+            TempData["SuccessMessage"] = "알림 양식이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/NotifyTemplate/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 38 - 35
Admin/Pages/Forum/Board/Meta/Permission.cshtml

@@ -1,82 +1,85 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
-@using Library.Constants
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.PermissionModel
 @{
     ViewData["Title"] = "게시판 관리 - 권한";
+    ViewData["Sector"] = "Permission";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/Permission" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <p class="text-muted form-text">권한은 최고관리자가 가장 높습니다. 권한이 높은 순으로 정책이 적용됩니다.</p>
 
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_BoardAccess" class="col-md-2 col-form-label">게시판 접근</label>
+            <label for="Input_BoardAccess" class="col-md-2 col-form-label">게시판 접근</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.BoardAccess" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.BoardAccess" class="text-danger"></span>
+                <select asp-for="Input.BoardAccess" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.BoardAccess" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_PostView" class="col-md-2 col-form-label">글 열람</label>
+            <label for="Input_PostView" class="col-md-2 col-form-label">글 열람</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.PostView" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.PostView" class="text-danger"></span>
+                <select asp-for="Input.PostView" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.PostView" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_PostWrite" class="col-md-2 col-form-label">글 작성</label>
+            <label for="Input_PostWrite" class="col-md-2 col-form-label">글 작성</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.PostWrite" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.PostWrite" class="text-danger"></span>
+                <select asp-for="Input.PostWrite" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.PostWrite" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_CommentView" class="col-md-2 col-form-label">댓글 목록</label>
+            <label for="Input_CommentView" class="col-md-2 col-form-label">댓글 목록</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.CommentView" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.CommentView" class="text-danger"></span>
+                <select asp-for="Input.CommentView" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.CommentView" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_CommentWrite" class="col-md-2 col-form-label">댓글 작성</label>
+            <label for="Input_CommentWrite" class="col-md-2 col-form-label">댓글 작성</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.CommentWrite" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.CommentWrite" class="text-danger"></span>
+                <select asp-for="Input.CommentWrite" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.CommentWrite" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_ReplyWrite" class="col-md-2 col-form-label">답글 작성</label>
+            <label for="Input_ReplyWrite" class="col-md-2 col-form-label">답글 작성</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.ReplyWrite" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.ReplyWrite" class="text-danger"></span>
+                <select asp-for="Input.ReplyWrite" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.ReplyWrite" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_FileUpload" class="col-md-2 col-form-label">파일 업로드</label>
+            <label for="Input_FileUpload" class="col-md-2 col-form-label">파일 업로드</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.FileUpload" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.FileUpload" class="text-danger"></span>
+                <select asp-for="Input.FileUpload" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.FileUpload" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Permission_FileDownload" class="col-md-2 col-form-label">파일 다운로드</label>
+            <label for="Input_FileDownload" class="col-md-2 col-form-label">파일 다운로드</label>
             <div class="col-12 col-lg-auto">
-                <select asp-for="BoardMeta.Permission.FileDownload" class="form-select" asp-items="@Model.Permissions"></select>
-                <span asp-validation-for="BoardMeta.Permission.FileDownload" class="text-danger"></span>
+                <select asp-for="Input.FileDownload" class="form-select" asp-items="@Model.PermissionList"></select>
+                <span asp-validation-for="Input.FileDownload" class="text-danger"></span>
             </div>
         </div>
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@@Model.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
-</div>
+</div>

+ 103 - 0
Admin/Pages/Forum/Board/Meta/Permission.cshtml.cs

@@ -0,0 +1,103 @@
+using Domain.Entities.Forum.Boards;
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Extensions;
+using MediatR;
+using System.ComponentModel.DataAnnotations;
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class PermissionModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+    public List<SelectListItem> PermissionList { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public short BoardAccess { get; set; }
+        public short PostView { get; set; }
+        public short PostWrite { get; set; }
+        public short CommentView { get; set; }
+        public short CommentWrite { get; set; }
+        public short ReplyWrite { get; set; }
+        public short FileUpload { get; set; }
+        public short FileDownload { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        PermissionList = [..Enum.GetValues<BoardPermission>().Select(e => new SelectListItem
+        {
+            Value = ((short)e).ToString(),
+            Text = e.GetType().GetMember(e.ToString()).FirstOrDefault() ?.GetCustomAttribute<DisplayAttribute>()?.Name ?? e.ToString()
+        })];
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            BoardAccess = meta.Permission.BoardAccess,
+            PostView = meta.Permission.PostView,
+            PostWrite = meta.Permission.PostWrite,
+            CommentView = meta.Permission.CommentView,
+            CommentWrite = meta.Permission.CommentWrite,
+            ReplyWrite = meta.Permission.ReplyWrite,
+            FileUpload = meta.Permission.FileUpload,
+            FileDownload = meta.Permission.FileDownload
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null, null, null, null, null,
+                new BoardMetaPermission
+                {
+                    BoardAccess = Input.BoardAccess,
+                    PostView = Input.PostView,
+                    PostWrite = Input.PostWrite,
+                    CommentView = Input.CommentView,
+                    CommentWrite = Input.CommentWrite,
+                    ReplyWrite = Input.ReplyWrite,
+                    FileUpload = Input.FileUpload,
+                    FileDownload = Input.FileDownload
+                },
+                null, null, null), ct);
+
+            TempData["SuccessMessage"] = "권한 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/Permission/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 59 - 55
Admin/Pages/Forum/Board/Meta/View.cshtml

@@ -1,175 +1,179 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.ViewMetaModel
 @{
     ViewData["Title"] = "게시판 관리 - 열람";
+    ViewData["Sector"] = "View";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/View" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowBookmark" class="col-md-2 col-form-label">즐겨찾기 기능</label>
+            <label for="Input_AllowBookmark" class="col-md-2 col-form-label">즐겨찾기 기능</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowBookmark" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowBookmark" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowBookmark" class="form-check-input" />
+                    <label asp-for="Input.AllowBookmark" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글을 즐겨찾기 시 글 보관함에 등록할 수있습니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowLike" class="col-md-2 col-form-label">좋아요 기능</label>
+            <label for="Input_AllowLike" class="col-md-2 col-form-label">좋아요 기능</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowLike" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowLike" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowLike" class="form-check-input" />
+                    <label asp-for="Input.AllowLike" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 반응 기능(좋아요)를 사용할 수 있도록 합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowDislike" class="col-md-2 col-form-label">싫어요 기능</label>
+            <label for="Input_AllowDislike" class="col-md-2 col-form-label">싫어요 기능</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowDislike" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowDislike" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowDislike" class="form-check-input" />
+                    <label asp-for="Input.AllowDislike" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 반응 기능(싫어요)를 사용할 수 있도록 합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowPrint" class="col-md-2 col-form-label">본문 인쇄 기능</label>
+            <label for="Input_AllowPrint" class="col-md-2 col-form-label">본문 인쇄 기능</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowPrint" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowPrint" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowPrint" class="form-check-input" />
+                    <label asp-for="Input.AllowPrint" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 내용을 인쇄할 수 있도록 합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowSnsShare" class="col-md-2 col-form-label">SNS 보내기 기능</label>
+            <label for="Input_AllowSnsShare" class="col-md-2 col-form-label">SNS 보내기 기능</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowSnsShare" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowSnsShare" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowSnsShare" class="form-check-input" />
+                    <label asp-for="Input.AllowSnsShare" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">Facebook, Twitter, Reddit, Band 공유 기능을 활성화 합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowPrevNextBotton" class="col-md-2 col-form-label">이전글, 다음글 버튼</label>
+            <label for="Input_AllowPrevNextBotton" class="col-md-2 col-form-label">이전글, 다음글 버튼</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowPrevNextBotton" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowPrevNextBotton" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowPrevNextBotton" class="form-check-input" />
+                    <label asp-for="Input.AllowPrevNextBotton" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 이전, 다음 글 버튼을 활성화 합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowBlame" class="col-md-2 col-form-label">신고 기능</label>
+            <label for="Input_AllowBlame" class="col-md-2 col-form-label">신고 기능</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowBlame" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowBlame" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowBlame" class="form-check-input" />
+                    <label asp-for="Input.AllowBlame" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글을 신고할 수 있도록 합니다. 숨김 횟수가 0이면 작동하지 않습니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_BlameHideCount" class="col-12 col-lg-2">신고 시 숨김 횟수</label>
+            <label for="Input_BlameHideCount" class="col-12 col-lg-2">신고 시 숨김 횟수</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.View.BlameHideCount" class="form-control" min="0" max="100" required />
-                        <span asp-validation-for="BoardMeta.View.BlameHideCount" class="text-danger"></span>
+                        <input type="number" asp-for="Input.BlameHideCount" class="form-control" min="0" max="100" required />
+                        <span asp-validation-for="Input.BlameHideCount" class="text-danger"></span>
                     </div>
                 </div>
                 <small class="text-muted form-text">지정한 횟수 이상 신고가 발생하면 게시물을 숨김 처리합니다. 숨김된 게시물은 관리자와 본인만 열람이 가능합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowContentLinkTargetBlank" class="col-md-2 col-form-label">URL 새창 열림</label>
+            <label for="Input_AllowContentLinkTargetBlank" class="col-md-2 col-form-label">URL 새창 열림</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowContentLinkTargetBlank" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowContentLinkTargetBlank" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowContentLinkTargetBlank" class="form-check-input" />
+                    <label asp-for="Input.AllowContentLinkTargetBlank" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">본문 안의 URL 주소는 무조건 새창으로 열립니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowPostUrlCopy" class="col-md-2 col-form-label">주소 복사 버튼</label>
+            <label for="Input_AllowPostUrlCopy" class="col-md-2 col-form-label">주소 복사 버튼</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowPostUrlCopy" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowPostUrlCopy" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowPostUrlCopy" class="form-check-input" />
+                    <label asp-for="Input.AllowPostUrlCopy" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">글 주소를 복사 할 수 있는 버튼이 나타납니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_AllowPostUrlQrCode" class="col-md-2 col-form-label">글 주소 QR 코드</label>
+            <label for="Input_AllowPostUrlQrCode" class="col-md-2 col-form-label">글 주소 QR 코드</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.AllowPostUrlQrCode" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.AllowPostUrlQrCode" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowPostUrlQrCode" class="form-check-input" />
+                    <label asp-for="Input.AllowPostUrlQrCode" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">글 주소를 복사하는 버튼 우측에 현재 주소를 담은 QR 코드를 볼 수 있는 버튼이 생겨납니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_ShowMemberPhoto" class="col-md-2 col-form-label">회원 사진 공개</label>
+            <label for="Input_ShowMemberPhoto" class="col-md-2 col-form-label">회원 사진 공개</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.ShowMemberPhoto" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.ShowMemberPhoto" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberPhoto" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberPhoto" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 상단에 회원 사진이 노출됩니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_ShowMemberIcon" class="col-md-2 col-form-label">회원 아이콘 공개</label>
+            <label for="Input_ShowMemberIcon" class="col-md-2 col-form-label">회원 아이콘 공개</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.ShowMemberIcon" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.ShowMemberIcon" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberIcon" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberIcon" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 상단에 회원 아이콘이 노출됩니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_ShowMemberRegDate" class="col-md-2 col-form-label">회원 가입일 공개</label>
+            <label for="Input_ShowMemberRegDate" class="col-md-2 col-form-label">회원 가입일 공개</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.ShowMemberRegDate" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.ShowMemberRegDate" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberRegDate" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberRegDate" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 작성자의 가입일을 보여줍니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_View_ShowMemberSummary" class="col-md-2 col-form-label">오늘의 한마디 공개</label>
+            <label for="Input_ShowMemberSummary" class="col-md-2 col-form-label">오늘의 한마디 공개</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.View.ShowMemberSummary" class="form-check-input" />
-                    <label asp-for="BoardMeta.View.ShowMemberSummary" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowMemberSummary" class="form-check-input" />
+                    <label asp-for="Input.ShowMemberSummary" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시글 작성자의 오늘의 한마디를 보여줍니다.</small>
             </div>
         </div>
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
-</div>
+</div>

+ 113 - 0
Admin/Pages/Forum/Board/Meta/View.cshtml.cs

@@ -0,0 +1,113 @@
+using Domain.Entities.Forum.Boards;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class ViewMetaModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public bool AllowBookmark { get; set; }
+        public bool AllowLike { get; set; }
+        public bool AllowDislike { get; set; }
+        public bool AllowPrint { get; set; }
+        public bool AllowSnsShare { get; set; }
+        public bool AllowPrevNextBotton { get; set; }
+        public bool AllowBlame { get; set; }
+        public ushort BlameHideCount { get; set; }
+        public bool AllowContentLinkTargetBlank { get; set; }
+        public bool AllowPostUrlCopy { get; set; }
+        public bool AllowPostUrlQrCode { get; set; }
+        public bool ShowMemberPhoto { get; set; }
+        public bool ShowMemberIcon { get; set; }
+        public bool ShowMemberRegDate { get; set; }
+        public bool ShowMemberSummary { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            AllowBookmark = meta.View.AllowBookmark,
+            AllowLike = meta.View.AllowLike,
+            AllowDislike = meta.View.AllowDislike,
+            AllowPrint = meta.View.AllowPrint,
+            AllowSnsShare = meta.View.AllowSnsShare,
+            AllowPrevNextBotton = meta.View.AllowPrevNextBotton,
+            AllowBlame = meta.View.AllowBlame,
+            BlameHideCount = meta.View.BlameHideCount,
+            AllowContentLinkTargetBlank = meta.View.AllowContentLinkTargetBlank,
+            AllowPostUrlCopy = meta.View.AllowPostUrlCopy,
+            AllowPostUrlQrCode = meta.View.AllowPostUrlQrCode,
+            ShowMemberPhoto = meta.View.ShowMemberPhoto,
+            ShowMemberIcon = meta.View.ShowMemberIcon,
+            ShowMemberRegDate = meta.View.ShowMemberRegDate,
+            ShowMemberSummary = meta.View.ShowMemberSummary
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null,
+                new BoardMetaView
+                {
+                    AllowBookmark = Input.AllowBookmark,
+                    AllowLike = Input.AllowLike,
+                    AllowDislike = Input.AllowDislike,
+                    AllowPrint = Input.AllowPrint,
+                    AllowSnsShare = Input.AllowSnsShare,
+                    AllowPrevNextBotton = Input.AllowPrevNextBotton,
+                    AllowBlame = Input.AllowBlame,
+                    BlameHideCount = Input.BlameHideCount,
+                    AllowContentLinkTargetBlank = Input.AllowContentLinkTargetBlank,
+                    AllowPostUrlCopy = Input.AllowPostUrlCopy,
+                    AllowPostUrlQrCode = Input.AllowPostUrlQrCode,
+                    ShowMemberPhoto = Input.ShowMemberPhoto,
+                    ShowMemberIcon = Input.ShowMemberIcon,
+                    ShowMemberRegDate = Input.ShowMemberRegDate,
+                    ShowMemberSummary = Input.ShowMemberSummary
+                },
+                null, null, null, null, null, null, null), ct);
+
+            TempData["SuccessMessage"] = "열람 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/View/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 83 - 79
Admin/Pages/Forum/Board/Meta/Write.cshtml

@@ -1,26 +1,30 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
-@using Library.Constants
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.Meta.WriteModel
+@using Domain.Entities.Forum.Constants
 @{
     ViewData["Title"] = "게시판 관리 - 작성";
+    ViewData["Sector"] = "Write";
+    ViewData["BoardID"] = Model.BoardID;
+    ViewData["BoardList"] = Model.BoardList;
+    ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/Meta/_Header.cshtml"/>
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
     <partial name="_Editor" />
-    <partial name="~/Views/Forum/Board/Meta/_Navbar.cshtml" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
-    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" action="/Forum/Board/Meta/Update/Write" enctype="multipart/form-data">
-        <input type="hidden" name="BoardMeta.Board.Code" value="@Model.Board.Code" />
-        <input type="hidden" asp-for="BoardMeta.ID" />
-        <input type="hidden" asp-for="BoardMeta.BoardID" />
+    <form name="f_admin_write" id="fAdminWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
 
         <div class="row mb-3">
-            <label for="BoardMeta_Write_ShowHeader" class="col-sm-2 col-form-label">상단 내용</label>
+            <label for="Input_ShowHeader" class="col-sm-2 col-form-label">상단 내용</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.ShowHeader" class="form-check-input" />
-                    <label for="BoardMeta_Write_ShowHeader" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowHeader" class="form-check-input" />
+                    <label for="Input_ShowHeader" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시판 상단에 내용을 출력합니다.</small>
             </div>
@@ -28,17 +32,17 @@
         <div class="row mb-3">
             <label class="col-md-2 col-form-label"></label>
             <div class="col-md-10">
-                <textarea asp-for="BoardMeta.Write.HeaderContent" class="form-control ck-editor"></textarea>
-                <span asp-validation-for="BoardMeta.Write.HeaderContent" class="text-danger"></span>
+                <textarea asp-for="Input.HeaderContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.HeaderContent" class="text-danger"></span>
                 <small class="text-muted form-text">작성란 상단에 표시될 내용을 입력합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_ShowFooter" class="col-sm-2 col-form-label">하단 내용</label>
+            <label for="Input_ShowFooter" class="col-sm-2 col-form-label">하단 내용</label>
             <div class="col-md-10">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.ShowFooter" class="form-check-input" />
-                    <label for="BoardMeta_Write_ShowFooter" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.ShowFooter" class="form-check-input" />
+                    <label for="Input_ShowFooter" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">게시판 하단에 내용을 출력합니다.</small>
             </div>
@@ -46,87 +50,87 @@
         <div class="row mb-3">
             <label class="col-md-2 col-form-label"></label>
             <div class="col-md-10">
-                <textarea asp-for="BoardMeta.Write.FooterContent" class="form-control ck-editor"></textarea>
-                <span asp-validation-for="BoardMeta.Write.FooterContent" class="text-danger"></span>
+                <textarea asp-for="Input.FooterContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.FooterContent" class="text-danger"></span>
                 <small class="text-muted form-text">작성란 하단에 표시될 내용을 입력합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_DefaultSubject" class="col-md-2 col-form-label">기본 제목</label>
+            <label for="Input_DefaultSubject" class="col-md-2 col-form-label">기본 제목</label>
             <div class="col-md-10">
-                <input type="text" asp-for="BoardMeta.Write.DefaultSubject" class="form-control" maxlength="@PostConst.MaxAllowedSubjectLength" />
-                <span asp-validation-for="BoardMeta.Write.DefaultSubject" class="text-danger"></span>
+                <input type="text" asp-for="Input.DefaultSubject" class="form-control" maxlength="@PostConstant.MaxAllowedSubjectLength" />
+                <span asp-validation-for="Input.DefaultSubject" class="text-danger"></span>
                 <small class="text-muted form-text">글 작성 시 기본으로 표시될 제목입니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_DefaultContent" class="col-md-2 col-form-label">기본 내용</label>
+            <label for="Input_DefaultContent" class="col-md-2 col-form-label">기본 내용</label>
             <div class="col-md-10">
-                <textarea asp-for="BoardMeta.Write.DefaultContent" class="form-control @(Model.BoardMeta.Write.AllowEditor ? "ck-editor" : "")" maxlength="@PostConst.MaxAllowedContentLength"></textarea>
-                <span asp-validation-for="BoardMeta.Write.DefaultContent" class="text-danger"></span>
+                <textarea asp-for="Input.DefaultContent" class="form-control @(Model.Input.AllowEditor ? "ck-editor" : "")" maxlength="@PostConstant.MaxAllowedContentLength"></textarea>
+                <span asp-validation-for="Input.DefaultContent" class="text-danger"></span>
                 <small class="text-muted form-text">글 작성 시 기본으로 표시될 내용입니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_AllowEditor" class="col-md-2 col-form-label">웹 에디터 사용</label>
+            <label for="Input_AllowEditor" class="col-md-2 col-form-label">웹 에디터 사용</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.AllowEditor" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.AllowEditor" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowEditor" class="form-check-input" />
+                    <label asp-for="Input.AllowEditor" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">본문을 웹 기반 에디터로 수정할 수 있도록합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_AllowPrefix" class="col-md-2 col-form-label">말머리 사용</label>
+            <label for="Input_AllowPrefix" class="col-md-2 col-form-label">말머리 사용</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.AllowPrefix" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.AllowPrefix" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowPrefix" class="form-check-input" />
+                    <label asp-for="Input.AllowPrefix" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">말머리를 사용할 수 있도록 합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_RequiredPrefix" class="col-md-2 col-form-label">말머리 필수 선택</label>
+            <label for="Input_RequiredPrefix" class="col-md-2 col-form-label">말머리 필수 선택</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.RequiredPrefix" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.RequiredPrefix" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.RequiredPrefix" class="form-check-input" />
+                    <label asp-for="Input.RequiredPrefix" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">말머리를 필수 선택하도록 합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_AllowSecret" class="col-md-2 col-form-label">비밀글 사용</label>
+            <label for="Input_AllowSecret" class="col-md-2 col-form-label">비밀글 사용</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.AllowSecret" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.AllowSecret" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowSecret" class="form-check-input" />
+                    <label asp-for="Input.AllowSecret" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">비밀글 작성 기능을 활성화합니다. 비밀글은 작성자 본인과 게시판 관리자 이상만 열람 가능합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_AllowTag" class="col-md-2 col-form-label">태그 사용</label>
+            <label for="Input_AllowTag" class="col-md-2 col-form-label">태그 사용</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.AllowTag" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.AllowTag" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowTag" class="form-check-input" />
+                    <label asp-for="Input.AllowTag" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">태그 기능을 활성화합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_TagLimit" class="col-12 col-lg-2 col-form-label">태그 개수 제한</label>
+            <label for="Input_TagLimit" class="col-12 col-lg-2 col-form-label">태그 개수 제한</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Write.TagLimit" class="form-control" min="1" max="{@PostConst.MaxAllowedTags}" required />
-                        <span asp-validation-for="BoardMeta.Write.TagLimit" class="text-danger"></span>
+                        <input type="number" asp-for="Input.TagLimit" class="form-control" min="1" max="@PostConstant.MaxAllowedTags" required />
+                        <span asp-validation-for="Input.TagLimit" class="text-danger"></span>
                     </div>
                 </div>
-                <small class="text-muted form-text">태그의 최대 개수를 설정합니다. 최대 {@PostConst.MaxAllowedTags}개</small>
+                <small class="text-muted form-text">태그의 최대 개수를 설정합니다. 최대 @(PostConstant.MaxAllowedTags)개</small>
             </div>
         </div>
 
@@ -134,108 +138,108 @@
         <h3>웹 에디터 기능 설정</h3>
         <hr />
         <div class="row mb-3">
-            <label for="BoardMeta_Write_AllowImage" class="col-md-2 col-form-label">이미지 사용</label>
+            <label for="Input_AllowImage" class="col-md-2 col-form-label">이미지 사용</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.AllowImage" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.AllowImage" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowImage" class="form-check-input" />
+                    <label asp-for="Input.AllowImage" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">이미지 업로드 기능을 활성화합니다. </small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_ImageUploadLimit" class="col-12 col-lg-2 col-form-label">이미지 개수 제한</label>
+            <label for="Input_ImageUploadLimit" class="col-12 col-lg-2 col-form-label">이미지 개수 제한</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Write.ImageUploadLimit" class="form-control" min="1" max="{@PostConst.MaxAllowedImages}" required />
-                        <span asp-validation-for="BoardMeta.Write.ImageUploadLimit" class="text-danger"></span>
+                        <input type="number" asp-for="Input.ImageUploadLimit" class="form-control" min="1" max="@PostConstant.MaxAllowedImages" required />
+                        <span asp-validation-for="Input.ImageUploadLimit" class="text-danger"></span>
                     </div>
                 </div>
-                <small class="text-muted form-text">첨부 가능한 이미지의 최대 개수를 설정합니다. 최대 @(PostConst.MaxAllowedImages)개</small>
+                <small class="text-muted form-text">첨부 가능한 이미지의 최대 개수를 설정합니다. 최대 @(PostConstant.MaxAllowedImages)개</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_ImageUploadMaxSize" class="col-12 col-lg-2 col-form-label">이미지 용량 제한</label>
+            <label for="Input_ImageUploadMaxSize" class="col-12 col-lg-2 col-form-label">이미지 용량 제한</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
                         <div class="input-group">
-                            <input type="number" asp-for="BoardMeta.Write.ImageUploadMaxSize" class="form-control" min="0" required />
+                            <input type="number" asp-for="Input.ImageUploadMaxSize" class="form-control" min="0" required />
                             <span class="input-group-text">KB</span>
                         </div>
-                        <span asp-validation-for="BoardMeta.Write.ImageUploadMaxSize" class="text-danger"></span>
+                        <span asp-validation-for="Input.ImageUploadMaxSize" class="text-danger"></span>
                     </div>
                 </div>
-                <small class="text-muted form-text">이미지 하나당 최대 용량을 설정합니다. 최대 @(PostConst.MaxAllowedImageSize)KB</small>
+                <small class="text-muted form-text">이미지 하나당 최대 용량을 설정합니다. 최대 @(PostConstant.MaxAllowedImageSize)KB</small>
             </div>
         </div>
         <hr />
         <div class="row mb-3">
-            <label for="BoardMeta_Write_AllowMedia" class="col-md-2 col-form-label">미디어 사용</label>
+            <label for="Input_AllowMedia" class="col-md-2 col-form-label">미디어 사용</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.AllowMedia" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.AllowMedia" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowMedia" class="form-check-input" />
+                    <label asp-for="Input.AllowMedia" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">미디어 추가 기능을 활성화합니다. (웹 에디터를 사용해야 가능)</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_MediaUploadLimit" class="col-12 col-lg-2 col-form-label">동영상 개수 제한</label>
+            <label for="Input_MediaUploadLimit" class="col-12 col-lg-2 col-form-label">동영상 개수 제한</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Write.MediaUploadLimit" class="form-control" min="1" max="@PostConst.MaxAllowedMedias" required />
-                        <span asp-validation-for="BoardMeta.Write.MediaUploadLimit" class="text-danger"></span>
+                        <input type="number" asp-for="Input.MediaUploadLimit" class="form-control" min="1" max="@PostConstant.MaxAllowedMedias" required />
+                        <span asp-validation-for="Input.MediaUploadLimit" class="text-danger"></span>
                     </div>
                 </div>
-                <small class="text-muted form-text">첨부 가능한 동영상의 최대 개수를 설정합니다. 최대 @(PostConst.MaxAllowedMedias)개</small>
+                <small class="text-muted form-text">첨부 가능한 동영상의 최대 개수를 설정합니다. 최대 @(PostConstant.MaxAllowedMedias)개</small>
             </div>
         </div>
         <hr />
         <div class="row mb-3">
-            <label for="BoardMeta_Write_AllowFile" class="col-md-2 col-form-label">파일 사용</label>
+            <label for="Input_AllowFile" class="col-md-2 col-form-label">파일 사용</label>
             <div class="col-md-10 align-self-center">
                 <div class="form-check">
-                    <input type="checkbox" asp-for="BoardMeta.Write.AllowFile" class="form-check-input" />
-                    <label asp-for="BoardMeta.Write.AllowFile" class="form-check-label">사용합니다.</label>
+                    <input type="checkbox" asp-for="Input.AllowFile" class="form-check-input" />
+                    <label asp-for="Input.AllowFile" class="form-check-label">사용합니다.</label>
                 </div>
                 <small class="text-muted form-text">파일 추가 기능을 활성화합니다.</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_FileUploadLimit" class="col-12 col-lg-2 col-form-label">파일 개수 제한</label>
+            <label for="Input_FileUploadLimit" class="col-12 col-lg-2 col-form-label">파일 개수 제한</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
-                        <input type="number" asp-for="BoardMeta.Write.FileUploadLimit" class="form-control" min="1" max="@PostConst.MaxAllowedFiles" required />
-                        <span asp-validation-for="BoardMeta.Write.FileUploadLimit" class="text-danger"></span>
+                        <input type="number" asp-for="Input.FileUploadLimit" class="form-control" min="1" max="@PostConstant.MaxAllowedFiles" required />
+                        <span asp-validation-for="Input.FileUploadLimit" class="text-danger"></span>
                     </div>
                 </div>
-                <small class="text-muted form-text">첨부 가능한 파일의 최대 개수를 설정합니다. 최대 @(PostConst.MaxAllowedFiles)개</small>
+                <small class="text-muted form-text">첨부 가능한 파일의 최대 개수를 설정합니다. 최대 @(PostConstant.MaxAllowedFiles)개</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_FileUploadMaxSize" class="col-12 col-lg-2 col-form-label">파일 용량 제한</label>
+            <label for="Input_FileUploadMaxSize" class="col-12 col-lg-2 col-form-label">파일 용량 제한</label>
             <div class="col-lg-10">
                 <div class="row">
                     <div class="col-12 col-lg-auto">
                         <div class="input-group">
-                            <input type="number" asp-for="BoardMeta.Write.FileUploadMaxSize" class="form-control" min="1" max="@PostConst.MaxAllowedFileSize" required />
+                            <input type="number" asp-for="Input.FileUploadMaxSize" class="form-control" min="1" max="@PostConstant.MaxAllowedFileSize" required />
                             <span class="input-group-text">KB</span>
                         </div>
-                        <span asp-validation-for="BoardMeta.Write.FileUploadMaxSize" class="text-danger"></span>
+                        <span asp-validation-for="Input.FileUploadMaxSize" class="text-danger"></span>
                     </div>
                 </div>
-                <small class="text-muted form-text">파일 하나당 최대 용량을 설정합니다. 최대 @(PostConst.MaxAllowedFileSize)KB</small>
+                <small class="text-muted form-text">파일 하나당 최대 용량을 설정합니다. 최대 @(PostConstant.MaxAllowedFileSize)KB</small>
             </div>
         </div>
         <div class="row mb-3">
-            <label for="BoardMeta_Write_FileUploadExtension" class="col-md-2 col-form-label">파일 허용 확장자</label>
+            <label for="Input_FileUploadExtension" class="col-md-2 col-form-label">파일 허용 확장자</label>
             <div class="col-md-10">
-                <input type="text" asp-for="BoardMeta.Write.FileUploadExtension" class="form-control" />
-                <span asp-validation-for="BoardMeta.Write.FileUploadExtension" class="text-danger"></span>
+                <input type="text" asp-for="Input.FileUploadExtension" class="form-control" />
+                <span asp-validation-for="Input.FileUploadExtension" class="text-danger"></span>
                 <small class="form-text text-muted">
                     허용할 파일 확장자를 입력합니다. (예: jpg,png,gif)<br/>
                     HTML5 File 속성 사용, `|` 로 구분하여 입력, 입력하지 않으면 확장자 제한없이 첨부 가능
@@ -246,8 +250,8 @@
         </div>
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="/Forum/Board/List?@ViewBag.QueryString" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="/Forum/Board/List\@Model.QueryString" class="btn btn-secondary">취소</a>
         </div>
         <br />
     </form>
@@ -255,8 +259,8 @@
 
 @section Scripts {
     <script>
-        $(document).on("change", "#BoardMeta_Write_AllowEditor", function(e) {
-            const textareaID = "BoardMeta_Write_DefaultContent";
+        $(document).on("change", "#Input_AllowEditor", function(e) {
+            const textareaID = "Input_DefaultContent";
             if (e.target.checked) { // CKEditor 표시
                 initEditor(textareaID);
             } else { // Textarea로 변경

+ 131 - 0
Admin/Pages/Forum/Board/Meta/Write.cshtml.cs

@@ -0,0 +1,131 @@
+using Domain.Entities.Forum.Boards;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Forum.Board.Meta;
+
+public class WriteModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public int ID { get; set; }
+        public int BoardID { get; set; }
+        public bool ShowHeader { get; set; }
+        public string? HeaderContent { get; set; }
+        public bool ShowFooter { get; set; }
+        public string? FooterContent { get; set; }
+        public string? DefaultSubject { get; set; }
+        public string? DefaultContent { get; set; }
+        public bool AllowEditor { get; set; }
+        public bool AllowPrefix { get; set; }
+        public bool RequiredPrefix { get; set; }
+        public bool AllowSecret { get; set; }
+        public bool AllowTag { get; set; }
+        public byte TagLimit { get; set; }
+        public bool AllowImage { get; set; }
+        public byte ImageUploadLimit { get; set; }
+        public int ImageUploadMaxSize { get; set; }
+        public bool AllowMedia { get; set; }
+        public byte MediaUploadLimit { get; set; }
+        public bool AllowFile { get; set; }
+        public byte FileUploadLimit { get; set; }
+        public int FileUploadMaxSize { get; set; }
+        public string? FileUploadExtension { get; set; }
+    }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var meta = await mediator.Send(new GetBoardMeta.Query(id), ct);
+
+        Input = new InputModel
+        {
+            ID = meta.ID,
+            BoardID = meta.BoardID,
+            ShowHeader = meta.Write.ShowHeader,
+            HeaderContent = meta.Write.HeaderContent,
+            ShowFooter = meta.Write.ShowFooter,
+            FooterContent = meta.Write.FooterContent,
+            DefaultSubject = meta.Write.DefaultSubject,
+            DefaultContent = meta.Write.DefaultContent,
+            AllowEditor = meta.Write.AllowEditor,
+            AllowPrefix = meta.Write.AllowPrefix,
+            RequiredPrefix = meta.Write.RequiredPrefix,
+            AllowSecret = meta.Write.AllowSecret,
+            AllowTag = meta.Write.AllowTag,
+            TagLimit = meta.Write.TagLimit,
+            AllowImage = meta.Write.AllowImage,
+            ImageUploadLimit = meta.Write.ImageUploadLimit,
+            ImageUploadMaxSize = meta.Write.ImageUploadMaxSize,
+            AllowMedia = meta.Write.AllowMedia,
+            MediaUploadLimit = meta.Write.MediaUploadLimit,
+            AllowFile = meta.Write.AllowFile,
+            FileUploadLimit = meta.Write.FileUploadLimit,
+            FileUploadMaxSize = meta.Write.FileUploadMaxSize,
+            FileUploadExtension = meta.Write.FileUploadExtension
+        };
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+    {
+        try
+        {
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            await mediator.Send(new UpdateBoardMeta.Command(
+                Input.ID,
+                Input.BoardID,
+                null, null,
+                new BoardMetaWrite
+                {
+                    ShowHeader = Input.ShowHeader,
+                    HeaderContent = Input.HeaderContent,
+                    ShowFooter = Input.ShowFooter,
+                    FooterContent = Input.FooterContent,
+                    DefaultSubject = Input.DefaultSubject,
+                    DefaultContent = Input.DefaultContent,
+                    AllowEditor = Input.AllowEditor,
+                    AllowPrefix = Input.AllowPrefix,
+                    RequiredPrefix = Input.RequiredPrefix,
+                    AllowSecret = Input.AllowSecret,
+                    AllowTag = Input.AllowTag,
+                    TagLimit = Input.TagLimit,
+                    AllowImage = Input.AllowImage,
+                    ImageUploadLimit = Input.ImageUploadLimit,
+                    ImageUploadMaxSize = Input.ImageUploadMaxSize,
+                    AllowMedia = Input.AllowMedia,
+                    MediaUploadLimit = Input.MediaUploadLimit,
+                    AllowFile = Input.AllowFile,
+                    FileUploadLimit = Input.FileUploadLimit,
+                    FileUploadMaxSize = Input.FileUploadMaxSize,
+                    FileUploadExtension = Input.FileUploadExtension
+                },
+                null, null, null, null, null, null), ct);
+
+            TempData["SuccessMessage"] = "작성 설정이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Meta/Write/{Input.BoardID}{Request.QueryString}");
+    }
+}

+ 14 - 25
Admin/Pages/Forum/Board/Meta/_Header.cshtml

@@ -1,44 +1,33 @@
-@model Admin.ViewModels.Forum.Board.Meta.IndexViewModel
-@{
-    var boardID = Model.Board.ID;
-    var boardList = Model.BoardList;
-}
-
 <div class="row">
     <div class="col">
         <h3>@ViewData["Title"]</h3>
     </div>
     <div class="col-auto">
-        <select id="boardID" class="form-select" required>
+        @{
+            var boardID = ViewData["BoardID"];
+            var boardList = ViewData["BoardList"] as List<(int ID, string Name)>;
+            var queryString = ViewData["QueryString"] as string ?? string.Empty;
+        }
+        <select id="boardMetaSelect" class="form-select">
             <option value="">게시판 선택</option>
             @if (boardList != null)
             {
-                @foreach (var row in boardList)
+                @foreach (var (id, name) in boardList)
                 {
-                    <option value="@row.ID" selected="@(row.ID == boardID)">@row.Name</option>
+                    <option value="@id" selected="@(id == (int?)boardID)">@name</option>
                 }
             }
         </select>
     </div>
 </div>
 <hr />
-
 <script type="module">
-    $(document).on("change", "#boardID", function () {
-        var boardID = $(this).val();
-        if (boardID) {
-            const url = new URL(window.location.href);
-
-            const pathSegments = url.pathname.split('/');
-
-            if (pathSegments.length >= 6) {
-                pathSegments[5] = boardID;
-            }
-
-            const newPath = pathSegments.join('/');
-            const newURL = `${newPath}${url.search}`;
-
-            window.location.href = newURL;
+    document.getElementById("boardMetaSelect")?.addEventListener("change", function() {
+        if (this.value) {
+            const path = window.location.pathname;
+            const segments = path.split('/');
+            segments[segments.length - 1] = this.value;
+            window.location.href = segments.join('/') + window.location.search;
         }
     });
 </script>

+ 68 - 36
Admin/Pages/Forum/Board/Prefix.cshtml

@@ -1,39 +1,38 @@
-@model Admin.ViewModels.Forum.Board.Prefix.IndexViewModel
-@using Library.Extensions
+@page "{id:int}"
+@model Admin.Pages.Forum.Board.PrefixModel
 @{
     ViewData["Title"] = "게시판 관리 - 말머리";
+    ViewData["Sector"] = "Prefix";
     ViewData["BoardID"] = Model.BoardID;
     ViewData["BoardList"] = Model.BoardList;
     ViewData["QueryString"] = Model.QueryString;
 }
 
 <div class="container">
-    <partial name="~/Views/Forum/Board/_Header.cshtml" />
+    <partial name="_Header" />
     <partial name="_StatusMessage" />
-    <partial name="~/Views/Forum/Board/_Navbar.cshtml" />
-
-    <form id="fAdminWrite" asp-action="Create" method="post" accept-charset="utf-8" autocomplete="off">
-        <input type="hidden" name="boardID" value="@Model.BoardID" />
+    <partial name="/Pages/Forum/Board/_NavTabs.cshtml" />
 
+    <form id="fAdminWrite" method="post" asp-page-handler="Create" accept-charset="utf-8" autocomplete="off">
         <div class="row g-2">
             <label class="col-lg-1 col-form-label">말머리</label>
             <div class="col-7 col-sm-auto">
                 <div class="input-group">
                     <div class="input-group-text">
-                        <input type="color" name="color" class="h-100" maxlength="10" required />
+                        <input type="color" name="Input.Color" class="h-100" maxlength="10" required />
                     </div>
-                    <input type="text" name="name" class="form-control" maxlength="20" required placeholder="이름" />
+                    <input type="text" name="Input.Name" class="form-control" maxlength="20" required placeholder="이름" />
                 </div>
             </div>
             <div class="col col-lg-auto">
-                <input type="number" name="order" class="form-control" min="-999" max="999" required placeholder="순서" />
+                <input type="number" name="Input.Order" class="form-control" min="-999" max="999" required placeholder="순서" />
             </div>
             <div class="col-12 col-sm-auto text-center">
                 <button type="submit" class="btn btn-primary w-100">등록</button>
             </div>
         </div>
     </form>
-    
+
     <hr/>
 
     <div class="row g-2 align-items-end">
@@ -41,15 +40,13 @@
             Total : @Model.Total.ToString("N0")
         </div>
         <div class="col text-end">
-            <button type="button" id="btnListDelete" class="btn btn-sm btn-danger" form="fAdminList" data-action="/Forum/Board/Prefix/@Model.BoardID/Delete" disabled>삭제</button>
-            <button type="submit" id="btnListSave" class="btn btn-sm btn-success" form="fAdminList" @(Model.Total <= 0 ? "disabled" : "")> 저장</button>
+            <button type="button" id="btnFListDelete" class="btn btn-sm btn-danger" form="fAdminList" disabled>삭제</button>
+            <button type="submit" id="btnFListSave" class="btn btn-sm btn-success" form="fAdminList" @(Model.Total <= 0 ? "disabled" : "")> 저장</button>
         </div>
     </div>
 
     <div class="table-responsive">
-        <form id="fAdminList" asp-action="Update" method="post" accept-charset="utf-8" autocomplete="off">
-            <input type="hidden" name="boardID" value="@Model.BoardID" />
-
+        <form id="fAdminList" method="post" asp-page-handler="Save" accept-charset="utf-8" autocomplete="off">
             <table class="table table-striped table-bordered table-hover mt-3">
                 <caption>
                     게시글 제목에 특정 단어를 넣는 기능입니다. 최대 10개를 추가할 수 있습니다.
@@ -99,28 +96,28 @@
                                         <label for="CheckList_@index" class="form-check-label">@row.ID</label>
                                     </div>
 
-                                    <input type="hidden" name="Items[@index].ID" class="form-control-plaintext text-center" value="@row.ID" />
+                                    <input type="hidden" name="UpdateItems[@index].ID" class="form-control-plaintext text-center" value="@row.ID" />
                                 </td>
                                 <td>
                                     <div class="input-group">
                                         <div class="input-group-text">
-                                            <input type="color" name="Items[@index].Color" class="h-100" maxlength="10" value="@row.Color" required />
+                                            <input type="color" name="UpdateItems[@index].Color" class="h-100" maxlength="10" value="@row.Color" required />
                                         </div>
-                                        <input type="text" name="Items[@index].Name" class="form-control" maxlength="20" value="@row.Name" required />
+                                        <input type="text" name="UpdateItems[@index].Name" class="form-control" maxlength="20" value="@row.Name" required />
                                     </div>
                                 </td>
                                 <td>
-                                    <input type="number" name="Items[@index].Order" class="form-control" min="-999" max="999" value="@row.Order" required />
+                                    <input type="number" name="UpdateItems[@index].Order" class="form-control" min="-999" max="999" value="@row.Order" required />
                                 </td>
                                 <td>@row.Posts</td>
                                 <td>
                                     <div class="form-check form-check-inline">
-                                        <input type="checkbox" name="Items[@index].IsActive" id="Items_@(index)_IsActive" class="form-check-input" checked="@row.IsActive" value="true" />
-                                        <label for="Items_@(index)_IsActive" class="form-check-label">사용</label>
+                                        <input type="checkbox" name="UpdateItems[@index].IsActive" id="UpdateItems_@(index)_IsActive" class="form-check-input" checked="@row.IsActive" value="true" />
+                                        <label for="UpdateItems_@(index)_IsActive" class="form-check-label">사용</label>
                                     </div>
                                 </td>
-                                <td>@row.CreatedAt.GetDateAt()</td>
-                                <td>@(row.UpdatedAt.GetDateAt() ?? "-")</td>
+                                <td>@row.CreatedAt</td>
+                                <td>@(row.UpdatedAt ?? "-")</td>
                             </tr>
                         }
                     }
@@ -131,18 +128,53 @@
 </div>
 
 @section Scripts {
-    <script>
-        // 저장
-        $(document).on("click", "#btnListSave", function() {
-            if (confirm("저장 하시겠습니까?")) {
-                let form = document.getElementById("fAdminList");
-                if (form.checkValidity()) { // HTML5 폼 검증 수행
-                    form.submit();
-                } else {
-                    form.reportValidity();
+<script>
+    // 삭제
+    $(document).on("click", "#btnFListDelete", function() {
+        if (confirm("삭제 하시겠습니까?")) {
+            let checked = document.querySelectorAll(".list-check-box:checked");
+            if (checked.length === 0) {
+                return false;
+            }
+
+            checked.forEach(function(el) {
+                let form = document.createElement("form");
+                form.method = "post";
+                form.action = "?handler=Delete";
+
+                let input = document.createElement("input");
+                input.type = "hidden";
+                input.name = "DeleteID";
+                input.value = el.value;
+
+                let token = document.querySelector('input[name="__RequestVerificationToken"]');
+                if (token) {
+                    let tokenInput = document.createElement("input");
+                    tokenInput.type = "hidden";
+                    tokenInput.name = "__RequestVerificationToken";
+                    tokenInput.value = token.value;
+                    form.appendChild(tokenInput);
                 }
+
+                form.appendChild(input);
+                document.body.appendChild(form);
+                form.submit();
+            });
+        }
+        return false;
+    });
+
+    // 저장
+    $(document).on("click", "#btnFListSave", function() {
+        if (confirm("저장 하시겠습니까?")) {
+            let form = document.getElementById("fAdminList");
+            if (form.checkValidity()) { // HTML5 폼 검증 수행
+                form.submit();
+            } else {
+                form.reportValidity();
             }
-            return false;
-        });
-    </script>
+        }
+        return false;
+    });
+</script>
 }

+ 179 - 0
Admin/Pages/Forum/Board/Prefix.cshtml.cs

@@ -0,0 +1,179 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel;
+
+namespace Admin.Pages.Forum.Board;
+
+public class PrefixModel(IMediator mediator) : PageModel
+{
+    public int BoardID { get; set; }
+    public List<(int ID, string Name)> BoardList { get; set; } = [];
+    public string? QueryString { get; set; }
+    public int Total { get; set; }
+
+    public List<(
+        int ID,
+        string Name,
+        string? Color,
+        short Order,
+        int Posts,
+        bool IsActive,
+        string? UpdatedAt,
+        string CreatedAt
+    )> Data { get; set; } = [];
+
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public sealed class InputModel
+    {
+        public string? Name { get; set; }
+        public string? Color { get; set; }
+        public short Order { get; set; }
+    }
+
+    [BindProperty]
+    public List<UpdateItemModel> UpdateItems { get; set; } = [];
+
+    public sealed class UpdateItemModel
+    {
+        [Required]
+        [DisplayName("ID")]
+        public int ID { get; set; }
+
+        [Required]
+        [MaxLength(50)]
+        [DisplayName("말머리")]
+        public string Name { get; set; } = default!;
+
+        [Required]
+        [DisplayName("색상")]
+        public string? Color { get; set; }
+
+        [Required]
+        [Range(-9999, 9999)]
+        [DisplayName("순서")]
+        public short Order { get; set; }
+
+        [DisplayName("사용 여부")]
+        public bool IsActive { get; set; }
+    }
+
+    [BindProperty]
+    public int DeleteID { get; set; }
+
+    public async Task OnGetAsync(int id, CancellationToken ct)
+    {
+        BoardID = id;
+        QueryString = Request.QueryString.ToString();
+
+        var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 100), ct);
+        BoardList = [..boards.List.Select(c => (c.ID, c.Name))];
+
+        var result = await mediator.Send(new GetBoardPrefixes.Query(id), ct);
+        Total = result.Total;
+        Data = [..result.List.Select(c => (
+            c.ID,
+            c.Name,
+            c.Color,
+            c.Order,
+            c.Posts,
+            c.IsActive,
+            c.UpdatedAt,
+            c.CreatedAt
+        ))];
+    }
+
+    public async Task<IActionResult> OnPostCreateAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            if (string.IsNullOrWhiteSpace(Input.Name))
+                throw new Exception("말머리 이름을 입력하세요.");
+
+            await mediator.Send(new SaveBoardPrefixes.Command(
+                id,
+                new SaveBoardPrefixes.Command.Create(
+                    Input.Name,
+                    Input.Color,
+                    Input.Order
+                ),
+                null,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "말머리가 추가되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Prefix/{id}{Request.QueryString}");
+    }
+
+    public async Task<IActionResult> OnPostSaveAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            foreach (var key in ModelState.Keys.Where(k => k.StartsWith("Input.")).ToList())
+            {
+                ModelState.Remove(key);
+            }
+
+            if (!ModelState.IsValid)
+            {
+                throw new Exception(ModelState.GetErrorMessages());
+            }
+
+            var updates = UpdateItems.Select(x => new SaveBoardPrefixes.Command.Update(
+                x.ID,
+                x.Name,
+                x.Color,
+                x.Order,
+                x.IsActive
+            )).ToList();
+
+            await mediator.Send(new SaveBoardPrefixes.Command(
+                id,
+                null,
+                updates,
+                null
+            ), ct);
+
+            TempData["SuccessMessage"] = "말머리 목록이 저장되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Prefix/{id}{Request.QueryString}");
+    }
+
+    public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
+    {
+        try
+        {
+            ModelState.Clear();
+
+            await mediator.Send(new SaveBoardPrefixes.Command(
+                id,
+                null,
+                null,
+                [DeleteID]
+            ), ct);
+
+            TempData["SuccessMessage"] = "말머리가 삭제되었습니다.";
+        }
+        catch (Exception e)
+        {
+            TempData["ErrorMessages"] = e.Message;
+        }
+
+        return Redirect($"/Forum/Board/Prefix/{id}{Request.QueryString}");
+    }
+}

+ 11 - 22
Admin/Pages/Forum/Board/_Header.cshtml

@@ -1,7 +1,6 @@
-@using Library.Models.Forum
 @{
-    var boardID = ViewData["BoardID"] as int?;
-    var boardList = ViewData["BoardList"] as List<Board>;
+    var boardID = ViewData["BoardID"];
+    var boardList = ViewData["BoardList"] as List<(int ID, string Name)>;
 }
 
 <div class="row">
@@ -9,36 +8,26 @@
         <h3>@ViewData["Title"]</h3>
     </div>
     <div class="col-auto">
-        <select id="boardID" class="form-select" required>
+        <select id="boardSelect" class="form-select">
             <option value="">게시판 선택</option>
             @if (boardList != null)
             {
-                @foreach (var row in boardList)
+                @foreach (var (id, name) in boardList)
                 {
-                    <option value="@row.ID" selected="@(row.ID == boardID)">@row.Name</option>
+                    <option value="@id" selected="@(id == (int?)boardID)">@name</option>
                 }
             }
         </select>
     </div>
 </div>
 <hr />
-
 <script type="module">
-    $(document).on("change", "#boardID", function () {
-        var boardID = $(this).val();
-        if (boardID) {
-            const url = new URL(window.location.href);
-
-            const pathSegments = url.pathname.split('/');
-
-            if (pathSegments.length >= 6) {
-                pathSegments[5] = boardID;
-            }
-
-            const newPath = pathSegments.join('/');
-            const newURL = `${newPath}${url.search}`;
-
-            window.location.href = newURL;
+    document.getElementById("boardSelect")?.addEventListener("change", function() {
+        if (this.value) {
+            const path = window.location.pathname;
+            const segments = path.split('/');
+            segments[segments.length - 1] = this.value;
+            window.location.href = segments.join('/') + window.location.search;
         }
     });
 </script>

+ 31 - 0
Admin/Pages/Forum/Board/_NavTabs.cshtml

@@ -0,0 +1,31 @@
+@{
+    var sector = ViewData["Sector"] as string ?? string.Empty;
+    var boardID = ViewData["BoardID"];
+    var queryString = (ViewData["QueryString"] as string ?? string.Empty).TrimStart('?');
+
+    var tabs = new List<(string Href, string Sector, string Name)>
+    {
+        ($"/Forum/Board/List/Edit/{boardID}?{queryString}", "Edit", "기본"),
+        ($"/Forum/Board/Meta/List/{boardID}?{queryString}", "List", "목록"),
+        ($"/Forum/Board/Meta/View/{boardID}?{queryString}", "View", "열람"),
+        ($"/Forum/Board/Meta/Write/{boardID}?{queryString}", "Write", "작성"),
+        ($"/Forum/Board/Prefix/{boardID}?{queryString}", "Prefix", "말머리"),
+        ($"/Forum/Board/Meta/Comment/{boardID}?{queryString}", "Comment", "댓글"),
+        ($"/Forum/Board/Meta/General/{boardID}?{queryString}", "General", "일반"),
+        ($"/Forum/Board/Meta/Notify/{boardID}?{queryString}", "Notify", "알림"),
+        ($"/Forum/Board/Meta/NotifyTemplate/{boardID}?{queryString}", "NotifyTemplate", "양식"),
+        ($"/Forum/Board/Meta/Permission/{boardID}?{queryString}", "Permission", "권한"),
+        ($"/Forum/Board/Meta/Exp/{boardID}?{queryString}", "Exp", "경험치"),
+        ($"/Forum/Board/Manager/{boardID}?{queryString}", "Manager", "관리자")
+    };
+}
+
+<ul class="nav nav-tabs">
+    @foreach (var (href, key, name) in tabs)
+    {
+        <li class="nav-item">
+            <a class="nav-link @(sector == key ? "active" : "")" href="@href">@name</a>
+        </li>
+    }
+</ul>
+<br />

+ 79 - 0
Admin/Pages/Forum/Comments/List/Edit.cshtml

@@ -0,0 +1,79 @@
+@page "{id:int}"
+@model Admin.Pages.Forum.Comments.List.EditModel
+@{
+    ViewData["Title"] = "댓글 상세";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered">
+            <colgroup>
+                <col style="width: 15%;" />
+                <col />
+            </colgroup>
+            <tr>
+                <th>ID</th>
+                <td>@Model.CommentID</td>
+            </tr>
+            <tr>
+                <th>게시판</th>
+                <td>@Model.BoardName</td>
+            </tr>
+            <tr>
+                <th>게시글</th>
+                <td><a href="/Forum/Posts/List/Edit/@Model.PostID">@Model.PostSubject</a></td>
+            </tr>
+            <tr>
+                <th>작성자</th>
+                <td>@(Model.Name ?? Model.SID ?? "-")</td>
+            </tr>
+            <tr>
+                <th>내용</th>
+                <td><pre class="mb-0" style="white-space:pre-wrap;">@Model.CommentContent</pre></td>
+            </tr>
+            <tr>
+                <th>상태</th>
+                <td>
+                    @if (Model.IsDeleted) { <span class="badge text-bg-danger me-1">삭제</span> }
+                    @if (Model.IsSecret) { <span class="badge text-bg-secondary me-1">비밀</span> }
+                    @if (Model.IsReply) { <span class="badge text-bg-info me-1">답글</span> }
+                </td>
+            </tr>
+            <tr>
+                <th>좋아요 / 싫어요</th>
+                <td>@Model.Likes / @Model.Dislikes</td>
+            </tr>
+            <tr>
+                <th>신고</th>
+                <td>@Model.Reports</td>
+            </tr>
+            <tr>
+                <th>답글 수</th>
+                <td>@Model.Replies</td>
+            </tr>
+            <tr>
+                <th>작성일</th>
+                <td>@Model.CreatedAt</td>
+            </tr>
+            <tr>
+                <th>수정일</th>
+                <td>@Model.UpdatedAt</td>
+            </tr>
+        </table>
+    </div>
+
+    <div class="row g-2 justify-content-center">
+        <div class="col-auto">
+            <form method="post" style="display:inline">
+                <input type="hidden" name="id" value="@Model.CommentID" />
+                <button type="submit" asp-page-handler="Delete" class="btn btn-danger" onclick="return confirm('이 댓글을 삭제하시겠습니까?')">삭제</button>
+            </form>
+            <a href="/Forum/Comments/List/Index@(Model.QueryString)" class="btn btn-secondary">목록</a>
+        </div>
+    </div>
+</div>

+ 68 - 0
Admin/Pages/Forum/Comments/List/Edit.cshtml.cs

@@ -0,0 +1,68 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using SharedKernel.Extensions;
+
+namespace Admin.Pages.Forum.Comments.List
+{
+    public class EditModel(IMediator mediator) : PageModel
+    {
+        public string? QueryString { get; set; }
+        public int CommentID { get; set; }
+        public int BoardID { get; set; }
+        public string BoardName { get; set; } = "";
+        public int PostID { get; set; }
+        public string PostSubject { get; set; } = "";
+        public new string CommentContent { get; set; } = "";
+        public string? Name { get; set; }
+        public string? SID { get; set; }
+        public bool IsReply { get; set; }
+        public bool IsSecret { get; set; }
+        public bool IsDeleted { get; set; }
+        public int Likes { get; set; }
+        public int Dislikes { get; set; }
+        public int Reports { get; set; }
+        public int Replies { get; set; }
+        public string? UpdatedAt { get; set; }
+        public string CreatedAt { get; set; } = "";
+
+        public async Task OnGetAsync(int id, CancellationToken ct)
+        {
+            var result = await mediator.Send(new GetComment.Query(id), ct);
+
+            CommentID = result.ID;
+            BoardID = result.BoardID;
+            BoardName = result.BoardName;
+            PostID = result.PostID;
+            PostSubject = result.PostSubject;
+            CommentContent = result.Content;
+            Name = result.Name;
+            SID = result.SID;
+            IsReply = result.IsReply;
+            IsSecret = result.IsSecret;
+            IsDeleted = result.IsDeleted;
+            Likes = result.Likes;
+            Dislikes = result.Dislikes;
+            Reports = result.Reports;
+            Replies = result.Replies;
+            UpdatedAt = result.UpdatedAt.GetDateAt() ?? "-";
+            CreatedAt = result.CreatedAt.GetDateAt();
+            QueryString = Request.QueryString.ToString();
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int id, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteComment.Command([id]), ct);
+                TempData["SuccessMessage"] = "댓글이 삭제되었습니다.";
+                return RedirectToPage("/Forum/Comments/List/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+                return Redirect($"/Forum/Comments/List/Edit/{id}{Request.QueryString}");
+            }
+        }
+    }
+}

+ 203 - 0
Admin/Pages/Forum/Comments/List/Index.cshtml

@@ -0,0 +1,203 @@
+@page
+@model Admin.Pages.Forum.Comments.List.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "댓글 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end mb-3">
+        <div class="col col-sm-auto">
+            <select id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col col-sm-auto">
+            <select id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+                <option value="0" selected="@(Model?.Query.Search == 0)">내용</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md">
+            <input type="text" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-sm-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+    </div>
+
+    <div class="row g-2 align-items-end">
+        <div class="col-auto">
+            <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" id="isDeleted" checked="@(Model?.Query.IsDeleted == true)" />
+                <label class="form-check-label" for="isDeleted">삭제 여부</label>
+            </div>
+        </div>
+    </div>
+
+    <div class="row justify-content-center mt-3">
+        <div class="col col-sm-auto">
+            <button type="submit" id="btnSearch" class="btn btn-primary w-100" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select id="sort" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="0" selected="@(Model?.Query.Sort == 0)">최신순</option>
+                <option value="1" selected="@(Model?.Query.Sort == 1)">조회순</option>
+                <option value="2" selected="@(Model?.Query.Sort == 2)">댓글순</option>
+                <option value="3" selected="@(Model?.Query.Sort == 3)">공감순</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Forum/Posts/List/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 15%;" />
+                <col style="width: 10%;" />
+                <col style="width: 8%;" />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>내용</th>
+                    <th>게시글</th>
+                    <th>게시판</th>
+                    <th>작성자</th>
+                    <th>좋아요</th>
+                    <th>싫어요</th>
+                    <th>신고</th>
+                    <th>작성일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">
+                                @if (row.IsDeleted) {
+                                    <span class="badge text-bg-danger me-1">삭제</span>
+                                }
+
+                                @if (row.IsSecret) {
+                                    <span class="badge text-bg-secondary me-1">비밀</span>
+                                }
+
+                                @if (row.IsReply) {
+                                    <span class="badge text-bg-info me-1">답글</span>
+                                }
+
+                                <a href="/Forum/Comments/List/Edit/@row.ID@(Request.QueryString)">
+                                    @(row.Content.Length > 80 ? row.Content[..80] + "..." : row.Content)
+                                </a>
+                            </td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 30 ? row.PostSubject[..30] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.Name ?? row.SID ?? "-")</td>
+                            <td>@row.Likes</td>
+                            <td>@row.Dislikes</td>
+                            <td>@row.Reports</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<!-- 검색을 위한 -->
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<!-- 삭제를 위한 -->
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="sort" value="@Model.Query.Sort" />
+    <input type="hidden" name="isDeleted" value="@Model.Query.IsDeleted" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+    </script>
+}

+ 129 - 0
Admin/Pages/Forum/Comments/List/Index.cshtml.cs

@@ -0,0 +1,129 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Comments.List
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public int? Sort { get; set; }
+            public bool? IsDeleted { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            string Content,
+            string? Name,
+            string? SID,
+            bool IsReply,
+            bool IsSecret,
+            bool IsDeleted,
+            int Likes,
+            int Dislikes,
+            int Reports,
+            int Replies,
+            string? UpdatedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchComments.Query(
+                Query.BoardID,
+                null,
+                Query.Search,
+                Query.Keyword,
+                Query.StartAt,
+                Query.EndAt,
+                Query.IsDeleted,
+                Query.PageNum,
+                Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.Content,
+                c.Name,
+                c.SID,
+                c.IsReply,
+                c.IsSecret,
+                c.IsDeleted,
+                c.Likes,
+                c.Dislikes,
+                c.Reports,
+                c.Replies,
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteComment.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Comments/List/Index", Query);
+        }
+    }
+}

+ 22 - 26
Admin/Pages/Forum/Posts/List/Edit.cshtml

@@ -1,4 +1,5 @@
-@model Admin.ViewModels.Forum.Posts.List.EditViewModel
+@page "{id:int}"
+@model Admin.Pages.Forum.Posts.List.EditModel
 @{
     ViewData["Title"] = "게시글 수정";
 }
@@ -9,16 +10,16 @@
 
     <partial name="_StatusMessage" />
 
-    <form method="post" action="/Forum/Post/Edit/@Model.ID" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
-        <input type="hidden" name="ID" value="@Model.ID" />
-        <input type="hidden" name="BoardID" value="@Model.BoardID" />
-        <input type="hidden" name="ReturnUrl" value="@(Model.ReturnUrl ?? "/Forum/Post/List")" />
+    <form method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+        <input type="hidden" asp-for="Input.ID" />
+        <input type="hidden" asp-for="Input.BoardID" />
+        <input type="hidden" asp-for="Input.ReturnUrl" />
 
         <!-- 제목 -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
             <div class="col-sm-10">
-                <input type="text" name="Subject" class="form-control" value="@Model.Subject" maxlength="255" required />
+                <input type="text" asp-for="Input.Subject" class="form-control" maxlength="255" required />
             </div>
         </div>
 
@@ -26,7 +27,7 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">내용</label>
             <div class="col-sm-10">
-                <textarea name="Content" class="form-control" rows="10" maxlength="8000">@Model.Content</textarea>
+                <textarea asp-for="Input.Content" class="form-control" rows="10" maxlength="8000"></textarea>
             </div>
         </div>
 
@@ -34,10 +35,10 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">대표 이미지</label>
             <div class="col-sm-10">
-                <div id="thumbPrev" @(string.IsNullOrWhiteSpace(Model.Thumbnail) ? "hidden" : "")>
-                    <img class="img-fluid img-thumbnail" alt="대표 이미지 미리보기" src="@(Model.Thumbnail ?? "")" />
+                <div id="thumbPrev" @(string.IsNullOrWhiteSpace(Model.CurrentThumbnail) ? "hidden" : "")>
+                    <img class="img-fluid img-thumbnail" alt="대표 이미지 미리보기" src="@(Model.CurrentThumbnail ?? "")" />
                 </div>
-                <input type="file" id="ThumbnailFile" name="ThumbnailFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp" />
+                <input type="file" id="Input_ThumbnailFile" asp-for="Input.ThumbnailFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp" />
                 <span class="form-text text-muted">
                     지원 확장자: <code>.jpg</code>, <code>.jpeg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>, <code>.bmp</code><br />
                     권장 크기: 가로 300px 이상 (최대 10MB)
@@ -45,27 +46,23 @@
             </div>
         </div>
 
-
         <!-- 상태 -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">상태</label>
             <div class="col-sm-10 align-content-center">
                 <div class="form-check form-check-inline">
-                    <input type="checkbox" class="form-check-input" name="IsNotice" value="true" @(Model.IsNotice ? "checked" : "") />
-                    <input type="hidden" name="IsNotice" value="false" />
-                    <label class="form-check-label">공지</label>
+                    <input type="checkbox" class="form-check-input" asp-for="Input.IsNotice" />
+                    <label class="form-check-label" for="Input_IsNotice">공지</label>
                 </div>
 
                 <div class="form-check form-check-inline">
-                    <input type="checkbox" class="form-check-input" name="IsSecret" value="true" @(Model.IsSecret ? "checked" : "") />
-                    <input type="hidden" name="IsSecret" value="false" />
-                    <label class="form-check-label">비밀</label>
+                    <input type="checkbox" class="form-check-input" asp-for="Input.IsSecret" />
+                    <label class="form-check-label" for="Input_IsSecret">비밀</label>
                 </div>
 
                 <div class="form-check form-check-inline">
-                    <input type="checkbox" class="form-check-input" name="IsAnonymous" value="true" @(Model.IsAnonymous ? "checked" : "") />
-                    <input type="hidden" name="IsAnonymous" value="false" />
-                    <label class="form-check-label">익명</label>
+                    <input type="checkbox" class="form-check-input" asp-for="Input.IsAnonymous" />
+                    <label class="form-check-label" for="Input_IsAnonymous">익명</label>
                 </div>
             </div>
         </div>
@@ -73,12 +70,11 @@
         <hr />
 
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a href="@(Model.ReturnUrl ?? "/Forum/Post/List")" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a href="@(Model.ReturnUrl ?? "/Forum/Posts/List")" class="btn btn-secondary">취소</a>
             <button type="submit"
-                    class="btn btn-sm btn-danger"
-                    formaction="/Forum/Post/Delete/@Model.ID"
-                    formmethod="post"
+                    class="btn btn-danger"
+                    asp-page-handler="Delete"
                     formnovalidate
                     onclick="return confirm('삭제 하시겠습니까? 삭제된 게시물은 복구가 불가능합니다.');">
                 삭제
@@ -91,6 +87,6 @@
 
 @section Scripts {
     <script>
-        setupImagePreview("ThumbnailFile", "thumbPrev");
+        setupImagePreview("Input_ThumbnailFile", "thumbPrev");
     </script>
 }

+ 123 - 0
Admin/Pages/Forum/Posts/List/Edit.cshtml.cs

@@ -0,0 +1,123 @@
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using SharedKernel.Extensions;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Posts.List
+{
+    public class EditModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public string? QueryString { get; set; }
+
+        public string? ReturnUrl { get; set; }
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public string? CurrentThumbnail { get; private set; }
+
+        public sealed class InputModel
+        {
+            [DisplayName("ID")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            public int ID { get; set; }
+
+            public int BoardID { get; set; }
+
+            [DisplayName("제목")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            [StringLength(255, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string Subject { get; set; } = null!;
+
+            [DisplayName("내용")]
+            [StringLength(8000, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string? Content { get; set; }
+
+            [DisplayName("대표 이미지")]
+            public IFormFile? ThumbnailFile { get; set; }
+
+            [DisplayName("공지")]
+            public bool IsNotice { get; set; } = false;
+
+            [DisplayName("비밀")]
+            public bool IsSecret { get; set; } = false;
+
+            [DisplayName("익명")]
+            public bool IsAnonymous { get; set; } = false;
+
+            public string? ReturnUrl { get; set; }
+        }
+
+        public async Task OnGetAsync(int id, CancellationToken ct)
+        {
+            var result = await mediator.Send(new GetPost.Query(id), ct);
+
+            CurrentThumbnail = result.Thumbnail;
+            ReturnUrl = Request.Headers.Referer.ToString();
+
+            Input = new InputModel
+            {
+                ID = result.ID,
+                BoardID = result.BoardID,
+                Subject = result.Subject,
+                Content = result.Content,
+                IsNotice = result.IsNotice,
+                IsSecret = result.IsSecret,
+                IsAnonymous = result.IsAnonymous,
+                ReturnUrl = ReturnUrl
+            };
+
+            QueryString = Request.QueryString.ToString();
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception(ModelState.GetErrorMessages());
+                }
+
+                await mediator.Send(new UpdatePost.Command(
+                    Input.ID,
+                    Input.Subject,
+                    Input.Content,
+                    Input.ThumbnailFile,
+                    Input.IsNotice,
+                    Input.IsSecret,
+                    Input.IsAnonymous
+                ), ct);
+
+                TempData["SuccessMessage"] = "게시글이 수정되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{Request.QueryString}");
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePost.Command([Input.ID]), ct);
+
+                TempData["SuccessMessage"] = "게시글이 삭제되었습니다.";
+
+                return RedirectToPage("/Forum/Posts/List/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{Request.QueryString}");
+            }
+        }
+    }
+}

+ 254 - 0
Admin/Pages/Forum/Posts/List/Index.cshtml

@@ -0,0 +1,254 @@
+@page
+@model Admin.Pages.Forum.Posts.List.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "게시물 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end mb-3">
+        <div class="col col-sm-auto">
+            <select id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col col-sm-auto">
+            <select id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="0" selected="@(Model?.Query.Search == 0)">제목</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">내용</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md">
+            <input type="text" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-sm-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+    </div>
+
+    <div class="row g-2 align-items-end">
+        <div class="col-auto">
+            <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" id="isNotice" checked="@(Model?.Query.IsNotice == true)" />
+                <label class="form-check-label" for="isNotice">공지</label>
+            </div>
+            <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" id="isSecret" checked="@(Model?.Query.IsSecret == true)" />
+                <label class="form-check-label" for="isSecret">비밀</label>
+            </div>
+            <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" id="isReply" checked="@(Model?.Query.IsReply == true)" />
+                <label class="form-check-label" for="isReply">답글</label>
+            </div>
+            <div class="form-check form-check-inline">
+                <input class="form-check-input" type="checkbox" id="isDeleted" checked="@(Model?.Query.IsDeleted == true)" />
+                <label class="form-check-label" for="isDeleted">삭제</label>
+            </div>
+        </div>
+    </div>
+
+    <div class="row justify-content-center mt-3">
+        <div class="col col-sm-auto">
+            <button type="submit" id="btnSearch" class="btn btn-primary w-100" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr/>
+
+    <div class="row g-2 align-items-end">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select id="sort" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="0" selected="@(Model?.Query.Sort == 0)">최신순</option>
+                <option value="1" selected="@(Model?.Query.Sort == 1)">조회순</option>
+                <option value="2" selected="@(Model?.Query.Sort == 2)">댓글순</option>
+                <option value="3" selected="@(Model?.Query.Sort == 3)">공감순</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" form="fAdminList" disabled>삭제</button>
+            <a class="btn btn-success" asp-page="/Forum/Posts/List/Write">추가</a>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 10%;" />
+                <col style="width: 6%;" />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
+                <col style="width: 5%;" />
+                <col style="width: 12%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>제목</th>
+                    <th>작성자</th>
+                    <th>조회</th>
+                    <th>공감</th>
+                    <th>비공감</th>
+                    <th>댓글</th>
+                    <th>첨부</th>
+                    <th>작성일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model?.Data == null || Model.Data.Count == 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    foreach (var item in Model.Data)
+                    {
+                        <tr class="@(item.IsNotice ? "table-warning" : "")">
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@item.ID" class="form-check-input list-check-box" value="@item.ID" form="fAdminList" />
+                                    <label for="ids_@item.ID">@item.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">
+                                @if (item.IsNotice) { <span class="badge text-bg-warning me-1">공지</span> }
+                                @if (item.IsSecret) { <span class="badge text-bg-secondary me-1">비밀</span> }
+                                @if (item.IsReply) { <span class="badge text-bg-info me-1">답글</span> }
+                                @if (item.IsSpeaker) { <span class="badge text-bg-primary me-1">작성자</span> }
+                                @if (item.IsAnonymous) { <span class="badge text-bg-dark me-1">익명</span> }
+                                @if (item.IsDeleted) { <span class="badge text-bg-danger me-1">삭제</span> }
+                                <a class="text-decoration-none" href="@item.EditURL">@item.Subject</a>
+                            </td>
+                            <td>@(item.Name ?? item.SID ?? "-")</td>
+                            <td class="text-end">@item.Views</td>
+                            <td class="text-end">@item.Likes</td>
+                            <td class="text-end">@item.Dislikes</td>
+                            <td class="text-end">@item.Comments</td>
+                            <td class="text-center">
+                                <span title="이미지">@item.Images</span> /
+                                <span title="미디어">@item.Medias</span> /
+                                <span title="파일">@item.Files</span> /
+                                <span title="태그">@item.Tags</span>
+                            </td>
+                            <td>@item.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model!.Pagination" />
+    </div>
+</div>
+
+<!-- 검색을 위한 -->
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<!-- 삭제를 위한 -->
+<form id="fAdminList" method="post" accept-charset="utf-8" asp-page-handler="Delete">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="sort" value="@Model.Query.Sort" />
+    <input type="hidden" name="isNotice" value="@Model.Query.IsNotice" />
+    <input type="hidden" name="isSecret" value="@Model.Query.IsSecret" />
+    <input type="hidden" name="isReply" value="@Model.Query.IsReply" />
+    <input type="hidden" name="isDeleted" value="@Model.Query.IsDeleted" />
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#sort, #perPage", function () {
+            searchForm.submit();
+        });
+
+        document.getElementById("btnSearch")?.addEventListener("click", function (e) {
+            e.preventDefault();
+            const qp = new URLSearchParams();
+
+            const boardID = document.getElementById("boardID").value;
+            if (boardID) {
+                qp.set("BoardID", boardID);
+            }
+
+            const searchVal = document.getElementById("search").value;
+            if (searchVal !== "") {
+                qp.set("Search", searchVal);
+            }
+
+            const keyword = document.getElementById("keyword").value;
+            if (keyword) {
+                qp.set("Keyword", keyword);
+            }
+
+            const startAt = document.getElementById("startAt").value;
+            if (startAt) {
+                qp.set("StartAt", startAt);
+            }
+
+            const endAt = document.getElementById("endAt").value;
+            if (endAt) {
+                qp.set("EndAt", endAt);
+            }
+
+            const sortVal = document.getElementById("sort").value;
+            if (sortVal !== "0") {
+                qp.set("Sort", sortVal);
+            }
+
+            if (document.getElementById("isNotice").checked) qp.set("IsNotice", "true");
+            if (document.getElementById("isSecret").checked) qp.set("IsSecret", "true");
+            if (document.getElementById("isReply").checked) qp.set("IsReply", "true");
+            if (document.getElementById("isDeleted").checked) qp.set("IsDeleted", "true");
+
+            qp.set("PerPage", document.getElementById("perPage").value || "20");
+            qp.set("PageNum", "1");
+
+            window.location.href = window.location.pathname + "?" + qp.toString();
+        });
+    </script>
+}

+ 154 - 0
Admin/Pages/Forum/Posts/List/Index.cshtml.cs

@@ -0,0 +1,154 @@
+using SharedKernel.Extensions;
+using SharedKernel.Helpers;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Posts.List
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public int? Sort { get; set; }
+            public bool? IsNotice { get; set; }
+            public bool? IsSecret { get; set; }
+            public bool? IsReply { get; set; }
+            public bool? IsDeleted { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; }
+
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int No,
+            int ID,
+            int BoardID,
+            string BoardName,
+            string Subject,
+            string? Thumbnail,
+            string? Name,
+            string? SID,
+            bool IsNotice,
+            bool IsSecret,
+            bool IsReply,
+            bool IsSpeaker,
+            bool IsAnonymous,
+            bool IsActive,
+            bool IsDeleted,
+            int Views,
+            int Likes,
+            int Dislikes,
+            int Comments,
+            byte Images,
+            byte Medias,
+            byte Files,
+            byte Tags,
+            string? UpdatedAt,
+            string CreatedAt,
+            string EditURL
+        )> Data { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            // 게시판 목록 조회
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchPosts.Query(
+                Query.BoardID,
+                Query.Search,
+                Query.Keyword,
+                Query.StartAt,
+                Query.EndAt,
+                Query.Sort,
+                Query.IsNotice,
+                Query.IsSecret,
+                Query.IsReply,
+                Query.IsDeleted,
+                Query.PageNum,
+                Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            Data = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.Subject,
+                c.Thumbnail,
+                c.Name,
+                c.SID,
+                c.IsNotice,
+                c.IsSecret,
+                c.IsReply,
+                c.IsSpeaker,
+                c.IsAnonymous,
+                IsActive: !c.IsDeleted,
+                c.IsDeleted,
+                c.Views,
+                c.Likes,
+                c.Dislikes,
+                c.Comments,
+                c.Images,
+                c.Medias,
+                c.Files,
+                c.Tags,
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt(),
+                EditURL: $"/Forum/Posts/List/Edit/{c.ID}{Request.QueryString}"
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePost.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Posts/List/Index", Query);
+        }
+    }
+}

+ 24 - 56
Admin/Pages/Forum/Posts/List/Write.cshtml

@@ -1,4 +1,5 @@
-@model Admin.ViewModels.Forum.Posts.List.WriteViewModel
+@page
+@model Admin.Pages.Forum.Posts.List.WriteModel
 @using Microsoft.AspNetCore.Mvc.Rendering
 @{
     ViewData["Title"] = "게시글 등록";
@@ -10,10 +11,7 @@
 
     <partial name="_StatusMessage" />
 
-    <form id="fPostWrite" method="post" accept-charset="utf-8" autocomplete="off"
-          action="/Forum/Post/Create" enctype="multipart/form-data">
-        @Html.AntiForgeryToken()
-        <input type="hidden" name="ReturnUrl" value="@(Model.ReturnUrl ?? "/Forum/Post/List")" />
+    <form id="fPostWrite" method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
 
         <!-- 게시판 -->
         <div class="row mb-2">
@@ -21,16 +19,13 @@
             <div class="col-sm-10">
                 @if ((Model.BoardList?.Count ?? 0) > 0)
                 {
-                    <select name="BoardID" class="form-select w-auto" required
-                            asp-items="@(new SelectList(Model.BoardList!, "Value", "Text",
-                                                            Model.BoardID == 0 ? null : Model.BoardID.ToString()))">
-                    <option value="">- 선택 -</option>
-                </select>
-                 }
+                    <select asp-for="Input.BoardID" class="form-select w-auto" required asp-items="@Model.BoardList">
+                        <option value="">- 선택 -</option>
+                    </select>
+                }
                 else
                 {
-                    <input type="number" name="BoardID" class="form-control w-auto"
-                           value="@(Model.BoardID == 0 ? "" : Model.BoardID.ToString())" required />
+                    <input type="number" asp-for="Input.BoardID" class="form-control w-auto" required />
                 }
             </div>
         </div>
@@ -39,8 +34,8 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label"><span class="text-danger">*</span> 제목</label>
             <div class="col-sm-10">
-                <input type="text" name="Subject" class="form-control"
-                       required maxlength="255" placeholder="제목을 입력하세요 (최대 255자)" value="@Model.Subject" />
+                <input type="text" asp-for="Input.Subject" class="form-control"
+                       required maxlength="255" placeholder="제목을 입력하세요 (최대 255자)" />
             </div>
         </div>
 
@@ -48,8 +43,8 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">내용</label>
             <div class="col-sm-10">
-                <textarea name="Content" class="form-control" rows="12" maxlength="8000"
-                          placeholder="내용을 입력하세요 (최대 8000자)">@Model.Content</textarea>
+                <textarea asp-for="Input.Content" class="form-control" rows="12" maxlength="8000"
+                          placeholder="내용을 입력하세요 (최대 8000자)"></textarea>
             </div>
         </div>
 
@@ -57,31 +52,7 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">대표 이미지(썸네일)</label>
             <div class="col-sm-10">
-                <input type="file" name="ThumbnailFile" class="form-control" placeholder="" accept="image/*" />
-            </div>
-        </div>
-
-        <!-- 이미지 여러 장 -->
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">이미지</label>
-            <div class="col-sm-10">
-                <input type="file" name="ImageFiles" class="form-control" placeholder="" accept="image/*" multiple />
-            </div>
-        </div>
-
-        <!-- 미디어 -->
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">미디어</label>
-            <div class="col-sm-10">
-                <input type="file" name="MediaFiles" class="form-control" placeholder="" accept="video/*,audio/*" multiple />
-            </div>
-        </div>
-
-        <!-- 첨부 파일 -->
-        <div class="row mb-2">
-            <label class="col-sm-2 col-form-label">첨부 파일</label>
-            <div class="col-sm-10">
-                <input type="file" name="AttachFiles" class="form-control" placeholder=""  multiple />
+                <input type="file" asp-for="Input.ThumbnailFile" class="form-control" accept="image/*" />
             </div>
         </div>
 
@@ -89,30 +60,27 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">상태</label>
             <div class="col-sm-10 align-content-center">
-                <input type="hidden" name="IsNotice" value="false" />
                 <div class="form-check form-check-inline">
-                    <input class="form-check-input" type="checkbox" name="IsNotice" value="true" @(Model.IsNotice ? "checked" : "") />
-                    <label class="form-check-label">공지</label>
+                    <input class="form-check-input" type="checkbox" asp-for="Input.IsNotice" />
+                    <label class="form-check-label" for="Input_IsNotice">공지</label>
                 </div>
 
-                <input type="hidden" name="IsSecret" value="false" />
                 <div class="form-check form-check-inline">
-                    <input class="form-check-input" type="checkbox" name="IsSecret" value="true" @(Model.IsSecret ? "checked" : "") />
-                    <label class="form-check-label">비밀</label>
+                    <input class="form-check-input" type="checkbox" asp-for="Input.IsSecret" />
+                    <label class="form-check-label" for="Input_IsSecret">비밀</label>
                 </div>
 
-                <input type="hidden" name="IsAnonymous" value="false" />
                 <div class="form-check form-check-inline">
-                    <input class="form-check-input" type="checkbox" name="IsAnonymous" value="true" @(Model.IsAnonymous ? "checked" : "") />
-                    <label class="form-check-label">익명</label>
+                    <input class="form-check-input" type="checkbox" asp-for="Input.IsAnonymous" />
+                    <label class="form-check-label" for="Input_IsAnonymous">익명</label>
                 </div>
             </div>
         </div>
 
         <hr />
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a class="btn btn-sm btn-secondary btn-cancel" href="@(Model.ReturnUrl ?? "/Forum/Post/List")">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a class="btn btn-secondary btn-cancel" href="/Forum/Posts/List">취소</a>
         </div>
         <br />
     </form>
@@ -122,8 +90,8 @@
     <script>
         $(function () {
             $(".btn-cancel").on("click", function (e) {
-                const s = $("input[name='Subject']").val()?.trim();
-                const c = $("textarea[name='Content']").val()?.trim();
+                const s = $("input[name='Input.Subject']").val()?.trim();
+                const c = $("textarea[name='Input.Content']").val()?.trim();
                 if (s || c) {
                     e.preventDefault();
                     if (confirm("내용이나 제목이 남아 있습니다. 글 작성을 취소하시겠습니까?")) {
@@ -133,4 +101,4 @@
             });
         });
     </script>
-}
+}

+ 92 - 0
Admin/Pages/Forum/Posts/List/Write.cshtml.cs

@@ -0,0 +1,92 @@
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Posts.List
+{
+    public class WriteModel(IMediator mediator) : PageModel
+    {
+        public string? ReturnUrl { get; set; }
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public sealed class InputModel
+        {
+            [DisplayName("게시판")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            public int BoardID { get; set; }
+
+            [DisplayName("제목")]
+            [Required(ErrorMessage = "{0}은 필수입니다.")]
+            [StringLength(255, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string Subject { get; set; } = null!;
+
+            [DisplayName("내용")]
+            [StringLength(8000, ErrorMessage = "{0}은 {1}자 이내로 입력하세요.")]
+            public string? Content { get; set; }
+
+            [DisplayName("대표 이미지")]
+            public IFormFile? ThumbnailFile { get; set; }
+
+            [DisplayName("공지")]
+            public bool IsNotice { get; set; } = false;
+
+            [DisplayName("비밀")]
+            public bool IsSecret { get; set; } = false;
+
+            [DisplayName("익명")]
+            public bool IsAnonymous { get; set; } = false;
+
+            public string? ReturnUrl { get; set; }
+        }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            ReturnUrl = Request.Headers.Referer.ToString();
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken ct)
+        {
+            try
+            {
+                if (!ModelState.IsValid)
+                {
+                    throw new Exception(ModelState.GetErrorMessages());
+                }
+
+                await mediator.Send(new CreatePost.Command(
+                    Input.BoardID,
+                    Input.Subject,
+                    Input.Content,
+                    Input.ThumbnailFile,
+                    Input.IsNotice,
+                    Input.IsSecret,
+                    Input.IsAnonymous
+                ), ct);
+
+                TempData["SuccessMessage"] = "게시글이 등록되었습니다.";
+
+                return RedirectToPage("/Forum/Posts/List/Index");
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+
+                return RedirectToPage("/Forum/Posts/List/Write");
+            }
+        }
+    }
+}

+ 173 - 0
Admin/Pages/Forum/Reactions/Comment/Index.cshtml

@@ -0,0 +1,173 @@
+@page
+@model Admin.Pages.Forum.Reactions.Comment.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@using Domain.Entities.Forum.ValueObject
+@{
+    ViewData["Title"] = "댓글 반응 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-sm col-lg-auto">
+            <select name="reaction" id="reaction" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Reaction == 1)">좋아요</option>
+                <option value="2" selected="@(Model?.Query.Reaction == 2)">싫어요</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 10%;" />
+                <col style="width: 5%;" />
+                <col style="width: 10%;" />
+                <col style="width: 8%;" />
+                <col style="width: 10%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>게시글</th>
+                    <th>게시판</th>
+                    <th>댓글ID</th>
+                    <th>회원</th>
+                    <th>반응</th>
+                    <th>IP</th>
+                    <th>등록일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 40 ? row.PostSubject[..40] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.BoardName</td>
+                            <td>@row.CommentID</td>
+                            <td>@(row.MemberName ?? $"ID:{row.MemberID}")</td>
+                            <td>
+                                @if (row.Reaction == Reaction.Like) {
+                                    <span class="badge text-bg-primary">좋아요</span>
+                                } else {
+                                    <span class="badge text-bg-danger">싫어요</span>
+                                }
+                            </td>
+                            <td>@row.IpAddress</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="reaction" value="@Model.Query.Reaction" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+         $(document).on("change", "#perPage", function () {
+             searchForm.elements["pageNum"].value = "1";
+             searchForm.submit();
+         });
+    </script>
+}

+ 112 - 0
Admin/Pages/Forum/Reactions/Comment/Index.cshtml.cs

@@ -0,0 +1,112 @@
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Reactions.Comment
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? CommentID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public byte? Reaction { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            int CommentID,
+            int MemberID,
+            string? MemberName,
+            Reaction Reaction,
+            string? IpAddress,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchCommentReactions.Query(
+                Query.BoardID, Query.PostID, Query.CommentID, Query.Reaction,
+                Query.StartAt, Query.EndAt, Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.CommentID,
+                c.MemberID,
+                c.MemberName ?? "-",
+                c.Reaction,
+                c.IpAddress ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteCommentReaction.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Reactions/Comment/Index", Query);
+        }
+    }
+}

+ 170 - 0
Admin/Pages/Forum/Reactions/Post/Index.cshtml

@@ -0,0 +1,170 @@
+@page
+@model Admin.Pages.Forum.Reactions.Post.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@using Domain.Entities.Forum.ValueObject
+@{
+    ViewData["Title"] = "게시글 반응 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-sm col-lg-auto">
+            <select name="reaction" id="reaction" class="form-select w-auto d-inline-block" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Reaction == 1)">좋아요</option>
+                <option value="2" selected="@(Model?.Query.Reaction == 2)">싫어요</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 10%;" />
+                <col style="width: 10%;" />
+                <col style="width: 8%;" />
+                <col style="width: 10%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>게시글</th>
+                    <th>게시판</th>
+                    <th>회원</th>
+                    <th>반응</th>
+                    <th>IP</th>
+                    <th>등록일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="7">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 40 ? row.PostSubject[..40] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.MemberName ?? $"ID:{row.MemberID}")</td>
+                            <td>
+                                @if (row.Reaction == Reaction.Like) {
+                                    <span class="badge text-bg-primary">좋아요</span>
+                                } else {
+                                    <span class="badge text-bg-danger">싫어요</span>
+                                }
+                            </td>
+                            <td>@row.IpAddress</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="reaction" value="@Model.Query.Reaction" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+         $(document).on("change", "#perPage", function () {
+             searchForm.elements["pageNum"].value = "1";
+             searchForm.submit();
+         });
+    </script>
+}

+ 109 - 0
Admin/Pages/Forum/Reactions/Post/Index.cshtml.cs

@@ -0,0 +1,109 @@
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Reactions.Post
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public byte? Reaction { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            int MemberID,
+            string? MemberName,
+            Reaction Reaction,
+            string? IpAddress,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchPostReactions.Query(
+                Query.BoardID, Query.PostID, Query.Reaction,
+                Query.StartAt, Query.EndAt, Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.MemberID,
+                c.MemberName ?? "-",
+                c.Reaction,
+                c.IpAddress ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePostReaction.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Reactions/Post/Index", Query);
+        }
+    }
+}

+ 242 - 0
Admin/Pages/Forum/Reports/Comment/Index.cshtml

@@ -0,0 +1,242 @@
+@page
+@model Admin.Pages.Forum.Reports.Comment.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@using Domain.Entities.Forum.ValueObject
+@{
+    ViewData["Title"] = "댓글 신고 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-6 col-sm col-lg-auto">
+            <select name="status" id="status" class="form-select" form="fAdminSearch">
+                <option value="">- 상태 -</option>
+                <option value="0" selected="@(Model?.Query.Status == 0)">접수</option>
+                <option value="1" selected="@(Model?.Query.Status == 1)">처리중</option>
+                <option value="2" selected="@(Model?.Query.Status == 2)">완료</option>
+            </select>
+        </div>
+        <div class="col-6 col-sm col-lg-auto">
+            <select name="type" id="type" class="form-select" form="fAdminSearch">
+                <option value="">- 유형 -</option>
+                <option value="1" selected="@(Model?.Query.Type == 1)">욕설</option>
+                <option value="2" selected="@(Model?.Query.Type == 2)">음란</option>
+                <option value="3" selected="@(Model?.Query.Type == 3)">불법</option>
+                <option value="4" selected="@(Model?.Query.Type == 4)">사칭</option>
+                <option value="5" selected="@(Model?.Query.Type == 5)">현금거래유도</option>
+                <option value="6" selected="@(Model?.Query.Type == 6)">스팸/광고</option>
+                <option value="7" selected="@(Model?.Query.Type == 7)">도배</option>
+                <option value="8" selected="@(Model?.Query.Type == 8)">개인정보노출</option>
+                <option value="9" selected="@(Model?.Query.Type == 9)">기타</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />   <!-- ID -->
+                <col style="width: 8%;" />   <!-- 게시판 -->
+                <col style="width: 8%;" />   <!-- 유형 -->
+                <col style="width: 7%;" />   <!-- 상태 -->
+                <col />                      <!-- 댓글 내용 -->
+                <col style="width: 7%;" />   <!-- 댓글ID -->
+                <col style="width: 10%;" />  <!-- 신고자 -->
+                <col style="width: 10%;" />  <!-- 사유 -->
+                <col style="width: 10%;" />  <!-- 등록일 -->
+            </colgroup>
+            <thead>
+                <tr>
+                    <th rowspan="2">
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th rowspan="2">게시판</th>
+                    <th colspan="3">댓글</th>
+                    <th rowspan="2">게시글 ID</th>
+                    <th rowspan="2">신고자</th>
+                    <th rowspan="2">사유</th>
+                    <th rowspan="2">등록일</th>
+                </tr>
+                <tr>
+                    <th>유형</th>
+                    <th>상태</th>
+                    <th>메모</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="9">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    var typeLabels = new Dictionary<ReportType, string> {
+                        { ReportType.None, "-" },
+                        { ReportType.Abuse, "욕설" },
+                        { ReportType.Obscene, "음란" },
+                        { ReportType.Illegal, "불법" },
+                        { ReportType.Impersonation, "사칭" },
+                        { ReportType.CashTrade, "현금거래" },
+                        { ReportType.SpamAd, "스팸/광고" },
+                        { ReportType.Flood, "도배" },
+                        { ReportType.PersonalLeak, "개인정보" },
+                        { ReportType.Other, "기타" }
+                    };
+
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td rowspan="2">
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td rowspan="2">@row.BoardName</td>
+                            <td colspan="3" class="text-start">
+                                [@row.CommentID] @row.Comment
+                            </td>
+                            <td rowspan="2">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @row.PostID
+                                </a>
+                            </td>
+                            <td rowspan="2">@(row.MemberName ?? $"ID:{row.MemberID}")</td>
+                            <td rowspan="2">@row.Reason</td>
+                            <td rowspan="2">@row.CreatedAt</td>
+                        </tr>
+                        <tr>
+                            <td><span class="badge text-bg-warning">@(typeLabels.GetValueOrDefault(row.Type, "-"))</span></td>
+                            <td>
+                                @if (row.Status == ReportStatus.Received)
+                                {
+                                    <span class="badge text-bg-danger">접수</span>
+                                }
+                                else if (row.Status == ReportStatus.Processing)
+                                {
+                                    <span class="badge text-bg-warning">처리중</span>
+                                }
+                                else if (row.Status == ReportStatus.Completed)
+                                {
+                                    <span class="badge text-bg-success">완료</span>
+                                }
+                            </td>
+                            <td>@row.Memo</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+
+    <div class="row g-2 align-items-center">
+        <div class="col-auto">
+            <select name="status" class="form-select w-auto d-inline-block" form="fAdminList">
+                <option value="0">접수</option>
+                <option value="1">처리중</option>
+                <option value="2">완료</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <input type="text" name="memo" class="form-control" style="width:250px" placeholder="메모 (선택)" form="fAdminList" />
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnUpdateStatus" class="btn btn-primary">상태 변경</button>
+        </div>
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="status" value="@Model.Query.Status" />
+    <input type="hidden" name="type" value="@Model.Query.Type" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+
+        document.getElementById("btnUpdateStatus")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=UpdateStatus";
+            form.submit();
+        });
+    </script>
+}

+ 144 - 0
Admin/Pages/Forum/Reports/Comment/Index.cshtml.cs

@@ -0,0 +1,144 @@
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Reports.Comment
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? CommentID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public byte? Status { get; set; }
+            public byte? Type { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            int CommentID,
+            string Comment,
+            int MemberID,
+            string? MemberName,
+            ReportType Type,
+            string? Reason,
+            ReportStatus Status,
+            string? Memo,
+            string? UpdatedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchCommentReports.Query(
+                Query.BoardID,
+                Query.PostID,
+                Query.CommentID,
+                Query.Status,
+                Query.Type,
+                Query.StartAt,
+                Query.EndAt,
+                Query.PageNum,
+                Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.CommentID,
+                c.Comment,
+                c.MemberID,
+                c.MemberName ?? "-",
+                c.Type,
+                c.Reason ?? "-",
+                c.Status,
+                c.Memo ?? "-",
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostUpdateStatusAsync(int[] ids, byte status, string? memo, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new UpdateCommentReportStatus.Command(ids, (ReportStatus)status, memo), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건의 상태가 변경되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Reports/Comment/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeleteCommentReport.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Reports/Comment/Index", Query);
+        }
+    }
+}

+ 237 - 0
Admin/Pages/Forum/Reports/Post/Index.cshtml

@@ -0,0 +1,237 @@
+@page
+@model Admin.Pages.Forum.Reports.Post.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@using Domain.Entities.Forum.ValueObject
+@{
+    ViewData["Title"] = "게시글 신고 관리";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-6 col-sm col-lg-auto">
+            <select name="status" id="status" class="form-select" form="fAdminSearch">
+                <option value="">- 상태 -</option>
+                <option value="0" selected="@(Model?.Query.Status == 0)">접수</option>
+                <option value="1" selected="@(Model?.Query.Status == 1)">처리중</option>
+                <option value="2" selected="@(Model?.Query.Status == 2)">완료</option>
+            </select>
+        </div>
+        <div class="col-6 col-sm col-lg-auto">
+            <select name="type" id="type" class="form-select" form="fAdminSearch">
+                <option value="">- 유형 -</option>
+                <option value="1" selected="@(Model?.Query.Type == 1)">욕설</option>
+                <option value="2" selected="@(Model?.Query.Type == 2)">음란</option>
+                <option value="3" selected="@(Model?.Query.Type == 3)">불법</option>
+                <option value="4" selected="@(Model?.Query.Type == 4)">사칭</option>
+                <option value="5" selected="@(Model?.Query.Type == 5)">현금거래유도</option>
+                <option value="6" selected="@(Model?.Query.Type == 6)">스팸/광고</option>
+                <option value="7" selected="@(Model?.Query.Type == 7)">도배</option>
+                <option value="8" selected="@(Model?.Query.Type == 8)">개인정보노출</option>
+                <option value="9" selected="@(Model?.Query.Type == 9)">기타</option>
+            </select>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />   <!-- ID -->
+                <col style="width: 8%;" />   <!-- 게시판 -->
+                <col style="width: 8%;" />   <!-- 유형 -->
+                <col style="width: 7%;" />   <!-- 상태 -->
+                <col />                      <!-- 게시글 제목 -->
+                <col style="width: 10%;" />  <!-- 신고자 -->
+                <col style="width: 10%;" />  <!-- 사유 -->
+                <col style="width: 10%;" />  <!-- 등록일 -->
+            </colgroup>
+            <thead>
+                <tr>
+                    <th rowspan="2">
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th rowspan="2">게시판</th>
+                    <th colspan="3">게시글</th>
+                    <th rowspan="2">신고자</th>
+                    <th rowspan="2">사유</th>
+                    <th rowspan="2">등록일</th>
+                </tr>
+                <tr>
+                    <th>유형</th>
+                    <th>상태</th>
+                    <th>메모</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    var typeLabels = new Dictionary<ReportType, string> {
+                        { ReportType.None, "-" },
+                        { ReportType.Abuse, "욕설" },
+                        { ReportType.Obscene, "음란" },
+                        { ReportType.Illegal, "불법" },
+                        { ReportType.Impersonation, "사칭" },
+                        { ReportType.CashTrade, "현금거래" },
+                        { ReportType.SpamAd, "스팸/광고" },
+                        { ReportType.Flood, "도배" },
+                        { ReportType.PersonalLeak, "개인정보" },
+                        { ReportType.Other, "기타" }
+                    };
+
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td rowspan="2">
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td rowspan="2">@row.BoardName</td>
+                            <td colspan="3" class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 30 ? row.PostSubject[..30] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td rowspan="2">@(row.MemberName ?? $"ID:{row.MemberID}")</td>
+                            <td rowspan="2">@row.Reason</td>
+                            <td rowspan="2">@row.CreatedAt</td>
+                        </tr>
+                        <tr>
+                            <td><span class="badge text-bg-warning">@(typeLabels.GetValueOrDefault(row.Type, "-"))</span></td>
+                            <td>
+                                @if (row.Status == ReportStatus.Received)
+                                {
+                                    <span class="badge text-bg-danger">접수</span>
+                                }
+                                else if (row.Status == ReportStatus.Processing)
+                                {
+                                    <span class="badge text-bg-warning">처리중</span>
+                                }
+                                else if (row.Status == ReportStatus.Completed)
+                                {
+                                    <span class="badge text-bg-success">완료</span>
+                                }
+                            </td>
+                            <td>@row.Memo</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+
+    <div class="row g-2 align-items-center">
+        <div class="col-auto">
+            <select name="status" class="form-select w-auto d-inline-block" form="fAdminList">
+                <option value="0">접수</option>
+                <option value="1">처리중</option>
+                <option value="2">완료</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <input type="text" name="memo" class="form-control" style="width:250px" placeholder="메모 (선택)" form="fAdminList" />
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnUpdateStatus" class="btn btn-primary">상태 변경</button>
+        </div>
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+    <input type="hidden" name="status" value="@Model.Query.Status" />
+    <input type="hidden" name="type" value="@Model.Query.Type" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+
+        document.getElementById("btnUpdateStatus")?.addEventListener("click", function () {
+            let form = document.getElementById("fAdminList");
+            form.action = "?handler=UpdateStatus";
+            form.submit();
+        });
+    </script>
+}

+ 132 - 0
Admin/Pages/Forum/Reports/Post/Index.cshtml.cs

@@ -0,0 +1,132 @@
+using Domain.Entities.Forum.ValueObject;
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Reports.Post
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? PostID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+            public byte? Status { get; set; }
+            public byte? Type { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            int MemberID,
+            string? MemberName,
+            ReportType Type,
+            string? Reason,
+            ReportStatus Status,
+            string? Memo,
+            string? UpdatedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchPostReports.Query(
+                Query.BoardID, Query.PostID, Query.Status, Query.Type,
+                Query.StartAt, Query.EndAt, Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num,
+                c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.MemberID,
+                c.MemberName ?? "-",
+                c.Type,
+                c.Reason ?? "-",
+                c.Status,
+                c.Memo ?? "-",
+                c.UpdatedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostUpdateStatusAsync(int[] ids, byte status, string? memo, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new UpdatePostReportStatus.Command(ids, (ReportStatus)status, memo), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건의 상태가 변경되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Reports/Post/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePostReport.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Reports/Post/Index", Query);
+        }
+    }
+}

+ 159 - 0
Admin/Pages/Forum/Trash/Comment/Index.cshtml

@@ -0,0 +1,159 @@
+@page
+@model Admin.Pages.Forum.Trash.Comment.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "휴지통 - 댓글";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">게시글 ID</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">게시글 제목</option>
+                <option value="3" selected="@(Model?.Query.Search == 3)">내용</option>
+                <option value="4" selected="@(Model?.Query.Search == 4)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr />
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListRestore" class="btn btn-success" disabled>복원</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 15%;" />
+                <col style="width: 10%;" />
+                <col style="width: 8%;" />
+                <col style="width: 12%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>내용</th>
+                    <th>게시글</th>
+                    <th>게시판</th>
+                    <th>작성자</th>
+                    <th>삭제일</th>
+                    <th>작성일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="7">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">
+                                @(row.Content.Length > 80 ? row.Content[..80] + "..." : row.Content)
+                            </td>
+                            <td class="text-start">
+                                <a href="/Forum/Posts/List/Edit/@row.PostID">
+                                    @(row.PostSubject.Length > 30 ? row.PostSubject[..30] + "..." : row.PostSubject)
+                                </a>
+                            </td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.Name ?? row.SID ?? "-")</td>
+                            <td>@row.DeletedAt</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+    </script>
+}

+ 122 - 0
Admin/Pages/Forum/Trash/Comment/Index.cshtml.cs

@@ -0,0 +1,122 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Trash.Comment
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            int PostID,
+            string PostSubject,
+            string Content,
+            string? Name,
+            string? SID,
+            string? DeletedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchTrashComments.Query(
+                Query.BoardID, Query.Keyword,
+                Query.StartAt, Query.EndAt,
+                Query.PageNum, Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num, c.ID,
+                c.BoardID,
+                c.BoardName,
+                c.PostID,
+                c.PostSubject,
+                c.Content,
+                c.Name,
+                c.SID,
+                c.DeletedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostRestoreAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new RestoreTrashComment.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 복원되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Trash/Comment/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new PermanentDeleteTrashComment.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 영구 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Trash/Comment/Index", Query);
+        }
+    }
+}

+ 155 - 0
Admin/Pages/Forum/Trash/Post/Index.cshtml

@@ -0,0 +1,155 @@
+@page
+@model Admin.Pages.Forum.Trash.Post.IndexModel
+@using Microsoft.AspNetCore.Mvc.Rendering
+@{
+    ViewData["Title"] = "휴지통 - 게시글";
+}
+
+<div class="container-fluid">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <partial name="_StatusMessage" />
+
+    <div class="row g-2 align-items-end">
+        <div class="col-6 col-sm-auto">
+            <select name="boardID" id="boardID" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                @foreach (var g in (Model?.BoardList ?? Enumerable.Empty<SelectListItem>()))
+                {
+                    <option value="@g.Value" selected="@((Model?.Query?.BoardID?.ToString() ?? "") == (g.Value ?? ""))">@g.Text</option>
+                }
+            </select>
+        </div>
+        <div class="col-6 col-sm-auto">
+            <select name="search" id="search" class="form-select" form="fAdminSearch">
+                <option value="">- 전체 -</option>
+                <option value="0" selected="@(Model?.Query.Search == 0)">제목</option>
+                <option value="1" selected="@(Model?.Query.Search == 1)">내용</option>
+                <option value="2" selected="@(Model?.Query.Search == 2)">작성자</option>
+            </select>
+        </div>
+        <div class="col-12 col-sm col-md col-lg-auto">
+            <input type="text" name="keyword" id="keyword" class="form-control" value="@(Model?.Query.Keyword ?? "")" placeholder="검색어" form="fAdminSearch" />
+        </div>
+        <div class="col-12 col-md-12 col-lg-auto">
+            <div class="input-group">
+                <input type="date" name="startAt" id="startAt" class="form-control" value="@(Model?.Query.StartAt ?? "")" form="fAdminSearch" />
+                <span class="input-group-text">~</span>
+                <input type="date" name="endAt" id="endAt" class="form-control" value="@(Model?.Query.EndAt ?? "")" form="fAdminSearch" />
+            </div>
+        </div>
+        <div class="col-12 col-md-auto text-center">
+            <button type="submit" id="btnSearch" class="btn btn-primary" form="fAdminSearch">검색</button>
+        </div>
+    </div>
+
+    <hr/>
+
+    <div class="row g-2 align-items-center mt-2">
+        <div class="col">
+            Total : @Model?.Total.ToString("N0")
+        </div>
+        <div class="col-auto">
+            <select name="perPage" id="perPage" class="form-select" form="fAdminSearch">
+                <option value="10" selected="@(Model.Query.PerPage == 10)">10</option>
+                <option value="20" selected="@(Model.Query.PerPage == 20)">20</option>
+                <option value="50" selected="@(Model.Query.PerPage == 50)">50</option>
+                <option value="100" selected="@(Model.Query.PerPage == 100)">100</option>
+            </select>
+        </div>
+        <div class="col-auto">
+            <button type="button" id="btnListRestore" class="btn btn-success" disabled>복원</button>
+            <button type="button" id="btnListDelete" class="btn btn-danger" disabled>삭제</button>
+        </div>
+    </div>
+
+    <div class="table-responsive">
+        <table class="table table-striped table-bordered table-hover mt-3">
+            <colgroup>
+                <col style="width: 5%;" />
+                <col />
+                <col style="width: 11%;" />
+                <col style="width: 8%;" />
+                <col style="width: 6%;" />
+                <col style="width: 5%;" />
+                <col style="width: 12%;" />
+                <col style="width: 12%;" />
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>
+                        <div class="form-check form-check-inline">
+                            <input type="checkbox" id="checkedAll" class="form-check-input" value="1" form="fAdminList" />
+                            <label for="checkedAll">ID</label>
+                        </div>
+                    </th>
+                    <th>제목</th>
+                    <th>게시판</th>
+                    <th>작성자</th>
+                    <th>조회</th>
+                    <th>댓글</th>
+                    <th>삭제일</th>
+                    <th>작성일</th>
+                </tr>
+            </thead>
+            <tbody>
+                @if (Model.List == null || Model.Total <= 0)
+                {
+                    <tr>
+                        <td colspan="8">No Data.</td>
+                    </tr>
+                }
+                else
+                {
+                    @foreach (var row in Model.List)
+                    {
+                        <tr>
+                            <td>
+                                <div class="form-check form-check-inline">
+                                    <input type="checkbox" name="ids[]" id="ids_@row.ID" class="form-check-input list-check-box" value="@row.ID" form="fAdminList" />
+                                    <label for="ids_@row.ID">@row.ID</label>
+                                </div>
+                            </td>
+                            <td class="text-start">@row.Subject</td>
+                            <td>@row.BoardName</td>
+                            <td>@(row.Name ?? row.SID ?? "-")</td>
+                            <td>@row.Views</td>
+                            <td>@row.Comments</td>
+                            <td>@row.DeletedAt</td>
+                            <td>@row.CreatedAt</td>
+                        </tr>
+                    }
+                }
+            </tbody>
+        </table>
+
+        <partial name="_Pagination" model="@Model.Pagination" />
+    </div>
+</div>
+
+<form id="fAdminSearch" method="get" accept-charset="utf-8">
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+</form>
+
+<form id="fAdminList" method="post" accept-charset="utf-8">
+    @Html.AntiForgeryToken()
+    <input type="hidden" name="pageNum" value="@Model.Query.PageNum" />
+    <input type="hidden" name="perPage" value="@Model.Query.PerPage" />
+    <input type="hidden" name="boardID" value="@Model.Query.BoardID" />
+    <input type="hidden" name="search" value="@Model.Query.Search" />
+    <input type="hidden" name="keyword" value="@Model.Query.Keyword" />
+    <input type="hidden" name="startAt" value="@Model.Query.StartAt" />
+    <input type="hidden" name="endAt" value="@Model.Query.EndAt" />
+</form>
+
+@section Scripts {
+    <script>
+        let searchForm = document.getElementById("fAdminSearch");
+
+        $(document).on("change", "#perPage", function () {
+            searchForm.elements["pageNum"].value = "1";
+            searchForm.submit();
+        });
+    </script>
+}

+ 121 - 0
Admin/Pages/Forum/Trash/Post/Index.cshtml.cs

@@ -0,0 +1,121 @@
+using SharedKernel.Helpers;
+using SharedKernel.Extensions;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Forum.Trash.Post
+{
+    public class IndexModel(IMediator mediator) : PageModel
+    {
+        [BindProperty(SupportsGet = true)]
+        public QueryParams Query { get; set; } = new();
+
+        public sealed class QueryParams
+        {
+            public int? BoardID { get; set; }
+            public int? Search { get; set; }
+            public string? Keyword { get; set; }
+            public string? StartAt { get; set; }
+            public string? EndAt { get; set; }
+
+            [Range(1, int.MaxValue)]
+            [DisplayName("페이지 번호")]
+            public int PageNum { get; set; } = 1;
+
+            [Range(1, 100)]
+            [DisplayName("페이지 목록 수")]
+            public ushort PerPage { get; set; } = 20;
+        }
+
+        public int Total { get; set; } = 0;
+        public List<SelectListItem> BoardList { get; set; } = [];
+
+        public List<(
+            int Num,
+            int ID,
+            int BoardID,
+            string BoardName,
+            string Subject,
+            string? Name,
+            string? SID,
+            int Views,
+            int Comments,
+            string? DeletedAt,
+            string CreatedAt
+        )> List { get; set; } = [];
+
+        public Pagination? Pagination { get; set; }
+
+        public async Task OnGetAsync(CancellationToken ct)
+        {
+            if (!ModelState.IsValid)
+            {
+                return;
+            }
+
+            var boards = await mediator.Send(new SearchBoards.Query(null, null, 1, 500), ct);
+            BoardList = [..boards.List.Select(c => new SelectListItem
+            {
+                Value = c.ID.ToString(),
+                Text = $"[{c.BoardGroupName}] {c.Name}"
+            })];
+
+            var result = await mediator.Send(new SearchTrashPosts.Query(
+                Query.BoardID,
+                Query.Search,
+                Query.Keyword,
+                Query.StartAt,
+                Query.EndAt,
+                Query.PageNum,
+                Query.PerPage
+            ), ct);
+
+            Total = result.Total;
+            List = [..result.List.Select(c => (
+                c.Num, c.ID, c.BoardID, c.BoardName,
+                c.Subject, c.Name, c.SID,
+                c.Views, c.Comments,
+                c.DeletedAt.GetDateAt() ?? "-",
+                c.CreatedAt.GetDateAt()
+            ))];
+
+            Pagination = new Pagination(result.Total, Query.PageNum, Query.PerPage);
+        }
+
+        public async Task<IActionResult> OnPostRestoreAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new RestoreTrashPost.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 복원되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Trash/Post/Index", Query);
+        }
+
+        public async Task<IActionResult> OnPostDeleteAsync(int[] ids, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new PermanentDeleteTrashPost.Command(ids), ct);
+
+                TempData["SuccessMessage"] = $"{ids.Length}건이 영구 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return RedirectToPage("/Forum/Trash/Post/Index", Query);
+        }
+    }
+}

+ 30 - 13
Admin/Pages/Member/Grade/Write.cshtml

@@ -46,34 +46,51 @@
         <div class="row mb-2">
             <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 순서</label>
             <div class="col-sm-10">
-                <input type="number" asp-for="Input.Order" class="form-control d-inline w-auto" min="-9999" max="9999" required />
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
+                </div>
                 <span asp-validation-for="Input.Order" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
             <label asp-for="Input.TotalDonationCount" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 누적 후원 횟수</label>
             <div class="col-sm-10">
-                <div class="input-group">
-                    <input type="number" asp-for="Input.TotalDonationCount" class="form-control w-auto flex-grow-0" min="0" max="1000000000" required />
-                    <span asp-validation-for="Input.TotalDonationCount" class="text-danger"></span>
-                    <div class="input-group-text">회</div>
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <div class="input-group">
+                            <input type="number" asp-for="Input.TotalDonationCount" class="form-control" min="0" max="1000000000" required />
+                            <div class="input-group-text">회</div>
+                        </div>
+                    </div>
                 </div>
+                <span asp-validation-for="Input.TotalDonationCount" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
             <label asp-for="Input.TotalDonationAmount" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 누적 후원 금액</label>
             <div class="col-sm-10">
-                <div class="input-group">
-                    <input type="number" asp-for="Input.TotalDonationAmount" class="form-control w-auto flex-grow-0" min="0" max="1000000000" required />
-                    <span asp-validation-for="Input.TotalDonationAmount" class="text-danger"></span>
-                    <div class="input-group-text">원</div>
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <div class="input-group">
+                            <input type="number" asp-for="Input.TotalDonationAmount" class="form-control" min="0" max="1000000000" required />
+                            <div class="input-group-text">원</div>
+                        </div>
+                    </div>
                 </div>
+                <span asp-validation-for="Input.TotalDonationAmount" class="text-danger"></span>
             </div>
         </div>
         <div class="row mb-2">
             <label asp-for="Input.TextColor" class="col-sm-2 col-form-label"><span class="text-danger">*</span> 표시 색상</label>
             <div class="col-sm-10">
-                <input type="text" asp-for="Input.TextColor" class="form-control w-auto flex-grow-0" minlength="4" maxlength="7" required />
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input type="text" asp-for="Input.TextColor" class="form-control" minlength="4" maxlength="7" required />
+                    </div>
+                </div>
+                <span asp-validation-for="Input.TextColor" class="text-danger"></span>
                 <div class="form-text text-muted">
                     색상 코드는 #RRGGBB 형식으로 입력하세요. (예: #FF5733)
                 </div>
@@ -93,8 +110,8 @@
         </div>
         <hr/>
         <div class="d-grid gap-2 text-center d-md-block">
-            <button type="submit" class="btn btn-sm btn-success">저장</button>
-            <a asp-page="Index" class="btn btn-sm btn-secondary">취소</a>
+            <button type="submit" class="btn btn-success">저장</button>
+            <a asp-page="Index" class="btn btn-secondary">취소</a>
         </div>
         <br/>
     </form>
@@ -104,4 +121,4 @@
     <script>
         setupImagePreview("Input_ImageFile", "memberGradePrev");
     </script>
-}
+}

+ 5 - 1
Admin/Pages/Popup/Edit.cshtml

@@ -42,7 +42,11 @@
         <div class="row mb-2">
             <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> ¼ø¼­</label>
             <div class="col-sm-10">
-                <input asp-for="Input.Order" class="form-control d-inline w-auto" type="number" min="-9999" max="9999" required />
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
+                </div>
                 <span asp-validation-for="Input.Order" class="text-danger"></span>
             </div>
         </div>

+ 5 - 1
Admin/Pages/Popup/Write.cshtml

@@ -39,7 +39,11 @@
         <div class="row mb-2">
             <label asp-for="Input.Order" class="col-sm-2 col-form-label"><span class="text-danger">*</span> ¼ø¼­</label>
             <div class="col-sm-10">
-                <input asp-for="Input.Order" class="form-control d-inline w-auto" type="number" min="-9999" max="9999" required />
+                <div class="row">
+                    <div class="col col-md-auto">
+                        <input asp-for="Input.Order" class="form-control" type="number" min="-9999" max="9999" required />
+                    </div>
+                </div>
                 <span asp-validation-for="Input.Order" class="text-danger"></span>
             </div>
         </div>

+ 1 - 0
Admin/Pages/Shared/_Pagination.cshtml

@@ -1,4 +1,5 @@
 @model SharedKernel.Helpers.Pagination
+
 @if (Model is not null && Model.TotalRows > 0)
 {
 <nav id="pagination" aria-label="Page navigation">

+ 84 - 1
Admin/using.cs

@@ -91,4 +91,87 @@ global using ChargeWallet = Application.Features.Member.Wallet.List.Charge;
 // 거래 장부
 global using SearchWalletTransactions = Application.Features.Member.Wallet.Transactions.Search;
 global using GetWalletTransaction = Application.Features.Member.Wallet.Transactions.Get;
-global using DeleteWalletTransaction = Application.Features.Member.Wallet.Transactions.Delete;
+global using DeleteWalletTransaction = Application.Features.Member.Wallet.Transactions.Delete;
+
+// 게시판 분류
+global using GetBoardGroups = Application.Features.Forum.BoardGroup.GetAll;
+global using SaveBoardGroups = Application.Features.Forum.BoardGroup.Save;
+
+// 게시판 목록
+global using SearchBoards = Application.Features.Forum.Board.Search;
+global using GetBoard = Application.Features.Forum.Board.Get;
+global using CreateBoard = Application.Features.Forum.Board.Create;
+global using UpdateBoard = Application.Features.Forum.Board.Update;
+global using DeleteBoard = Application.Features.Forum.Board.Delete;
+
+// 게시글 목록
+global using SearchPosts = Application.Features.Forum.Post.Search;
+global using GetPost = Application.Features.Forum.Post.Get;
+global using CreatePost = Application.Features.Forum.Post.Create;
+global using UpdatePost = Application.Features.Forum.Post.Update;
+global using DeletePost = Application.Features.Forum.Post.Delete;
+
+// 게시판 설정
+global using GetBoardMeta = Application.Features.Forum.BoardMeta.Get;
+global using UpdateBoardMeta = Application.Features.Forum.BoardMeta.Update;
+
+// 게시판 관리자
+global using GetBoardManagers = Application.Features.Forum.BoardManager.GetAll;
+global using SaveBoardManagers = Application.Features.Forum.BoardManager.Save;
+
+// 게시판 말머리
+global using GetBoardPrefixes = Application.Features.Forum.BoardPrefix.GetAll;
+global using SaveBoardPrefixes = Application.Features.Forum.BoardPrefix.Save;
+
+// 댓글 목록
+global using SearchComments = Application.Features.Forum.Comment.Search;
+global using GetComment = Application.Features.Forum.Comment.Get;
+global using DeleteComment = Application.Features.Forum.Comment.Delete;
+
+// 휴지통 - 게시글
+global using SearchTrashPosts = Application.Features.Forum.Trash.Post.Search;
+global using RestoreTrashPost = Application.Features.Forum.Trash.Post.Restore;
+global using PermanentDeleteTrashPost = Application.Features.Forum.Trash.Post.PermanentDelete;
+
+// 휴지통 - 댓글
+global using SearchTrashComments = Application.Features.Forum.Trash.Comment.Search;
+global using RestoreTrashComment = Application.Features.Forum.Trash.Comment.Restore;
+global using PermanentDeleteTrashComment = Application.Features.Forum.Trash.Comment.PermanentDelete;
+
+// 게시글 이미지
+global using SearchPostImages = Application.Features.Forum.PostImage.Search;
+global using DeletePostImage = Application.Features.Forum.PostImage.Delete;
+global using ToggleDisablePostImage = Application.Features.Forum.PostImage.ToggleDisable;
+
+// 게시글 첨부파일
+global using SearchPostFiles = Application.Features.Forum.PostFile.Search;
+global using DeletePostFile = Application.Features.Forum.PostFile.Delete;
+global using ToggleDisablePostFile = Application.Features.Forum.PostFile.ToggleDisable;
+
+// 댓글 이미지
+global using SearchCommentImages = Application.Features.Forum.CommentImage.Search;
+global using DeleteCommentImage = Application.Features.Forum.CommentImage.Delete;
+global using ToggleDisableCommentImage = Application.Features.Forum.CommentImage.ToggleDisable;
+
+// 댓글 첨부파일
+global using SearchCommentFiles = Application.Features.Forum.CommentFile.Search;
+global using DeleteCommentFile = Application.Features.Forum.CommentFile.Delete;
+global using ToggleDisableCommentFile = Application.Features.Forum.CommentFile.ToggleDisable;
+
+// 게시글 반응
+global using SearchPostReactions = Application.Features.Forum.PostReaction.Search;
+global using DeletePostReaction = Application.Features.Forum.PostReaction.Delete;
+
+// 댓글 반응
+global using SearchCommentReactions = Application.Features.Forum.CommentReaction.Search;
+global using DeleteCommentReaction = Application.Features.Forum.CommentReaction.Delete;
+
+// 게시글 신고
+global using SearchPostReports = Application.Features.Forum.PostReport.Search;
+global using UpdatePostReportStatus = Application.Features.Forum.PostReport.UpdateStatus;
+global using DeletePostReport = Application.Features.Forum.PostReport.Delete;
+
+// 댓글 신고
+global using SearchCommentReports = Application.Features.Forum.CommentReport.Search;
+global using UpdateCommentReportStatus = Application.Features.Forum.CommentReport.UpdateStatus;
+global using DeleteCommentReport = Application.Features.Forum.CommentReport.Delete;

BIN
Admin/wwwroot/editors/post/1/5/41b090e3d9f84c14876ef87a71ea7b17.jpg


+ 42 - 17
Admin/wwwroot/js/site.js

@@ -12,6 +12,24 @@ $(function () {
     } else {
         aside.showAside();
     }
+
+    // 사이드바 스크롤 위치 복원
+    const savedAsideScroll = sessionStorage.getItem("aside-scroll");
+    if (savedAsideScroll) {
+        document.getElementById("aside").scrollTop = parseInt(savedAsideScroll);
+    }
+
+    // 메인 콘텐츠 스크롤 위치 복원
+    const savedMainScroll = sessionStorage.getItem("main-scroll");
+    if (savedMainScroll) {
+        document.getElementById("main").scrollTop = parseInt(savedMainScroll);
+    }
+});
+
+// 페이지 이동 전 스크롤 위치 저장
+window.addEventListener("beforeunload", function () {
+    sessionStorage.setItem("aside-scroll", document.getElementById("aside").scrollTop);
+    sessionStorage.setItem("main-scroll", document.getElementById("main").scrollTop);
 });
 
 // 좌측 메뉴 처리
@@ -129,7 +147,25 @@ class ActionButtons {
         return true;
     }
 
-    checkout() {
+    checkout(e, action)
+    {
+        let handler = "";
+
+        switch (action) {
+            case "Restore":
+                handler = "?handler=Restore";
+            case "Delete":
+                handler = "?handler=Delete";
+            case "Update":
+                handler = "?handler=Update";
+            default:
+                return "";
+        };
+
+        if (handler) {
+            this.form.action = handler;
+        }
+
         this.form.submit();
     }
 
@@ -139,7 +175,7 @@ class ActionButtons {
         }
 
         if (confirm("선택한 항목을 정말 수정 하시겠습니까?")) {
-            this.checkout(e);
+            this.checkout(e, "Update");
         }
     }
 
@@ -163,26 +199,16 @@ class ActionButtons {
             }
         }
 
-        setTimeout(() => this.checkout(e), 100);
+        setTimeout(() => this.checkout(e, "Delete"), 100);
     }
 
-    Recover(e) {
+    Restore(e) {
         if (!this.validate()) {
             return false;
         }
 
         if (confirm("선택한 항목을 정말 복원 하시겠습니까?")) {
-            this.checkout(e);
-        }
-    }
-
-    Execute(e) {
-        if (!this.validate()) {
-            return false;
-        }
-
-        if (confirm("선택한 항목을 정말 처리 하시겠습니까?")) {
-            this.checkout(e);
+            this.checkout(e, "Restore");
         }
     }
 
@@ -213,9 +239,8 @@ class ActionButtons {
 const actionButtons = new ActionButtons();
 $(document).on("click", "#btnListUpdate", (e) => actionButtons.Update(e));
 $(document).on("click", "#btnListDelete", (e) => actionButtons.Delete(e));
-$(document).on("click", "#btnListRecover", (e) => actionButtons.Recover(e));
+$(document).on("click", "#btnListRestore", (e) => actionButtons.Restore(e));
 $(document).on("click", "#btnListExecute", (e) => actionButtons.Execute(e));
 $(document).on("click", ".btn-row-delete", (e) => actionButtons.Delete(e));
-$(document).on("click", ".btn-row-execute", () => confirm("정말 처리하시겠습니까?"));
 $(document).on("click", "#checkedAll", (e) => actionButtons.checkedAll(e));
 $(document).on("change", "input.list-check-box", actionButtons.toggleDisabled);

+ 41 - 0
Application/Abstractions/Data/IAppDbContext.cs

@@ -6,6 +6,10 @@ using Domain.Entities.Page.Faq;
 using Domain.Entities.Page.Banner;
 using Domain.Entities.Members.Logs;
 using Domain.Entities.Wallets;
+using Domain.Entities.Forum.Boards;
+using Domain.Entities.Forum.Posts;
+using Domain.Entities.Forum.Comments;
+using Domain.Entities.Forum.Logs;
 
 namespace Application.Abstractions.Data
 {
@@ -32,6 +36,43 @@ namespace Application.Abstractions.Data
         DbSet<Wallet> Wallet { get; set; }
         DbSet<WalletTransaction> WalletTransaction { get; set; }
 
+        // 게시판
+        DbSet<BoardGroup> BoardGroup { get; set; }
+        DbSet<Board> Board { get; set; }
+        DbSet<BoardMeta> BoardMeta { get; set; }
+        DbSet<BoardManager> BoardManager { get; set; }
+        DbSet<BoardPrefix> BoardPrefix { get; set; }
+
+        // 게시글
+        DbSet<Post> Post { get; set; }
+        DbSet<PostImage> PostImage { get; set; }
+        DbSet<PostMedia> PostMedia { get; set; }
+        DbSet<PostFile> PostFile { get; set; }
+        DbSet<PostLink> PostLink { get; set; }
+        DbSet<PostReaction> PostReaction { get; set; }
+        DbSet<PostBookmark> PostBookmark { get; set; }
+        DbSet<PostReport> PostReport { get; set; }
+        DbSet<PostTag> PostTag { get; set; }
+        DbSet<Tag> Tag { get; set; }
+
+        // 댓글
+        DbSet<Comment> Comment { get; set; }
+        DbSet<CommentFile> CommentFile { get; set; }
+        DbSet<CommentImage> CommentImage { get; set; }
+        DbSet<CommentMedia> CommentMedia { get; set; }
+        DbSet<CommentLink> CommentLink { get; set; }
+        DbSet<CommentReaction> CommentReaction { get; set; }
+        DbSet<CommentReport> CommentReport { get; set; }
+        DbSet<CommentMention> CommentMention { get; set; }
+
+        // 게시글/댓글 로그
+        DbSet<PostUpdateLog> PostUpdateLog { get; set; }
+        DbSet<PostFileDownLog> PostFileDownLog { get; set; }
+        DbSet<PostLinkClickLog> PostLinkClickLog { get; set; }
+        DbSet<CommentUpdateLog> CommentUpdateLog { get; set; }
+        DbSet<CommentFileDownLog> CommentFileDownLog { get; set; }
+        DbSet<CommentLinkClickLog> CommentLinkClickLog { get; set; }
+
         Task<int> SaveChangesAsync(CancellationToken ct = default);
     }
 }

+ 13 - 0
Application/Features/Forum/Board/Create/Command.cs

@@ -0,0 +1,13 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.Board.Create
+{
+    public sealed record Command(
+        int BoardGroupID,
+        string Code,
+        string Name,
+        short Order,
+        bool IsSearch,
+        bool IsActive
+    ) : ICommand;
+}

+ 43 - 0
Application/Features/Forum/Board/Create/Handler.cs

@@ -0,0 +1,43 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.Board.Create;
+
+public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        if (!await db.BoardGroup.AnyAsync(x => x.ID == request.BoardGroupID, ct))
+        {
+            throw new KeyNotFoundException("게시판 분류를 찾을 수 없습니다.");
+        }
+
+        if (await db.Board.AnyAsync(x => x.Code == request.Code, ct))
+        {
+            throw new InvalidOperationException($"`{request.Code}`는 이미 등록된 게시판 주소입니다.");
+        }
+
+        var board = new Domain.Entities.Forum.Boards.Board
+        {
+            BoardGroupID = request.BoardGroupID,
+            Code = request.Code,
+            Name = request.Name,
+            Order = request.Order,
+            IsSearch = request.IsSearch,
+            IsActive = request.IsActive
+        };
+
+        await db.Board.AddAsync(board, ct);
+        await db.SaveChangesAsync(ct);
+
+        // BoardGroup의 Boards 카운트 증가
+        var boardGroup = await db.BoardGroup.FirstOrDefaultAsync(x => x.ID == request.BoardGroupID, ct);
+        if (boardGroup is not null)
+        {
+            boardGroup.Boards++;
+            boardGroup.UpdatedAt = DateTime.UtcNow;
+            await db.SaveChangesAsync(ct);
+        }
+    }
+}

+ 6 - 0
Application/Features/Forum/Board/Delete/Command.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.Board.Delete
+{
+    public sealed record Command(int[] IDs) : ICommand;
+}

+ 38 - 0
Application/Features/Forum/Board/Delete/Handler.cs

@@ -0,0 +1,38 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.Board.Delete;
+
+public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        if (request.IDs is null || request.IDs.Length == 0)
+        {
+            return;
+        }
+
+        // 게시글이 있는 게시판은 삭제 불가
+        var hasPosts = await db.Post.AsNoTracking().AnyAsync(c => request.IDs.Contains(c.BoardID), ct);
+        if (hasPosts)
+        {
+            throw new InvalidOperationException("게시글이 등록된 게시판은 삭제할 수 없습니다. 먼저 해당 게시글을 삭제하세요.");
+        }
+
+        // BoardGroup 카운트 감소
+        var boards = await db.Board.Where(c => request.IDs.Contains(c.ID)).ToListAsync(ct);
+        var groupIDs = boards.Select(c => c.BoardGroupID).Distinct().ToList();
+        var groups = await db.BoardGroup.Where(c => groupIDs.Contains(c.ID)).ToListAsync(ct);
+
+        foreach (var group in groups)
+        {
+            var count = boards.Count(c => c.BoardGroupID == group.ID);
+            group.Boards -= (short)count;
+            group.UpdatedAt = DateTime.UtcNow;
+        }
+
+        db.Board.RemoveRange(boards);
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 29 - 0
Application/Features/Forum/Board/Get/Handler.cs

@@ -0,0 +1,29 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.Board.Get;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var item = await db.Board.AsNoTracking().FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        if (item is null)
+        {
+            throw new KeyNotFoundException("게시판을 찾을 수 없습니다.");
+        }
+
+        return new Response(
+            item.ID,
+            item.BoardGroupID,
+            item.Code,
+            item.Name,
+            item.Order,
+            item.IsSearch,
+            item.IsActive,
+            item.UpdatedAt,
+            item.CreatedAt
+        );
+    }
+}

+ 6 - 0
Application/Features/Forum/Board/Get/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.Board.Get
+{
+    public sealed record Query(int ID) : IQuery<Response>;
+}

+ 14 - 0
Application/Features/Forum/Board/Get/Response.cs

@@ -0,0 +1,14 @@
+namespace Application.Features.Forum.Board.Get
+{
+    public sealed record Response(
+        int ID,
+        int BoardGroupID,
+        string Code,
+        string Name,
+        short Order,
+        bool IsSearch,
+        bool IsActive,
+        DateTime? UpdatedAt,
+        DateTime CreatedAt
+    );
+}

+ 69 - 0
Application/Features/Forum/Board/Search/Handler.cs

@@ -0,0 +1,69 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.Board.Search;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var query = db.Board.AsNoTracking().Include(c => c.BoardGroup).AsQueryable();
+
+        if (request.BoardGroupID.HasValue)
+        {
+            query = query.Where(c => c.BoardGroupID == request.BoardGroupID.Value);
+        }
+
+        if (!string.IsNullOrWhiteSpace(request.Keyword))
+        {
+            var kw = request.Keyword.Trim();
+            query = query.Where(c => c.Name.Contains(kw) || c.Code.Contains(kw));
+        }
+
+        var total = await query.CountAsync(ct);
+
+        var list = await query
+            .OrderBy(c => c.Order)
+            .ThenByDescending(c => c.ID)
+            .Skip((request.PageNum - 1) * request.PerPage)
+            .Take(request.PerPage)
+            .Select(c => new
+            {
+                c.ID,
+                c.BoardGroupID,
+                BoardGroupName = c.BoardGroup.Name,
+                c.Code,
+                c.Name,
+                c.Order,
+                c.IsSearch,
+                c.IsActive,
+                c.Posts,
+                c.Comments,
+                c.UpdatedAt,
+                c.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        var startNum = total - ((request.PageNum - 1) * request.PerPage);
+
+        return new Response(
+            total,
+            [..list.Select((c, i) => new Response.Row(
+                Num: startNum - i,
+                c.ID,
+                c.BoardGroupID,
+                c.BoardGroupName,
+                c.Code,
+                c.Name,
+                c.Order,
+                c.IsSearch,
+                c.IsActive,
+                c.Posts,
+                c.Comments,
+                c.UpdatedAt,
+                c.CreatedAt
+            ))]
+        );
+    }
+}

+ 6 - 0
Application/Features/Forum/Board/Search/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.Board.Search
+{
+    public sealed record Query(int? BoardGroupID, string? Keyword, int PageNum, ushort PerPage) : IQuery<Response>;
+}

+ 21 - 0
Application/Features/Forum/Board/Search/Response.cs

@@ -0,0 +1,21 @@
+namespace Application.Features.Forum.Board.Search
+{
+    public sealed record Response(int Total, List<Response.Row> List)
+    {
+        public sealed record Row(
+            int Num,
+            int ID,
+            int BoardGroupID,
+            string BoardGroupName,
+            string Code,
+            string Name,
+            short Order,
+            bool IsSearch,
+            bool IsActive,
+            int Posts,
+            int Comments,
+            DateTime? UpdatedAt,
+            DateTime CreatedAt
+        );
+    }
+}

+ 14 - 0
Application/Features/Forum/Board/Update/Command.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.Board.Update
+{
+    public sealed record Command(
+        int ID,
+        int BoardGroupID,
+        string Code,
+        string Name,
+        short Order,
+        bool IsSearch,
+        bool IsActive
+    ) : ICommand;
+}

+ 57 - 0
Application/Features/Forum/Board/Update/Handler.cs

@@ -0,0 +1,57 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.Board.Update;
+
+public sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var board = await db.Board.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        if (board is null)
+        {
+            throw new KeyNotFoundException("게시판을 찾을 수 없습니다.");
+        }
+
+        if (!await db.BoardGroup.AnyAsync(x => x.ID == request.BoardGroupID, ct))
+        {
+            throw new KeyNotFoundException("게시판 분류를 찾을 수 없습니다.");
+        }
+
+        if (board.Code != request.Code && await db.Board.AnyAsync(x => x.Code == request.Code, ct))
+        {
+            throw new InvalidOperationException($"`{request.Code}`는 이미 등록된 게시판 주소입니다.");
+        }
+
+        var oldGroupID = board.BoardGroupID;
+
+        board.BoardGroupID = request.BoardGroupID;
+        board.Code = request.Code;
+        board.Name = request.Name;
+        board.Order = request.Order;
+        board.IsSearch = request.IsSearch;
+        board.IsActive = request.IsActive;
+        board.UpdatedAt = DateTime.UtcNow;
+
+        // BoardGroup 변경 시 카운트 조정
+        if (oldGroupID != request.BoardGroupID)
+        {
+            var oldGroup = await db.BoardGroup.FirstOrDefaultAsync(x => x.ID == oldGroupID, ct);
+            if (oldGroup is not null)
+            {
+                oldGroup.Boards--;
+                oldGroup.UpdatedAt = DateTime.UtcNow;
+            }
+
+            var newGroup = await db.BoardGroup.FirstOrDefaultAsync(x => x.ID == request.BoardGroupID, ct);
+            if (newGroup is not null)
+            {
+                newGroup.Boards++;
+                newGroup.UpdatedAt = DateTime.UtcNow;
+            }
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 46 - 0
Application/Features/Forum/BoardGroup/GetAll/Handler.cs

@@ -0,0 +1,46 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.BoardGroup.GetAll
+{
+    public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+    {
+        public async Task<Response> Handle(Query request, CancellationToken ct)
+        {
+            var items = await db.BoardGroup
+                .AsNoTracking()
+                .OrderBy(c => c.Order)
+                .ThenByDescending(c => c.ID)
+                .Select(c => new
+                {
+                    c.ID,
+                    c.Code,
+                    c.Name,
+                    c.Order,
+                    c.Boards,
+                    c.Posts,
+                    c.Comments,
+                    c.UpdatedAt,
+                    c.CreatedAt
+                })
+                .ToListAsync(ct);
+
+            return new Response(
+                items.Count,
+                [..items.Select((c, i) => new Response.Row(
+                    i + 1,
+                    c.ID,
+                    i,
+                    c.Code,
+                    c.Name,
+                    c.Order,
+                    c.Boards,
+                    c.Posts,
+                    c.Comments,
+                    c.UpdatedAt,
+                    c.CreatedAt
+                ))]);
+        }
+    }
+}

+ 6 - 0
Application/Features/Forum/BoardGroup/GetAll/Query.cs

@@ -0,0 +1,6 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.BoardGroup.GetAll
+{
+    public sealed record Query : IQuery<Response>;
+}

+ 19 - 0
Application/Features/Forum/BoardGroup/GetAll/Response.cs

@@ -0,0 +1,19 @@
+namespace Application.Features.Forum.BoardGroup.GetAll
+{
+    public sealed record Response(int Total, IReadOnlyList<Response.Row> List)
+    {
+        public sealed record Row(
+            int Num,
+            int ID,
+            int Index,
+            string Code,
+            string Name,
+            short Order,
+            short Boards,
+            int Posts,
+            int Comments,
+            DateTime? UpdatedAt,
+            DateTime CreatedAt
+        );
+    }
+}

+ 14 - 0
Application/Features/Forum/BoardGroup/Save/Command.cs

@@ -0,0 +1,14 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.BoardGroup.Save
+{
+    public sealed record Command(IReadOnlyList<Command.Row> Items) : ICommand<Response>
+    {
+        public sealed record Row(
+            int? ID,
+            string Code,
+            string Name,
+            short Order
+        );
+    }
+}

+ 84 - 0
Application/Features/Forum/BoardGroup/Save/Handler.cs

@@ -0,0 +1,84 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.BoardGroup.Save
+{
+    public sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Response>
+    {
+        public async Task<Response> Handle(Command request, CancellationToken ct)
+        {
+            var items = request.Items;
+
+            var dbRows = await db.BoardGroup.ToListAsync(ct);
+            var dbByID = dbRows.ToDictionary(c => c.ID);
+
+            var requestIDs = items.Where(c => c.ID.HasValue && c.ID.Value > 0).Select(c => c.ID!.Value).ToHashSet();
+
+            // 삭제 대상 조회
+            var deleteTargets = dbRows.Where(x => !requestIDs.Contains(x.ID)).ToList();
+
+            if (deleteTargets.Count > 0)
+            {
+                var deleteIDs = deleteTargets.Select(c => c.ID).ToList();
+                var hasBoards = await db.Board.AsNoTracking().AnyAsync(c => deleteIDs.Contains(c.BoardGroupID), ct);
+
+                if (hasBoards)
+                {
+                    throw new InvalidOperationException("게시판이 등록된 분류는 삭제할 수 없습니다. 먼저 해당 게시판을 삭제하세요.");
+                }
+
+                db.BoardGroup.RemoveRange(deleteTargets);
+            }
+
+            ushort inserted = 0;
+            ushort updated = 0;
+            ushort deleted = 0;
+
+            foreach (var row in items)
+            {
+                // 신규 추가
+                if (!row.ID.HasValue || row.ID.Value <= 0)
+                {
+                    if (await db.BoardGroup.AnyAsync(c => c.Code == row.Code, ct))
+                    {
+                        throw new InvalidOperationException($"`{row.Code}`는 이미 등록되었습니다.");
+                    }
+
+                    db.BoardGroup.Add(new Domain.Entities.Forum.Boards.BoardGroup
+                    {
+                        Code = row.Code,
+                        Name = row.Name,
+                        Order = row.Order
+                    });
+
+                    inserted++;
+                    continue;
+                }
+
+                // 수정
+                if (!dbByID.TryGetValue(row.ID.Value, out var existing))
+                {
+                    throw new InvalidOperationException($"존재하지 않는 ID: {row.ID.Value}");
+                }
+
+                if (existing.Code != row.Code && await db.BoardGroup.AnyAsync(c => c.Code == row.Code, ct))
+                {
+                    throw new InvalidOperationException($"`{row.Code}`는 이미 등록되었습니다.");
+                }
+
+                existing.Code = row.Code;
+                existing.Name = row.Name;
+                existing.Order = row.Order;
+                existing.UpdatedAt = DateTime.UtcNow;
+                updated++;
+            }
+
+            deleted = (ushort)deleteTargets.Count;
+
+            await db.SaveChangesAsync(ct);
+
+            return new Response(inserted, updated, deleted);
+        }
+    }
+}

+ 4 - 0
Application/Features/Forum/BoardGroup/Save/Response.cs

@@ -0,0 +1,4 @@
+namespace Application.Features.Forum.BoardGroup.Save
+{
+    public sealed record Response(ushort Inserted = 0, ushort Updated = 0, ushort Deleted = 0);
+}

+ 45 - 0
Application/Features/Forum/BoardManager/GetAll/Handler.cs

@@ -0,0 +1,45 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using SharedKernel.Extensions;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.BoardManager.GetAll;
+
+internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var items = await db.BoardManager
+            .AsNoTracking()
+            .Include(x => x.Member)
+            .Where(c => c.BoardID == request.BoardID)
+            .OrderByDescending(c => c.ID)
+            .Select(c => new
+            {
+                c.ID,
+                c.BoardID,
+                c.MemberID,
+                MemberEmail = c.Member.Email,
+                MemberFullName = c.Member.FullName,
+                c.CanEdit,
+                c.CanDelete,
+                c.UpdatedAt,
+                c.CreatedAt
+            })
+            .ToListAsync(ct);
+
+        return new Response(
+            items.Count,
+            [..items.Select(c => new Response.Row(
+                c.ID,
+                c.BoardID,
+                c.MemberID,
+                c.MemberEmail,
+                c.MemberFullName,
+                c.CanEdit,
+                c.CanDelete,
+                c.UpdatedAt.GetDateAt(),
+                c.CreatedAt.GetDateAt()
+            ))]);
+    }
+}

+ 5 - 0
Application/Features/Forum/BoardManager/GetAll/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.BoardManager.GetAll;
+
+public sealed record Query(int BoardID) : IQuery<Response>;

+ 16 - 0
Application/Features/Forum/BoardManager/GetAll/Response.cs

@@ -0,0 +1,16 @@
+namespace Application.Features.Forum.BoardManager.GetAll;
+
+public sealed record Response(int Total, IReadOnlyList<Response.Row> List)
+{
+    public sealed record Row(
+        int ID,
+        int BoardID,
+        int MemberID,
+        string MemberEmail,
+        string? MemberFullName,
+        bool CanEdit,
+        bool CanDelete,
+        string? UpdatedAt,
+        string CreatedAt
+    );
+}

+ 23 - 0
Application/Features/Forum/BoardManager/Save/Command.cs

@@ -0,0 +1,23 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.BoardManager.Save;
+
+public sealed record Command(
+    int BoardID,
+    Command.Create? NewItem,
+    IReadOnlyList<Command.Update>? Updates,
+    int[]? DeleteIDs
+) : ICommand
+{
+    public sealed record Create(
+        int MemberID,
+        bool CanEdit,
+        bool CanDelete
+    );
+
+    public sealed record Update(
+        int ID,
+        bool CanEdit,
+        bool CanDelete
+    );
+}

+ 70 - 0
Application/Features/Forum/BoardManager/Save/Handler.cs

@@ -0,0 +1,70 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.BoardManager.Save;
+
+internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        // 삭제
+        if (request.DeleteIDs is { Length: > 0 })
+        {
+            var deleteTargets = await db.BoardManager
+                .Where(c => c.BoardID == request.BoardID && request.DeleteIDs.Contains(c.ID))
+                .ToListAsync(ct);
+
+            if (deleteTargets.Count > 0)
+            {
+                db.BoardManager.RemoveRange(deleteTargets);
+            }
+        }
+
+        // 수정
+        if (request.Updates is { Count: > 0 })
+        {
+            var updateIDs = request.Updates.Select(c => c.ID).ToList();
+            var updateTargets = await db.BoardManager
+                .Where(c => c.BoardID == request.BoardID && updateIDs.Contains(c.ID))
+                .ToListAsync(ct);
+
+            var updateByID = updateTargets.ToDictionary(c => c.ID);
+
+            foreach (var row in request.Updates)
+            {
+                if (!updateByID.TryGetValue(row.ID, out var existing))
+                {
+                    throw new InvalidOperationException($"존재하지 않는 ID: {row.ID}");
+                }
+
+                existing.CanEdit = row.CanEdit;
+                existing.CanDelete = row.CanDelete;
+                existing.UpdatedAt = DateTime.UtcNow;
+            }
+        }
+
+        // 신규 추가
+        if (request.NewItem is not null)
+        {
+            var isDuplicate = await db.BoardManager
+                .AsNoTracking()
+                .AnyAsync(c => c.BoardID == request.BoardID && c.MemberID == request.NewItem.MemberID, ct);
+
+            if (isDuplicate)
+            {
+                throw new InvalidOperationException("이미 등록된 매니저입니다.");
+            }
+
+            db.BoardManager.Add(new Domain.Entities.Forum.Boards.BoardManager
+            {
+                BoardID = request.BoardID,
+                MemberID = request.NewItem.MemberID,
+                CanEdit = request.NewItem.CanEdit,
+                CanDelete = request.NewItem.CanDelete
+            });
+        }
+
+        await db.SaveChangesAsync(ct);
+    }
+}

+ 41 - 0
Application/Features/Forum/BoardMeta/Get/Handler.cs

@@ -0,0 +1,41 @@
+using Application.Abstractions.Data;
+using Application.Abstractions.Messaging;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Forum.BoardMeta.Get;
+
+public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+{
+    public async Task<Response> Handle(Query request, CancellationToken ct)
+    {
+        var board = await db.Board
+            .AsNoTracking()
+            .FirstOrDefaultAsync(x => x.ID == request.BoardID, ct)
+            ?? throw new KeyNotFoundException("게시판을 찾을 수 없습니다.");
+
+        var meta = await db.BoardMeta
+            .FirstOrDefaultAsync(x => x.BoardID == request.BoardID, ct);
+
+        if (meta is null)
+        {
+            meta = new Domain.Entities.Forum.Boards.BoardMeta { BoardID = request.BoardID };
+            db.BoardMeta.Add(meta);
+            await db.SaveChangesAsync(ct);
+        }
+
+        return new Response(
+            meta.ID,
+            meta.BoardID,
+            board.Code,
+            board.Name,
+            meta.List,
+            meta.View,
+            meta.Write,
+            meta.Comment,
+            meta.General,
+            meta.Permission,
+            meta.Notify,
+            meta.NotifyTemplate,
+            meta.Exp);
+    }
+}

+ 5 - 0
Application/Features/Forum/BoardMeta/Get/Query.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Forum.BoardMeta.Get;
+
+public sealed record Query(int BoardID) : IQuery<Response>;

+ 18 - 0
Application/Features/Forum/BoardMeta/Get/Response.cs

@@ -0,0 +1,18 @@
+using Domain.Entities.Forum.Boards;
+
+namespace Application.Features.Forum.BoardMeta.Get;
+
+public sealed record Response(
+    int ID,
+    int BoardID,
+    string BoardCode,
+    string BoardName,
+    BoardMetaList List,
+    BoardMetaView View,
+    BoardMetaWrite Write,
+    BoardMetaComment Comment,
+    BoardMetaGeneral General,
+    BoardMetaPermission Permission,
+    BoardMetaNotify Notify,
+    BoardMetaNotifyTemplate NotifyTemplate,
+    BoardMetaExp Exp);

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است