KIM-JINO5 2 bulan lalu
induk
melakukan
6e0090721e
37 mengubah file dengan 552 tambahan dan 196 penghapusan
  1. 15 1
      Admin/Pages/Banner/List/Edit.cshtml
  2. 34 13
      Admin/Pages/Forum/Posts/List/Edit.cshtml
  3. 57 5
      Admin/Pages/Forum/Posts/List/Edit.cshtml.cs
  4. 18 6
      Admin/Pages/Forum/Posts/List/Index.cshtml
  5. 178 140
      Admin/Pages/Forum/Posts/List/View.cshtml
  6. 2 0
      Admin/Pages/Forum/Posts/List/View.cshtml.cs
  7. 20 7
      Admin/Pages/Forum/Posts/List/Write.cshtml
  8. 6 1
      Admin/Pages/Forum/Posts/List/Write.cshtml.cs
  9. 1 0
      Admin/using.cs
  10. TEMPAT SAMPAH
      Admin/wwwroot/editors/post/39/b6d0f5bbc3494fdfa6396a79b948a949.jpg
  11. 5 1
      Admin/wwwroot/js/func.js
  12. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/23/788dd7c44a3c46388477d39b3c961d7c.jpg
  13. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/38/62386546b4b346a185ce41ee05fcf257.jpg
  14. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/39/9ee22bd7de7441e184674fd83afb3053.jpg
  15. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/39/a8b1d1256b004204b6c630f24a737536.png
  16. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/40/21eca82bc422402fb216643f9ee13865.jpg
  17. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/40/92d42f3d3917456c84b3550d684a665f.jpg
  18. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/41/7af37c6464ac4522ae0a56113d1cb28f.jpg
  19. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/5/0af0d89a44af4bd794dffa7ccb0f65a3.jpg
  20. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/5/54122d3cfc8641aca7fbfa91369ddb7e.jpg
  21. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/5/55e00bda0c2c4f7da47d8ccf98e2776c.jpg
  22. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/5/8c4179fc90254af28ed52ef70670fdfc.jpg
  23. TEMPAT SAMPAH
      Admin/wwwroot/uploads/post/5/b87bb812cd57431e9a6ccea4fd3c2e80.jpg
  24. 1 0
      Application/Application.csproj
  25. 29 6
      Application/Features/Admin/Forum/Comment/Search/Handler.cs
  26. 55 2
      Application/Features/Admin/Forum/Post/Create/Handler.cs
  27. 5 0
      Application/Features/Admin/Forum/Post/DeleteThumbnail/Command.cs
  28. 25 0
      Application/Features/Admin/Forum/Post/DeleteThumbnail/Handler.cs
  29. 3 1
      Application/Features/Admin/Forum/Post/Get/Handler.cs
  30. 1 0
      Application/Features/Admin/Forum/Post/Get/Response.cs
  31. 55 5
      Application/Features/Admin/Forum/Post/Update/Handler.cs
  32. 18 2
      Application/Features/Api/Forum/Comment/Search/Handler.cs
  33. 10 0
      Application/Features/Api/MyPage/GetPosts/Handler.cs
  34. 5 0
      Application/Features/Api/MyPage/GetPosts/Response.cs
  35. 5 3
      Infrastructure/Storage/LocalFileStorage.cs
  36. 2 2
      SharedKernel/Constants/Menus.cs
  37. 2 1
      global.json

+ 15 - 1
Admin/Pages/Banner/List/Edit.cshtml

@@ -77,11 +77,19 @@
             <div class="col-sm-10">
                 <div class="mb-3">
                     <label asp-for="Input.DesktopImageFile" class="form-label">Desktop</label>
+                    <div id="DesktopBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Desktop) 미리보기" /><br />
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
                     <input asp-for="Input.DesktopImageFile" type="file" class="form-control" accept="image/*" />
                     <span asp-validation-for="Input.DesktopImageFile" class="text-danger"></span>
                 </div>
                 <div>
                     <label asp-for="Input.MobileImageFile" class="form-label">Mobile</label>
+                    <div id="MobileBannerPrev" hidden>
+                        <img class="img-fluid img-thumbnail" alt="이미지(Mobile) 미리보기" /><br />
+                        <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                    </div>
                     <input asp-for="Input.MobileImageFile" type="file" class="form-control" accept="image/*" />
                     <span asp-validation-for="Input.MobileImageFile" class="text-danger"></span>
                 </div>
@@ -136,4 +144,10 @@
         </div>
         <br/>
     </form>
-</div>
+</div>
+@section Scripts {
+    <script>
+        setupImagePreview("Input_DesktopImageFile", "DesktopBannerPrev");
+        setupImagePreview("Input_MobileImageFile", "MobileBannerPrev");
+    </script>
+}

+ 34 - 13
Admin/Pages/Forum/Posts/List/Edit.cshtml

@@ -12,14 +12,14 @@
 
     <partial name="_StatusMessage" />
 
-    <form method="post" accept-charset="utf-8" autocomplete="off" enctype="multipart/form-data">
+    <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" />
         <input type="hidden" asp-for="Input.ReturnUrl" />
 
-        <!-- 말머리 -->
+        <!-- 말머리 / 문의 유형 -->
         <div class="row mb-2" id="prefixRow">
-            <label class="col-sm-2 col-form-label">말머리</label>
+            <label class="col-sm-2 col-form-label">@(Model.IsQnA ? "문의 유형" : "말머리")</label>
             <div class="col-sm-10">
                 <select asp-for="Input.BoardPrefixID" class="form-select w-auto" id="boardPrefixSelect">
                     <option value="">- 선택 -</option>
@@ -40,6 +40,7 @@
             <label class="col-sm-2 col-form-label">내용</label>
             <div class="col-sm-10">
                 <textarea asp-for="Input.Content" class="ck-editor" id="Input_Content" rows="12"></textarea>
+                <span asp-validation-for="Input.Content" class="text-danger"></span>
             </div>
         </div>
 
@@ -47,10 +48,24 @@
         <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.CurrentThumbnail) ? "hidden" : "")>
-                    <img class="img-fluid img-thumbnail" alt="대표 이미지 미리보기" src="@(Model.CurrentThumbnail ?? "")" style="max-width: 300px;" />
+                @if (!string.IsNullOrWhiteSpace(Model.CurrentThumbnail))
+                {
+                    <div class="mb-2">
+                        <img class="img-fluid img-thumbnail" alt="대표 이미지" src="@Model.CurrentThumbnail" style="max-width: 300px;" /><br />
+                        <button type="submit"
+                                class="btn btn-sm btn-danger mt-2 mb-2"
+                                formaction="?handler=DeleteThumbnail"
+                                formnovalidate
+                                onclick="return confirm('대표 이미지를 삭제하시겠습니까?');">
+                            삭제
+                        </button>
+                    </div>
+                }
+                <div id="thumbPrev" hidden>
+                    <img class="img-fluid img-thumbnail" alt="대표 이미지 미리보기" style="max-width: 300px;" /><br />
+                    <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
                 </div>
-                <input type="file" id="Input_ThumbnailFile" asp-for="Input.ThumbnailFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.bmp" />
+                <input type="file" id="Input_ThumbnailFile" asp-for="Input.ThumbnailFile" class="form-control" />
                 <span class="form-text text-muted">
                     지원 확장자: <code>.jpg</code>, <code>.jpeg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>, <code>.bmp</code>
                 </span>
@@ -60,26 +75,33 @@
         <!-- 기존 첨부파일 -->
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">기존 첨부파일</label>
-            <div class="col-sm-10">
+            <div class="col-sm-10 align-self-center">
                 @if (Model.ExistingFiles.Count > 0)
                 {
                     <ul class="list-unstyled mb-1">
                         @foreach (var file in Model.ExistingFiles)
                         {
-                            <li>
+                            <li class="d-flex align-items-center gap-2 mb-1">
                                 <a href="@file.Url" target="_blank">@file.FileName</a>
                                 @if (file.Size.HasValue)
                                 {
                                     <small class="text-muted">(@(file.Size.Value > 1048576 ? $"{file.Size.Value / 1048576.0:F1}MB" : $"{file.Size.Value / 1024.0:F1}KB"))</small>
                                 }
                                 <small class="text-muted">다운로드: @file.Downloads</small>
+                                <button type="submit"
+                                        class="btn btn-sm btn-outline-danger py-0 px-1"
+                                        formaction="?handler=DeleteFile&amp;fileID=@file.ID"
+                                        formnovalidate
+                                        onclick="return confirm(`첨부파일 '@file.FileName'을(를) 삭제하시겠습니까?`);">
+                                    삭제
+                                </button>
                             </li>
                         }
                     </ul>
                 }
                 else
                 {
-                    <span class="text-muted">없음</span>
+                    <span class="text-muted p-2">없음</span>
                 }
             </div>
         </div>
@@ -88,9 +110,8 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">첨부파일 추가</label>
             <div class="col-sm-10">
-                <input type="file" asp-for="Input.Files" class="form-control" multiple
-                       accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,.7z,.hwp,.hwpx,.csv" />
-                <span class="form-text text-muted">여러 파일을 선택할 수 있습니다.</span>
+                <input type="file" asp-for="Input.Files" class="form-control" multiple />
+                <span class="form-text text-muted">허용 확장자: <code>.jpg</code>, <code>.jpeg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>, <code>.bmp</code>, <code>.pdf</code>, <code>.doc</code>, <code>.docx</code>, <code>.xls</code>, <code>.xlsx</code>, <code>.ppt</code>, <code>.pptx</code>, <code>.txt</code>, <code>.zip</code>, <code>.rar</code>, <code>.7z</code>, <code>.hwp</code>, <code>.hwpx</code>, <code>.csv</code></span>
             </div>
         </div>
 
@@ -182,4 +203,4 @@
             }
         })();
     </script>
-}
+}

+ 57 - 5
Admin/Pages/Forum/Posts/List/Edit.cshtml.cs

@@ -1,7 +1,9 @@
-using SharedKernel.Extensions;
 using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.WebUtilities;
+using SharedKernel.Attributes;
+using SharedKernel.Extensions;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
@@ -18,6 +20,7 @@ namespace Admin.Pages.Forum.Posts.List
         public InputModel Input { get; set; } = new();
 
         public string? CurrentThumbnail { get; private set; }
+        public bool IsQnA { get; set; }
         public List<(int ID, string FileName, string Url, string? Extension, long? Size, int Downloads)> ExistingFiles { get; set; } = [];
         public List<(int TagID, string Name)> ExistingTags { get; set; } = [];
 
@@ -41,9 +44,11 @@ namespace Admin.Pages.Forum.Posts.List
             public string? Content { get; set; }
 
             [DisplayName("대표 이미지")]
+            [AllowedExtensions("jpg,jpeg,png,gif,webp,bmp", ErrorMessage = "이미지 파일은 jpg, jpeg, png, gif, webp, bmp 형식이어야 합니다.")]
             public IFormFile? ThumbnailFile { get; set; }
 
             [DisplayName("첨부파일")]
+            [AllowedExtensions("jpg,jpeg,png,gif,webp,bmp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,zip,rar,7z,hwp,hwpx,csv", ErrorMessage = "첨부 파일은 허용된 형식이어야 합니다.")]
             public List<IFormFile>? Files { get; set; }
 
             [DisplayName("태그")]
@@ -66,7 +71,9 @@ namespace Admin.Pages.Forum.Posts.List
             var result = await mediator.Send(new GetPost.Query(id), ct);
 
             CurrentThumbnail = result.Thumbnail;
-            ReturnUrl = Request.Headers.Referer.ToString();
+            IsQnA = result.IsQnA;
+            var referer = Request.Headers.Referer.ToString();
+            ReturnUrl = string.IsNullOrWhiteSpace(referer) ? null : referer;
 
             Input = new InputModel
             {
@@ -128,7 +135,37 @@ namespace Admin.Pages.Forum.Posts.List
                 TempData["ErrorMessages"] = e.Message;
             }
 
-            return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{Request.QueryString}");
+            return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{GetCleanQueryString()}");
+        }
+
+        public async Task<IActionResult> OnPostDeleteThumbnailAsync(CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePostThumbnail.Command(Input.ID), ct);
+                TempData["SuccessMessage"] = "대표 이미지가 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{GetCleanQueryString()}");
+        }
+
+        public async Task<IActionResult> OnPostDeleteFileAsync(int fileID, CancellationToken ct)
+        {
+            try
+            {
+                await mediator.Send(new DeletePostFile.Command([fileID]), ct);
+                TempData["SuccessMessage"] = "첨부파일이 삭제되었습니다.";
+            }
+            catch (Exception e)
+            {
+                TempData["ErrorMessages"] = e.Message;
+            }
+
+            return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{GetCleanQueryString()}");
         }
 
         public async Task<IActionResult> OnPostDeleteAsync(CancellationToken ct)
@@ -145,8 +182,23 @@ namespace Admin.Pages.Forum.Posts.List
             {
                 TempData["ErrorMessages"] = e.Message;
 
-                return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{Request.QueryString}");
+                return Redirect($"/Forum/Posts/List/Edit/{Input.ID}{GetCleanQueryString()}");
             }
         }
+
+        private string GetCleanQueryString()
+        {
+            var query = QueryHelpers.ParseQuery(Request.QueryString.ToString());
+            query.Remove("handler");
+            query.Remove("fileID");
+
+            if (query.Count == 0)
+            {
+                return string.Empty;
+            }
+
+            var pairs = query.SelectMany(kvp => kvp.Value, (kvp, v) => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(v ?? "")}");
+            return "?" + string.Join("&", pairs);
+        }
     }
-}
+}

+ 18 - 6
Admin/Pages/Forum/Posts/List/Index.cshtml

@@ -154,6 +154,10 @@
                                 </div>
                             </td>
                             <td class="text-start">
+                                @if (item.IsSecret)
+                                {
+                                    <i class="bi bi-lock-fill"></i>
+                                }
                                 @if (item.BoardPrefixID != null)
                                 {
                                     <a href="?BoardPrefixID=@item.BoardPrefixID" class="text-success text-decoration-none fw-bold">[@item.BoardPrefixName]</a>
@@ -169,6 +173,18 @@
                                     }
                                 </a>
                                 <span class="text-danger">[@item.Comments]</span>
+                                @if (item.Images > 0)
+                                {
+                                    <i class="bi bi-card-image"></i>
+                                }   
+                                @if (item.Files > 0)
+                                {
+                                    <i class="bi bi-file-earmark-arrow-down-fill"></i>
+                                }
+                                @if (item.Medias > 0)
+                                {
+                                    <i class="bi bi-collection-play-fill"></i>
+                                }
                                 @if (item.IsSpeaker)
                                 {
                                     <i class="bi bi-megaphone-fill"></i>
@@ -177,17 +193,13 @@
                                 {
                                     <i class="bi bi-bell-fill"></i>
                                 }
-                                @if (item.IsSecret)
-                                {
-                                    <i class="bi bi-lock-fill"></i>
-                                }
                                 @if (item.IsQnA)
                                 {
-                                    <i class="bi bi-question-circle-fill"></i>
+                                    <i class="bi bi-chat-dots-fill"></i>
 
                                     @if (item.IsReply)
                                     {
-                                        <i class="bi bi-person-raised-hand"></i>
+                                        <i class="bi bi-reply-fill"></i>
                                     }
                                 }
                                 @if (item.IsAnonymous)

+ 178 - 140
Admin/Pages/Forum/Posts/List/View.cshtml

@@ -12,154 +12,192 @@
 
     <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.ID</td>
-            </tr>
-            <tr>
-                <th>게시판</th>
-                <td>@Model.BoardName</td>
-            </tr>
-            <tr>
-                <th>말머리</th>
-                <td>
-                    @if (Model.BoardPrefixName is not null)
-                    {
-                        <span class="fw-bold" style="color: @(Model.BoardPrefixColor ?? "green")">[@Model.BoardPrefixName]</span>
-                    }
-                    else
-                    {
-                        <text>-</text>
-                    }
-                </td>
-            </tr>
-            <tr>
-                <th>제목</th>
-                <td>@Model.Subject</td>
-            </tr>
-            <tr>
-                <th>작성자</th>
-                <td>@(Model.Name ?? Model.SID ?? "-")</td>
-            </tr>
-            <tr>
-                <th>내용</th>
-                <td>
-                    <div class="ck-content border rounded p-3" style="min-height: 100px;">@Html.Raw(Model.Content)</div>
-                </td>
-            </tr>
-            <tr>
-                <th>대표 이미지</th>
-                <td>
-                    @if (!string.IsNullOrWhiteSpace(Model.Thumbnail))
-                    {
-                        <img src="@Model.Thumbnail" class="img-fluid img-thumbnail" style="max-width: 300px;" alt="대표 이미지" />
-                    }
-                    else
-                    {
-                        <text>-</text>
-                    }
-                </td>
-            </tr>
-            <tr>
-                <th>태그</th>
-                <td>
-                    @if (Model.Tags.Count > 0)
-                    {
-                        foreach (var tag in Model.Tags)
-                        {
-                            <span class="badge bg-secondary me-1">@tag.Name</span>
-                        }
-                    }
-                    else
-                    {
-                        <text>-</text>
-                    }
-                </td>
-            </tr>
-            <tr>
-                <th>첨부파일</th>
-                <td>
-                    @if (Model.Files.Count > 0)
-                    {
-                        <ul class="list-unstyled mb-0">
-                            @foreach (var file in Model.Files)
-                            {
-                                <li>
-                                    <a href="@file.Url" target="_blank">@file.FileName</a>
-                                    @if (file.Size.HasValue)
-                                    {
-                                        <small class="text-muted">(@(file.Size.Value > 1048576 ? $"{file.Size.Value / 1048576.0:F1}MB" : $"{file.Size.Value / 1024.0:F1}KB"))</small>
-                                    }
-                                    <small class="text-muted">다운로드: @file.Downloads</small>
-                                </li>
-                            }
-                        </ul>
-                    }
-                    else
+    <div class="border rounded">
+        <!-- ID -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">ID</div>
+            <div class="col-12 col-md-10 p-2">@Model.ID</div>
+        </div>
+
+        <!-- 게시판 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">게시판</div>
+            <div class="col-12 col-md-10 p-2">@Model.BoardName</div>
+        </div>
+
+        <!-- 말머리 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">@(Model.IsQnA ? "문의 유형" : "말머리")</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (Model.BoardPrefixName is not null)
+                {
+                    <span class="fw-bold" style="color: @(Model.BoardPrefixColor ?? "green")">[@Model.BoardPrefixName]</span>
+                }
+                else
+                {
+                    <text>-</text>
+                }
+            </div>
+        </div>
+
+        <!-- 제목 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">제목</div>
+            <div class="col-12 col-md-10 p-2">@Model.Subject</div>
+        </div>
+
+        <!-- 작성자 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">작성자</div>
+            <div class="col-12 col-md-10 p-2">@(Model.Name ?? Model.SID ?? "-")</div>
+        </div>
+
+        <!-- 내용 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">내용</div>
+            <div class="col-12 col-md-10 p-2">
+                <div class="ck-content border rounded p-3" style="min-height: 100px;">@Html.Raw(Model.Content)</div>
+            </div>
+        </div>
+
+        <!-- 대표 이미지 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">대표 이미지</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (!string.IsNullOrWhiteSpace(Model.Thumbnail))
+                {
+                    <img src="@Model.Thumbnail" class="img-fluid img-thumbnail" style="max-width: 300px;" alt="대표 이미지" />
+                }
+                else
+                {
+                    <text>-</text>
+                }
+            </div>
+        </div>
+
+        <!-- 태그 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">태그</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (Model.Tags.Count > 0)
+                {
+                    foreach (var tag in Model.Tags)
                     {
-                        <text>-</text>
+                        <span class="badge bg-secondary me-1">@tag.Name</span>
                     }
-                </td>
-            </tr>
-            <tr>
-                <th>이미지</th>
-                <td>
-                    @if (Model.Images.Count > 0)
-                    {
-                        foreach (var img in Model.Images)
+                }
+                else
+                {
+                    <text>-</text>
+                }
+            </div>
+        </div>
+
+        <!-- 첨부파일 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">첨부파일</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (Model.Files.Count > 0)
+                {
+                    <ul class="list-unstyled mb-0">
+                        @foreach (var file in Model.Files)
                         {
-                            <a href="@img.Url" target="_blank">
-                                <img src="@img.Url" class="img-thumbnail me-1 mb-1" style="max-width: 120px; max-height: 120px;" alt="@img.FileName" />
-                            </a>
+                            <li class="mb-1">
+                                <a href="@file.Url" target="_blank">@file.FileName</a>
+                                @if (file.Size.HasValue)
+                                {
+                                    <small class="text-muted">(@(file.Size.Value > 1048576 ? $"{file.Size.Value / 1048576.0:F1}MB" : $"{file.Size.Value / 1024.0:F1}KB"))</small>
+                                }
+                                <small class="text-muted">다운로드: @file.Downloads</small>
+                            </li>
                         }
-                    }
-                    else
-                    {
-                        <text>-</text>
-                    }
-                </td>
-            </tr>
-            <tr>
-                <th>상태</th>
-                <td>
-                    @if (Model.IsNotice) { <span class="badge bg-warning text-dark me-1">공지</span> }
-                    @if (Model.IsSecret) { <span class="badge bg-dark me-1">비밀</span> }
-                    @if (Model.IsAnonymous) { <span class="badge bg-info me-1">익명</span> }
-                    @if (Model.IsSpeaker) { <span class="badge bg-primary me-1">스피커</span> }
-                    @if (Model.IsDeleted) { <span class="badge bg-danger me-1">삭제됨</span> }
-                    @if (!Model.IsNotice && !Model.IsSecret && !Model.IsAnonymous && !Model.IsSpeaker && !Model.IsDeleted)
+                    </ul>
+                }
+                else
+                {
+                    <text>-</text>
+                }
+            </div>
+        </div>
+
+        <!-- 이미지 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">이미지</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (Model.Images.Count > 0)
+                {
+                    foreach (var img in Model.Images)
                     {
-                        <text>일반</text>
+                        <a href="@img.Url" target="_blank">
+                            <img src="@img.Url" class="img-thumbnail me-1 mb-1" style="max-width: 120px; max-height: 120px;" alt="@img.FileName" />
+                        </a>
                     }
-                </td>
-            </tr>
-            <tr>
-                <th>조회 / 공감 / 비공감</th>
-                <td>@Model.Views / @Model.Likes / @Model.Dislikes</td>
-            </tr>
-            <tr>
-                <th>댓글</th>
-                <td>@Model.CommentCount</td>
-            </tr>
-            <tr>
-                <th>수정일</th>
-                <td>@(Model.UpdatedAt ?? "-")</td>
-            </tr>
-            <tr>
-                <th>등록일</th>
-                <td>@Model.CreatedAt</td>
-            </tr>
-        </table>
+                }
+                else
+                {
+                    <text>-</text>
+                }
+            </div>
+        </div>
+
+        <!-- 상태 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">상태</div>
+            <div class="col-12 col-md-10 p-2">
+                @if (Model.IsNotice)
+                {
+                    <span class="badge bg-warning text-dark me-1">공지</span>
+                }
+                @if (Model.IsSecret)
+                {
+                    <span class="badge bg-dark me-1">비밀</span>
+                }
+                @if (Model.IsAnonymous)
+                {
+                    <span class="badge bg-info me-1">익명</span>
+                }
+                @if (Model.IsSpeaker)
+                {
+                    <span class="badge bg-primary me-1">스피커</span>
+                }
+                @if (Model.IsDeleted)
+                {
+                    <span class="badge bg-danger me-1">삭제됨</span>
+                }
+                @if (!Model.IsNotice && !Model.IsSecret && !Model.IsAnonymous && !Model.IsSpeaker && !Model.IsDeleted)
+                {
+                    <text>일반</text>
+                }
+            </div>
+        </div>
+
+        <!-- 조회 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">조회 / 공감 / 비공감</div>
+            <div class="col-12 col-md-10 p-2">@Model.Views / @Model.Likes / @Model.Dislikes</div>
+        </div>
+
+        <!-- 댓글 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">댓글</div>
+            <div class="col-12 col-md-10 p-2">@Model.CommentCount</div>
+        </div>
+
+        <!-- 수정일 -->
+        <div class="row g-0 border-bottom">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">수정일</div>
+            <div class="col-12 col-md-10 p-2">@(Model.UpdatedAt ?? "-")</div>
+        </div>
+
+        <!-- 등록일 (마지막 라인: border-bottom 제거) -->
+        <div class="row g-0">
+            <div class="col-12 col-md-2 bg-body-tertiary fw-semibold p-2">등록일</div>
+            <div class="col-12 col-md-10 p-2">@Model.CreatedAt</div>
+        </div>
     </div>
 
-    <div class="d-grid gap-2 text-center d-md-block">
-        <a href="/Forum/Posts/List/Edit/@(Model.ID)@(Model.QueryString)" class="btn btn-info">수정</a>
+    <div class="d-grid gap-2 text-center d-md-block mt-3">
+        <a href="/Forum/Posts/List/Edit/@(Model.ID)@(Model.QueryString)" class="btn btn-info text-white">수정</a>
         <a href="@(string.IsNullOrWhiteSpace(Model.ReturnUrl) ? "/Forum/Posts/List" : Model.ReturnUrl)" class="btn btn-secondary">목록</a>
     </div>
     <br />

+ 2 - 0
Admin/Pages/Forum/Posts/List/View.cshtml.cs

@@ -22,6 +22,7 @@ namespace Admin.Pages.Forum.Posts.List
         public bool IsAnonymous { get; set; }
         public bool IsSpeaker { get; set; }
         public bool IsDeleted { get; set; }
+        public bool IsQnA { get; set; }
         public int Views { get; set; }
         public int Likes { get; set; }
         public int Dislikes { get; set; }
@@ -60,6 +61,7 @@ namespace Admin.Pages.Forum.Posts.List
             IsAnonymous = result.IsAnonymous;
             IsSpeaker = result.IsSpeaker;
             IsDeleted = result.IsDeleted;
+            IsQnA = result.IsQnA;
             Views = result.Views;
             Likes = result.Likes;
             Dislikes = result.Dislikes;

+ 20 - 7
Admin/Pages/Forum/Posts/List/Write.cshtml

@@ -34,7 +34,7 @@
 
         <!-- 말머리 -->
         <div class="row mb-2" id="prefixRow" hidden>
-            <label class="col-sm-2 col-form-label">말머리</label>
+            <label class="col-sm-2 col-form-label" id="prefixLabel">말머리</label>
             <div class="col-sm-10">
                 <select asp-for="Input.BoardPrefixID" class="form-select w-auto" id="boardPrefixSelect">
                     <option value="">- 선택 -</option>
@@ -64,7 +64,14 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">대표 이미지</label>
             <div class="col-sm-10">
-                <input type="file" asp-for="Input.ThumbnailFile" class="form-control" accept="image/*" />
+                <div id="thumbPrev" hidden>
+                    <img class="img-fluid img-thumbnail" alt="대표 이미지 미리보기" style="max-width: 300px;" /><br />
+                    <button type="button" class="btn btn-sm btn-danger mt-2 mb-2 btn-remove-preview">삭제</button>
+                </div>
+                <input type="file" id="Input_ThumbnailFile" asp-for="Input.ThumbnailFile" class="form-control" />
+                <span class="form-text text-muted">
+                    지원 확장자: <code>.jpg</code>, <code>.jpeg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>, <code>.bmp</code>
+                </span>
             </div>
         </div>
 
@@ -72,8 +79,8 @@
         <div class="row mb-2">
             <label class="col-sm-2 col-form-label">첨부파일</label>
             <div class="col-sm-10">
-                <input type="file" asp-for="Input.Files" class="form-control" multiple accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar,.7z,.hwp,.hwpx,.csv" />
-                <span class="form-text text-muted">여러 파일을 선택할 수 있습니다.</span>
+                <input type="file" asp-for="Input.Files" class="form-control" multiple />
+                <span class="form-text text-muted">허용 확장자: <code>.jpg</code>, <code>.jpeg</code>, <code>.png</code>, <code>.gif</code>, <code>.webp</code>, <code>.bmp</code>, <code>.pdf</code>, <code>.doc</code>, <code>.docx</code>, <code>.xls</code>, <code>.xlsx</code>, <code>.ppt</code>, <code>.pptx</code>, <code>.txt</code>, <code>.zip</code>, <code>.rar</code>, <code>.7z</code>, <code>.hwp</code>, <code>.hwpx</code>, <code>.csv</code></span>
             </div>
         </div>
 
@@ -118,6 +125,7 @@
 
 @section Scripts {
     <script>
+        setupImagePreview("Input_ThumbnailFile", "thumbPrev");
         // 게시판 변경 시 말머리 동적 로드
         document.getElementById("Input_BoardID")?.addEventListener("change", async function () {
             const boardID = this.value;
@@ -133,10 +141,15 @@
 
             try {
                 const res = await fetch(`/Forum/Posts/List/Write?handler=Prefixes&boardID=${boardID}`);
-                const items = await res.json();
+                const data = await res.json();
+                const prefixLabel = document.getElementById("prefixLabel");
+
+                if (prefixLabel) {
+                    prefixLabel.textContent = data.isQnA ? "문의 유형" : "말머리";
+                }
 
-                if (items.length > 0) {
-                    items.forEach(item => {
+                if (data.items.length > 0) {
+                    data.items.forEach(item => {
                         const opt = document.createElement("option");
                         opt.value = item.id;
                         opt.textContent = item.name;

+ 6 - 1
Admin/Pages/Forum/Posts/List/Write.cshtml.cs

@@ -3,6 +3,7 @@ using MediatR;
 using Microsoft.AspNetCore.Mvc;
 using Microsoft.AspNetCore.Mvc.RazorPages;
 using Microsoft.AspNetCore.Mvc.Rendering;
+using Domain.Entities.Forum.ValueObject;
 using System.ComponentModel;
 using System.ComponentModel.DataAnnotations;
 
@@ -70,7 +71,11 @@ namespace Admin.Pages.Forum.Posts.List
         {
             var result = await mediator.Send(new GetBoardPrefixes.Query(boardID), ct);
             var items = result.List.Where(c => c.IsActive).Select(c => new { id = c.ID, name = c.Name });
-            return new JsonResult(items);
+
+            var meta = await mediator.Send(new GetBoardMeta.Query(boardID), ct);
+            var isQnA = meta.List.Layout == BoardLayout.QnA;
+
+            return new JsonResult(new { items, isQnA });
         }
 
         public async Task<IActionResult> OnPostAsync(CancellationToken ct)

+ 1 - 0
Admin/using.cs

@@ -122,6 +122,7 @@ global using GetPost = Application.Features.Admin.Forum.Post.Get;
 global using CreatePost = Application.Features.Admin.Forum.Post.Create;
 global using UpdatePost = Application.Features.Admin.Forum.Post.Update;
 global using DeletePost = Application.Features.Admin.Forum.Post.Delete;
+global using DeletePostThumbnail = Application.Features.Admin.Forum.Post.DeleteThumbnail;
 
 // 게시판 설정
 global using GetBoardMeta = Application.Features.Admin.Forum.BoardMeta.Get;

TEMPAT SAMPAH
Admin/wwwroot/editors/post/39/b6d0f5bbc3494fdfa6396a79b948a949.jpg


+ 5 - 1
Admin/wwwroot/js/func.js

@@ -78,7 +78,11 @@ $.validator.setDefaults({ // Bootstrap Required.
     invalidHandler: function (event, validator) {
 
     },
-    submitHandler: function (form) {
+    submitHandler: function (form, event) {
+        const submitter = event?.originalEvent?.submitter;
+        if (submitter?.getAttribute('formaction')) {
+            form.action = submitter.getAttribute('formaction');
+        }
         form.submit();
     },
     showErrors: function (errorMap, errorList) {

TEMPAT SAMPAH
Admin/wwwroot/uploads/post/23/788dd7c44a3c46388477d39b3c961d7c.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/38/62386546b4b346a185ce41ee05fcf257.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/39/9ee22bd7de7441e184674fd83afb3053.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/39/a8b1d1256b004204b6c630f24a737536.png


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/40/21eca82bc422402fb216643f9ee13865.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/40/92d42f3d3917456c84b3550d684a665f.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/41/7af37c6464ac4522ae0a56113d1cb28f.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/5/0af0d89a44af4bd794dffa7ccb0f65a3.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/5/54122d3cfc8641aca7fbfa91369ddb7e.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/5/55e00bda0c2c4f7da47d8ccf98e2776c.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/5/8c4179fc90254af28ed52ef70670fdfc.jpg


TEMPAT SAMPAH
Admin/wwwroot/uploads/post/5/b87bb812cd57431e9a6ccea4fd3c2e80.jpg


+ 1 - 0
Application/Application.csproj

@@ -18,6 +18,7 @@
   </ItemGroup>
 
   <ItemGroup>
+    <PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
     <PackageReference Include="MediatR" Version="14.0.0" />
     <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
   </ItemGroup>

+ 29 - 6
Application/Features/Admin/Forum/Comment/Search/Handler.cs

@@ -1,23 +1,29 @@
+using System.Net;
+using System.Text.RegularExpressions;
 using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Admin.Forum.Comment.Search;
 
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+public sealed partial class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {
+    [GeneratedRegex("<[^>]*>")]
+    private static partial Regex HtmlTagRegex();
+
     public async Task<Response> Handle(Query request, CancellationToken ct)
     {
-        var query = db.Comment.AsNoTracking()
-            .Include(c => c.Board)
-            .Include(c => c.Post)
-            .AsQueryable();
+        var query = db.Comment.AsNoTracking().Include(c => c.Board).Include(c => c.Post).AsQueryable();
 
         if (request.BoardID.HasValue)
+        {
             query = query.Where(c => c.BoardID == request.BoardID.Value);
+        }
 
         if (request.PostID.HasValue)
+        {
             query = query.Where(c => c.PostID == request.PostID.Value);
+        }
 
         if (!string.IsNullOrWhiteSpace(request.Keyword))
         {
@@ -31,13 +37,19 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
         }
 
         if (!string.IsNullOrWhiteSpace(request.StartAt) && DateTime.TryParse(request.StartAt, out var startDate))
+        {
             query = query.Where(c => c.CreatedAt >= startDate);
+        }
 
         if (!string.IsNullOrWhiteSpace(request.EndAt) && DateTime.TryParse(request.EndAt, out var endDate))
+        {
             query = query.Where(c => c.CreatedAt <= endDate.AddDays(1));
+        }
 
         if (request.IsDeleted == true)
+        {
             query = query.Where(c => c.IsDeleted);
+        }
 
         query = query.OrderByDescending(c => c.ID);
 
@@ -79,7 +91,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 c.BoardName,
                 c.PostID,
                 c.PostSubject,
-                c.Content,
+                StripHtml(c.Content),
                 c.Name,
                 c.SID,
                 c.IsReply,
@@ -94,4 +106,15 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             ))]
         );
     }
+
+    private static string StripHtml(string html)
+    {
+        if (string.IsNullOrEmpty(html))
+        {
+            return html;
+        }
+
+        var decoded = WebUtility.HtmlDecode(html);
+        return HtmlTagRegex().Replace(decoded, "");
+    }
 }

+ 55 - 2
Application/Features/Admin/Forum/Post/Create/Handler.cs

@@ -2,6 +2,7 @@ using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
 using SharedKernel.Storage;
 using Microsoft.EntityFrameworkCore;
+using System.Text.RegularExpressions;
 
 namespace Application.Features.Admin.Forum.Post.Create;
 
@@ -9,6 +10,7 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
 {
     private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
     private static readonly string[] AllowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".zip", ".rar", ".7z", ".hwp", ".hwpx", ".csv"];
+    private static readonly Regex ImgBase64Regex = new(@"<img\b[^>]*?\bsrc\s*=\s*""(?<src>data:image/(?<ext>[a-zA-Z0-9.+-]+);base64,(?<data>[a-zA-Z0-9+/=\s]+))""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
 
     public async Task Handle(Command request, CancellationToken ct)
     {
@@ -35,11 +37,62 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
 
         var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
 
+        // 에디터 이미지 처리 (base64 → 파일 + PostImage 레코드)
+        var content = post.Content;
+        var matches = ImgBase64Regex.Matches(content);
+
+        if (matches.Count > 0)
+        {
+            byte imageCount = 0;
+            var firstImageUrl = (string?)null;
+
+            foreach (Match match in matches)
+            {
+                var ext = FileUtils.NormalizeExtension(match.Groups["ext"].Value);
+                var bytes = Convert.FromBase64String(match.Groups["data"].Value);
+                var result = await fileStorage.SaveBytesAsync(bytes, ext, uploadPath, ct);
+
+                if (result is not null)
+                {
+                    content = content.Replace(match.Groups["src"].Value, result.Url);
+                    firstImageUrl ??= result.Url;
+
+                    await db.PostImage.AddAsync(new Domain.Entities.Forum.Posts.PostImage
+                    {
+                        BoardID = request.BoardID,
+                        PostID = post.ID,
+                        FileName = result.FileName,
+                        HashedName = result.FileName,
+                        Path = uploadPath.ToRelativePath(),
+                        Url = result.Url,
+                        Extension = ext,
+                        ContentType = $"image/{match.Groups["ext"].Value}",
+                        Size = result.Size,
+                        Width = result.Width,
+                        Height = result.Height
+                    }, ct);
+
+                    imageCount++;
+                }
+            }
+
+            post.Content = content;
+            post.Images = imageCount;
+
+            if (string.IsNullOrEmpty(post.Thumbnail) && firstImageUrl is not null)
+            {
+                post.Thumbnail = firstImageUrl;
+            }
+        }
+
         // 썸네일 처리
         if (request.ThumbnailFile is not null)
         {
             var result = await fileStorage.SaveFileAsync(request.ThumbnailFile, uploadPath, AllowedImageExtensions, ct);
-            post.Thumbnail = result?.Url;
+            if (result is not null)
+            {
+                post.Thumbnail = result.Url;
+            }
         }
 
         // 파일 처리
@@ -139,4 +192,4 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
             await db.SaveChangesAsync(ct);
         }
     }
-}
+}

+ 5 - 0
Application/Features/Admin/Forum/Post/DeleteThumbnail/Command.cs

@@ -0,0 +1,5 @@
+using Application.Abstractions.Messaging;
+
+namespace Application.Features.Admin.Forum.Post.DeleteThumbnail;
+
+public sealed record Command(int ID) : ICommand;

+ 25 - 0
Application/Features/Admin/Forum/Post/DeleteThumbnail/Handler.cs

@@ -0,0 +1,25 @@
+using Application.Abstractions.Messaging;
+using Application.Abstractions.Data;
+using SharedKernel.Storage;
+using Microsoft.EntityFrameworkCore;
+
+namespace Application.Features.Admin.Forum.Post.DeleteThumbnail;
+
+public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : ICommandHandler<Command>
+{
+    public async Task Handle(Command request, CancellationToken ct)
+    {
+        var post = await db.Post.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        if (post is null)
+        {
+            throw new KeyNotFoundException("게시글을 찾을 수 없습니다.");
+        }
+
+        if (!string.IsNullOrEmpty(post.Thumbnail))
+        {
+            fileStorage.DeleteByUrl(post.Thumbnail);
+            post.Thumbnail = null;
+            await db.SaveChangesAsync(ct);
+        }
+    }
+}

+ 3 - 1
Application/Features/Admin/Forum/Post/Get/Handler.cs

@@ -1,5 +1,6 @@
 using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
+using Domain.Entities.Forum.ValueObject;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Admin.Forum.Post.Get;
@@ -8,7 +9,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {
     public async Task<Response> Handle(Query request, CancellationToken ct)
     {
-        var item = await db.Post.AsNoTracking().Include(c => c.Board).Include(c => c.BoardPrefix).Include(c => c.PostFile).Include(c => c.PostImage).Include(c => c.PostTag).ThenInclude(c => c.Tag).FirstOrDefaultAsync(x => x.ID == request.ID, ct);
+        var item = await db.Post.AsNoTracking().Include(c => c.Board).ThenInclude(c => c.BoardMeta).Include(c => c.BoardPrefix).Include(c => c.PostFile).Include(c => c.PostImage).Include(c => c.PostTag).ThenInclude(c => c.Tag).FirstOrDefaultAsync(x => x.ID == request.ID, ct);
         if (item is null)
         {
             throw new KeyNotFoundException("게시글을 찾을 수 없습니다.");
@@ -48,6 +49,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             [..item.PostTag.Select(t => new Response.TagItem(
                 t.TagID, t.Tag.Name, t.Tag.Slug
             ))],
+            item.Board.BoardMeta.List.Layout == BoardLayout.QnA,
             item.UpdatedAt,
             item.CreatedAt
         );

+ 1 - 0
Application/Features/Admin/Forum/Post/Get/Response.cs

@@ -28,6 +28,7 @@ public sealed record Response(
     List<Response.FileItem> Files,
     List<Response.ImageItem> Images,
     List<Response.TagItem> Tags,
+    bool IsQnA,
     DateTime? UpdatedAt,
     DateTime CreatedAt
 )

+ 55 - 5
Application/Features/Admin/Forum/Post/Update/Handler.cs

@@ -2,6 +2,7 @@ using Application.Abstractions.Messaging;
 using Application.Abstractions.Data;
 using SharedKernel.Storage;
 using Microsoft.EntityFrameworkCore;
+using System.Text.RegularExpressions;
 
 namespace Application.Features.Admin.Forum.Post.Update;
 
@@ -9,6 +10,7 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
 {
     private static readonly string[] AllowedImageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"];
     private static readonly string[] AllowedFileExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".zip", ".rar", ".7z", ".hwp", ".hwpx", ".csv"];
+    private static readonly Regex ImgBase64Regex = new(@"<img\b[^>]*?\bsrc\s*=\s*""(?<src>data:image/(?<ext>[a-zA-Z0-9.+-]+);base64,(?<data>[a-zA-Z0-9+/=\s]+))""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
 
     public async Task Handle(Command request, CancellationToken ct)
     {
@@ -26,11 +28,61 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         post.IsAnonymous = request.IsAnonymous;
         post.UpdatedAt = DateTime.UtcNow;
 
+        var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
+
+        // 에디터 이미지 처리 (base64 → 파일 + PostImage 레코드)
+        var content = post.Content;
+        var matches = ImgBase64Regex.Matches(content);
+
+        if (matches.Count > 0)
+        {
+            byte newImageCount = 0;
+            var firstImageUrl = (string?)null;
+
+            foreach (Match match in matches)
+            {
+                var ext = FileUtils.NormalizeExtension(match.Groups["ext"].Value);
+                var bytes = Convert.FromBase64String(match.Groups["data"].Value);
+                var result = await fileStorage.SaveBytesAsync(bytes, ext, uploadPath, ct);
+
+                if (result is not null)
+                {
+                    content = content.Replace(match.Groups["src"].Value, result.Url);
+                    firstImageUrl ??= result.Url;
+
+                    await db.PostImage.AddAsync(new Domain.Entities.Forum.Posts.PostImage
+                    {
+                        BoardID = post.BoardID,
+                        PostID = post.ID,
+                        FileName = result.FileName,
+                        HashedName = result.FileName,
+                        Path = uploadPath.ToRelativePath(),
+                        Url = result.Url,
+                        Extension = ext,
+                        ContentType = $"image/{match.Groups["ext"].Value}",
+                        Size = result.Size,
+                        Width = result.Width,
+                        Height = result.Height
+                    }, ct);
+
+                    newImageCount++;
+                }
+            }
+
+            post.Content = content;
+
+            var existingImageCount = await db.PostImage.CountAsync(x => x.PostID == post.ID && !x.IsDisabled, ct);
+            post.Images = (byte)Math.Min(existingImageCount + newImageCount, byte.MaxValue);
+
+            if (string.IsNullOrEmpty(post.Thumbnail) && firstImageUrl is not null)
+            {
+                post.Thumbnail = firstImageUrl;
+            }
+        }
+
         // 썸네일 처리
         if (request.ThumbnailFile is not null)
         {
-            var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
-
             if (!string.IsNullOrEmpty(post.Thumbnail))
             {
                 fileStorage.DeleteByUrl(post.Thumbnail);
@@ -43,8 +95,6 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
         // 파일 처리 (새 파일 추가)
         if (request.Files is { Count: > 0 })
         {
-            var uploadPath = new FileStoragePath(UploadTarget.Upload, UploadFolder.Post, post.ID);
-
             foreach (var file in request.Files)
             {
                 var result = await fileStorage.SaveFileAsync(file, uploadPath, AllowedFileExtensions, ct);
@@ -133,4 +183,4 @@ public sealed class Handler(IAppDbContext db, IFileStorage fileStorage) : IComma
 
         await db.SaveChangesAsync(ct);
     }
-}
+}

+ 18 - 2
Application/Features/Api/Forum/Comment/Search/Handler.cs

@@ -1,11 +1,16 @@
+using System.Net;
+using System.Text.RegularExpressions;
 using Application.Abstractions.Data;
 using Application.Abstractions.Messaging;
 using Microsoft.EntityFrameworkCore;
 
 namespace Application.Features.Api.Forum.Comment.Search;
 
-public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
+public sealed partial class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
 {
+    [GeneratedRegex("<[^>]*>")]
+    private static partial Regex HtmlTagRegex();
+
     public async Task<Response> Handle(Query request, CancellationToken ct)
     {
         var query = db.Comment.AsNoTracking()
@@ -79,7 +84,7 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
                 c.BoardName,
                 c.PostID,
                 c.PostSubject,
-                c.Content,
+                StripHtml(c.Content),
                 c.Name,
                 c.SID,
                 c.IsReply,
@@ -94,4 +99,15 @@ public sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Response>
             ))]
         );
     }
+
+    private static string StripHtml(string html)
+    {
+        if (string.IsNullOrEmpty(html))
+        {
+            return html;
+        }
+
+        var decoded = WebUtility.HtmlDecode(html);
+        return HtmlTagRegex().Replace(decoded, "");
+    }
 }

+ 10 - 0
Application/Features/Api/MyPage/GetPosts/Handler.cs

@@ -24,9 +24,14 @@ internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Re
                 p.BoardID,
                 BoardName = p.Board.Name,
                 p.Subject,
+                p.IsSecret,
+                p.IsReply,
                 p.Views,
                 p.Likes,
                 p.Comments,
+                p.Images,
+                p.Medias,
+                p.Files,
                 p.CreatedAt
             })
             .ToListAsync(ct);
@@ -40,9 +45,14 @@ internal sealed class Handler(IAppDbContext db) : IQueryHandler<Query, Result<Re
                 p.BoardID,
                 p.BoardName,
                 p.Subject,
+                p.IsSecret,
+                p.IsReply,
                 p.Views,
                 p.Likes,
                 p.Comments,
+                p.Images,
+                p.Medias,
+                p.Files,
                 p.CreatedAt
             ))],
             total,

+ 5 - 0
Application/Features/Api/MyPage/GetPosts/Response.cs

@@ -13,8 +13,13 @@ public sealed record PostItem(
     int BoardID,
     string BoardName,
     string Subject,
+    bool IsSecret,
+    bool IsReply,
     int Views,
     int Likes,
     int Comments,
+    byte Images,
+    byte Medias,
+    byte Files,
     DateTime CreatedAt
 );

+ 5 - 3
Infrastructure/Storage/LocalFileStorage.cs

@@ -37,15 +37,17 @@ namespace Infrastructure.Storage
             var fileName = $"{Guid.NewGuid():N}{ext}";
             var fullPath = Path.Combine(dir, fileName);
 
-            await using var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
-            await file.CopyToAsync(fs, ct);
+            await using (var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+            {
+                await file.CopyToAsync(fs, ct);
+            }
 
             var url = "/" + $"{rel}/{fileName}".Replace("\\", "/");
 
             short? width = null, height = null;
             if (IsImageExtension(ext))
             {
-                using var readStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
+                using var readStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Read);
                 var dim = ImageDimensionHelper.GetDimensions(readStream);
                 if (dim.HasValue)
                 {

+ 2 - 2
SharedKernel/Constants/Menus.cs

@@ -112,9 +112,9 @@ namespace SharedKernel.Constants
                         new Menu
                         {
                             Id = 210,
-                            Name = "캐시 관리",
+                            Name = "Cache 관리",
                             Path = "/Server/Cache",
-                            Roles = [ "Admin", "환경 - 캐시 관리" ]
+                            Roles = [ "Admin", "환경 - Cache 관리" ]
                         }
                     }
                 },

+ 2 - 1
global.json

@@ -1,5 +1,6 @@
 {
   "sdk": {
-    "version": "10.0.103"
+    "version": "10.0.103",
+    "rollForward": "latestFeature"
   }
 }