KIM-JINO5 преди 4 месеца
родител
ревизия
f0eefd8a03
променени са 100 файла, в които са добавени 10414 реда и са изтрити 177 реда
  1. 2 0
      Admin/Admin.csproj
  2. 9 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml
  3. 9 0
      Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml
  4. 9 0
      Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml
  5. 6 6
      Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml
  6. 2 2
      Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml
  7. 11 6
      Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml
  8. 17 1
      Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs
  9. 27 3
      Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml
  10. 2 4
      Admin/Areas/Identity/Pages/Account/Manage/_Layout.cshtml
  11. 5 5
      Admin/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml
  12. 1 1
      Admin/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml
  13. 6 2
      Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml
  14. 3 3
      Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml
  15. 9 0
      Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml
  16. 13 20
      Admin/Constants/Menus.cs
  17. 12 0
      Admin/Extensions/NavActiveExtension.cs
  18. 182 0
      Admin/Pages/Config/Basic.cshtml
  19. 56 0
      Admin/Pages/Config/Basic.cshtml.cs
  20. 161 0
      Admin/Pages/Config/Company.cshtml
  21. 72 0
      Admin/Pages/Config/Company.cshtml.cs
  22. 91 0
      Admin/Pages/Config/External.cshtml
  23. 56 0
      Admin/Pages/Config/External.cshtml.cs
  24. 213 0
      Admin/Pages/Config/Images.cshtml
  25. 70 0
      Admin/Pages/Config/Images.cshtml.cs
  26. 0 27
      Admin/Pages/Config/Index.cshtml
  27. 0 73
      Admin/Pages/Config/Index.cshtml.cs
  28. 97 0
      Admin/Pages/Config/Meta.cshtml
  29. 58 0
      Admin/Pages/Config/Meta.cshtml.cs
  30. 263 0
      Admin/Pages/Config/Register.cshtml
  31. 56 0
      Admin/Pages/Config/Register.cshtml.cs
  32. 129 0
      Admin/Pages/Config/Template/Email.cshtml
  33. 56 0
      Admin/Pages/Config/Template/Email.cshtml.cs
  34. 70 0
      Admin/Pages/Config/Test/Email.cshtml
  35. 87 0
      Admin/Pages/Config/Test/Email.cshtml.cs
  36. 40 0
      Admin/Pages/Server/Env.cshtml
  37. 21 0
      Admin/Pages/Server/Env.cshtml.cs
  38. 246 0
      Admin/Pages/Server/Info.cshtml
  39. 91 0
      Admin/Pages/Server/Info.cshtml.cs
  40. 7 0
      Admin/Pages/Shared/Components/Layout/Default.cshtml
  41. 8 0
      Admin/Pages/Shared/Config/_navTabs.cshtml
  42. 13 6
      Admin/Pages/Shared/Layout/LayoutDataProvider.cs
  43. 241 0
      Admin/Pages/Shared/_Editor.cshtml
  44. 9 6
      Admin/Pages/Shared/_Layout.cshtml
  45. 62 0
      Admin/Pages/Shared/_MenuItem.cshtml
  46. 41 0
      Admin/Pages/Shared/_StatusMessage.cshtml
  47. 2 1
      Admin/Pages/Shared/_Sub.cshtml
  48. 1 0
      Admin/Pages/_ViewImports.cshtml
  49. 2 2
      Admin/Program.cs
  50. 13 6
      Admin/appsettings.json
  51. 1 1
      Admin/wwwroot/css/account.css
  52. 1 1
      Admin/wwwroot/css/admin.css
  53. 1 1
      Admin/wwwroot/css/site.css
  54. 4 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5-content.css
  55. 4 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5-editor.css
  56. 4 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5.css
  57. 0 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5.css.map
  58. 4 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5.js
  59. 0 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5.js.map
  60. 10 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5.umd.js
  61. 0 0
      Admin/wwwroot/lib/ckeditor/browser/ckeditor5.umd.js.map
  62. 493 0
      Admin/wwwroot/lib/ckeditor/ckeditor5-content.css
  63. 2802 0
      Admin/wwwroot/lib/ckeditor/ckeditor5-editor.css
  64. 4172 0
      Admin/wwwroot/lib/ckeditor/ckeditor5.css
  65. 0 0
      Admin/wwwroot/lib/ckeditor/ckeditor5.css.map
  66. 63 0
      Admin/wwwroot/lib/ckeditor/ckeditor5.js
  67. 0 0
      Admin/wwwroot/lib/ckeditor/ckeditor5.js.map
  68. 8 0
      Admin/wwwroot/lib/ckeditor/translations/af.d.ts
  69. 4 0
      Admin/wwwroot/lib/ckeditor/translations/af.js
  70. 6 0
      Admin/wwwroot/lib/ckeditor/translations/af.umd.js
  71. 8 0
      Admin/wwwroot/lib/ckeditor/translations/ar.d.ts
  72. 4 0
      Admin/wwwroot/lib/ckeditor/translations/ar.js
  73. 6 0
      Admin/wwwroot/lib/ckeditor/translations/ar.umd.js
  74. 8 0
      Admin/wwwroot/lib/ckeditor/translations/ast.d.ts
  75. 4 0
      Admin/wwwroot/lib/ckeditor/translations/ast.js
  76. 6 0
      Admin/wwwroot/lib/ckeditor/translations/ast.umd.js
  77. 8 0
      Admin/wwwroot/lib/ckeditor/translations/az.d.ts
  78. 4 0
      Admin/wwwroot/lib/ckeditor/translations/az.js
  79. 6 0
      Admin/wwwroot/lib/ckeditor/translations/az.umd.js
  80. 8 0
      Admin/wwwroot/lib/ckeditor/translations/bg.d.ts
  81. 4 0
      Admin/wwwroot/lib/ckeditor/translations/bg.js
  82. 6 0
      Admin/wwwroot/lib/ckeditor/translations/bg.umd.js
  83. 8 0
      Admin/wwwroot/lib/ckeditor/translations/bn.d.ts
  84. 4 0
      Admin/wwwroot/lib/ckeditor/translations/bn.js
  85. 6 0
      Admin/wwwroot/lib/ckeditor/translations/bn.umd.js
  86. 8 0
      Admin/wwwroot/lib/ckeditor/translations/bs.d.ts
  87. 4 0
      Admin/wwwroot/lib/ckeditor/translations/bs.js
  88. 6 0
      Admin/wwwroot/lib/ckeditor/translations/bs.umd.js
  89. 8 0
      Admin/wwwroot/lib/ckeditor/translations/ca.d.ts
  90. 4 0
      Admin/wwwroot/lib/ckeditor/translations/ca.js
  91. 6 0
      Admin/wwwroot/lib/ckeditor/translations/ca.umd.js
  92. 8 0
      Admin/wwwroot/lib/ckeditor/translations/cs.d.ts
  93. 4 0
      Admin/wwwroot/lib/ckeditor/translations/cs.js
  94. 6 0
      Admin/wwwroot/lib/ckeditor/translations/cs.umd.js
  95. 8 0
      Admin/wwwroot/lib/ckeditor/translations/da.d.ts
  96. 4 0
      Admin/wwwroot/lib/ckeditor/translations/da.js
  97. 6 0
      Admin/wwwroot/lib/ckeditor/translations/da.umd.js
  98. 8 0
      Admin/wwwroot/lib/ckeditor/translations/de-ch.d.ts
  99. 4 0
      Admin/wwwroot/lib/ckeditor/translations/de-ch.js
  100. 6 0
      Admin/wwwroot/lib/ckeditor/translations/de-ch.umd.js

+ 2 - 0
Admin/Admin.csproj

@@ -23,6 +23,7 @@
       <PrivateAssets>all</PrivateAssets>
     </PackageReference>
     <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
+    <PackageReference Include="System.Management" Version="10.0.2" />
   </ItemGroup>
 
   <ItemGroup>
@@ -32,6 +33,7 @@
 
   <ItemGroup>
     <Folder Include="Middleware\" />
+    <Folder Include="wwwroot\uploads\basic\" />
   </ItemGroup>
 
 </Project>

+ 9 - 0
Admin/Areas/Identity/Pages/Account/ConfirmEmail.cshtml

@@ -4,5 +4,14 @@
     ViewData["Title"] = "Confirm email";
 }
 
+@*
 <h1>@ViewData["Title"]</h1>
 <partial name="_StatusMessage" model="Model.StatusMessage" />
+*@
+
+<script>
+    setTimeout(() => {
+        alert("이메일이 정상적으로 인증되었습니다.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 9 - 0
Admin/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml

@@ -4,5 +4,14 @@
     ViewData["Title"] = "Confirm email change";
 }
 
+@*
 <h1>@ViewData["Title"]</h1>
 <partial name="_StatusMessage" model="Model.StatusMessage" />
+*@
+
+<script>
+    setTimeout(() => {
+        alert("이메일이 정상적으로 변경되었습니다.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 9 - 0
Admin/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml

@@ -4,7 +4,16 @@
     ViewData["Title"] = "Forgot password confirmation";
 }
 
+@*
 <h1>@ViewData["Title"]</h1>
 <p>
     Please check your email to reset your password.
 </p>
+*@
+
+<script>
+    setTimeout(() => {
+        alert("받은 메일함을 확인하고 비밀번호를 변경하세요.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 6 - 6
Admin/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml

@@ -1,11 +1,11 @@
 @page
 @model ChangePasswordModel
 @{
-    ViewData["Title"] = "Change password";
+    ViewData["Title"] = "비밀번호 변경";
     ViewData["ActivePage"] = ManageNavPages.ChangePassword;
 }
 
-<h3>@ViewData["Title"]</h3>
+<h3 class="pb-2">@ViewData["Title"]</h3>
 <partial name="_StatusMessage" for="StatusMessage" />
 <div class="row">
     <div class="col-md-6">
@@ -13,20 +13,20 @@
             <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
             <div class="form-floating mb-3">
                 <input asp-for="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password." />
-                <label asp-for="Input.OldPassword" class="form-label"></label>
+                <label asp-for="Input.OldPassword" class="form-label">현재 비밀번호</label>
                 <span asp-validation-for="Input.OldPassword" class="text-danger"></span>
             </div>
             <div class="form-floating mb-3">
                 <input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password." />
-                <label asp-for="Input.NewPassword" class="form-label"></label>
+                <label asp-for="Input.NewPassword" class="form-label">새 비밀번호</label>
                 <span asp-validation-for="Input.NewPassword" class="text-danger"></span>
             </div>
             <div class="form-floating mb-3">
                 <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
-                <label asp-for="Input.ConfirmPassword" class="form-label"></label>
+                <label asp-for="Input.ConfirmPassword" class="form-label">새 비밀번호 재입력</label>
                 <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
             </div>
-            <button type="submit" class="w-100 btn btn-lg btn-primary">Update password</button>
+            <button type="submit" class="w-100 btn btn-lg btn-primary">변경하기</button>
         </form>
     </div>
 </div>

+ 2 - 2
Admin/Areas/Identity/Pages/Account/Manage/Email.cshtml

@@ -1,11 +1,11 @@
 @page
 @model EmailModel
 @{
-    ViewData["Title"] = "Manage Email";
+    ViewData["Title"] = "이메일 변경";
     ViewData["ActivePage"] = ManageNavPages.Email;
 }
 
-<h3>@ViewData["Title"]</h3>
+<h3 class="pb-2">@ViewData["Title"]</h3>
 <partial name="_StatusMessage" for="StatusMessage" />
 <div class="row">
     <div class="col-md-6">

+ 11 - 6
Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml

@@ -1,26 +1,31 @@
 @page
 @model IndexModel
 @{
-    ViewData["Title"] = "Profile";
+    ViewData["Title"] = "기본 정보";
     ViewData["ActivePage"] = ManageNavPages.Index;
 }
 
-<h3>@ViewData["Title"]</h3>
+<h3 class="pb-2">@ViewData["Title"]</h3>
 <partial name="_StatusMessage" for="StatusMessage" />
 <div class="row">
     <div class="col-md-6">
-        <form id="profile-form" method="post">
+        <form id="profile-form" method="post" class="mt-3">
             <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
             <div class="form-floating mb-3">
                 <input asp-for="Username" class="form-control" placeholder="Please choose your username." disabled />
-                <label asp-for="Username" class="form-label"></label>
+                <label asp-for="Username" class="form-label">이메일</label>
+            </div>
+            <div class="form-floating mb-3">
+                <input asp-for="Input.FullName" class="form-control" placeholder="Please enter your fullname." />
+                <label asp-for="Input.FullName" class="form-label">이름</label>
+                <span asp-validation-for="Input.FullName" class="text-danger"></span>
             </div>
             <div class="form-floating mb-3">
                 <input asp-for="Input.PhoneNumber" class="form-control" placeholder="Please enter your phone number."/>
-                <label asp-for="Input.PhoneNumber" class="form-label"></label>
+                <label asp-for="Input.PhoneNumber" class="form-label">연락처</label>
                 <span asp-validation-for="Input.PhoneNumber" class="text-danger"></span>
             </div>
-            <button id="update-profile-button" type="submit" class="w-100 btn btn-lg btn-primary">Save</button>
+            <button id="update-profile-button" type="submit" class="w-100 btn btn-lg btn-primary">저장</button>
         </form>
     </div>
 </div>

+ 17 - 1
Admin/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs

@@ -59,6 +59,9 @@ namespace Admin.Areas.Identity.Pages.Account.Manage
             [Phone]
             [Display(Name = "Phone number")]
             public string PhoneNumber { get; set; }
+
+            [Display(Name = "Full name")]
+            public string FullName { get; set; }
         }
 
         private async Task LoadAsync(ApplicationUser user)
@@ -70,7 +73,8 @@ namespace Admin.Areas.Identity.Pages.Account.Manage
 
             Input = new InputModel
             {
-                PhoneNumber = phoneNumber
+                PhoneNumber = phoneNumber,
+                FullName = user.FullName
             };
         }
 
@@ -111,6 +115,18 @@ namespace Admin.Areas.Identity.Pages.Account.Manage
                 }
             }
 
+            if (Input.FullName != user.FullName)
+            {
+                user.SetFullName(Input.FullName);
+
+                var updateResult = await _userManager.UpdateAsync(user);
+                if (!updateResult.Succeeded)
+                {
+                    StatusMessage = "Unexpected error when trying to set full name.";
+                    return RedirectToPage();
+                }
+            }
+
             await _signInManager.RefreshSignInAsync(user);
             StatusMessage = "Your profile has been updated";
             return RedirectToPage();

+ 27 - 3
Admin/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml

@@ -1,12 +1,35 @@
 @page
-@model PersonalDataModel
+@* @model PersonalDataModel *@
+@model DeletePersonalDataModel
 @{
-    ViewData["Title"] = "Personal Data";
+    ViewData["Title"] = "계정 삭제";
     ViewData["ActivePage"] = ManageNavPages.PersonalData;
 }
 
-<h3>@ViewData["Title"]</h3>
+<h3 class="pb-2">@ViewData["Title"]</h3>
 
+<div class="alert alert-warning" role="alert">
+    <p class="pb-0 mb-0">
+        <strong>이 데이터를 삭제하면 계정이 영구적으로 삭제되며, 이는 복구할 수 없습니다.</strong>
+    </p>
+</div>
+
+<div>
+    <form id="delete-user" method="post" accept-charset="utf-8" autocomplete="off">
+        <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
+        @if (Model.RequirePassword)
+        {
+            <div class="form-floating mb-3">
+                <input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
+                <label asp-for="Input.Password" class="form-label">비밀번호</label>
+                <span asp-validation-for="Input.Password" class="text-danger"></span>
+            </div>
+        }
+        <button class="btn btn-danger justify-content-center" type="submit">회원탈퇴 처리에 동의합니다.</button>
+    </form>
+</div>
+
+@*
 <div class="row">
     <div class="col-md-6">
         <p>Your account contains personal data that you have given us. This page allows you to download or delete that data.</p>
@@ -21,6 +44,7 @@
         </p>
     </div>
 </div>
+*@
 
 @section Scripts {
     <partial name="_ValidationScriptsPartial" />

+ 2 - 4
Admin/Areas/Identity/Pages/Account/Manage/_Layout.cshtml

@@ -9,10 +9,8 @@
     }
 }
 
-<h1>Manage your account</h1>
-
-<div>
-    <h2>Change your account settings</h2>
+<div class="container">
+    <h5>내 정보</h5>
     <hr />
     <div class="row">
         <div class="col-md-3">

+ 5 - 5
Admin/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml

@@ -4,13 +4,13 @@
     var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
 }
 <ul class="nav nav-pills flex-column">
-    <li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">Profile</a></li>
-    <li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">Email</a></li>
-    <li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">Password</a></li>
+    <li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">기본 정보</a></li>
+    <li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">이메일 변경</a></li>
+    <li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">비밀번호 변경</a></li>
     @if (hasExternalLogins)
     {
         <li id="external-logins" class="nav-item"><a id="external-login" class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External logins</a></li>
     }
-    <li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
-    <li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data" asp-page="./PersonalData">Personal data</a></li>
+    @* <li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li> *@
+    <li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data" asp-page="./PersonalData">계정 삭제</a></li>
 </ul>

+ 1 - 1
Admin/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml

@@ -3,7 +3,7 @@
 @if (!String.IsNullOrEmpty(Model))
 {
     var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
-    <div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
+    <div class="alert alert-@statusMessageClass alert-dismissible m-0" role="alert">
         <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
         @Model
     </div>

+ 6 - 2
Admin/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml

@@ -4,6 +4,7 @@
     ViewData["Title"] = "Register confirmation";
 }
 @*
+
 <h1>@ViewData["Title"]</h1>
 @{
     if (@Model.DisplayConfirmAccountLink)
@@ -21,7 +22,10 @@
     }
 }
 *@
+
 <script>
-    alert("이메일 확인 메일을 전송했습니다. 인증 확인 후 접속이 가능합니다.");
-    window.location.replace("/Identity/Account/Login");
+    setTimeout(() => {
+        alert("이메일 확인 메일을 전송했습니다. 인증 확인 후 접속이 가능합니다.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
 </script>

+ 3 - 3
Admin/Areas/Identity/Pages/Account/ResetPassword.cshtml

@@ -16,17 +16,17 @@
                 <input asp-for="Input.Code" type="hidden" />
                 <div class="form-floating mb-3">
                     <input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
-                    <label asp-for="Input.Email" class="form-label"></label>
+                    <label asp-for="Input.Email" class="form-label">이메일</label>
                     <span asp-validation-for="Input.Email" class="text-danger"></span>
                 </div>
                 <div class="form-floating mb-3">
                     <input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
-                    <label asp-for="Input.Password" class="form-label"></label>
+                    <label asp-for="Input.Password" class="form-label">비밀번호</label>
                     <span asp-validation-for="Input.Password" class="text-danger"></span>
                 </div>
                 <div class="form-floating mb-3">
                     <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
-                    <label asp-for="Input.ConfirmPassword" class="form-label"></label>
+                    <label asp-for="Input.ConfirmPassword" class="form-label">비밀번호 재입력</label>
                     <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
                 </div>
                 <button type="submit" class="w-100 btn btn-lg btn-primary">변경하기</button>

+ 9 - 0
Admin/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml

@@ -4,7 +4,16 @@
     ViewData["Title"] = "Reset password confirmation";
 }
 
+@*
 <h1>@ViewData["Title"]</h1>
 <p>
     Your password has been reset. Please <a asp-page="./Login">click here to log in</a>.
 </p>
+*@
+
+<script>
+    setTimeout(() => {
+        alert("비밀번호가 변경되었습니다. 다시 로그인 해주세요.");
+        window.location.replace("/Identity/Account/Login");
+    }, 0);
+</script>

+ 13 - 20
Admin/Constants/Menus.cs

@@ -49,7 +49,7 @@ namespace Admin.Constants
                         {
                             Id = 201,
                             Name = "서버 정보",
-                            Path = "/Systems/Server",
+                            Path = "/Server/Info",
                             Roles = new List<string> { "Admin", "환경 - 서버 정보" },
                             Policies = null
                         },
@@ -57,66 +57,59 @@ namespace Admin.Constants
                         {
                             Id = 202,
                             Name = "환경변수",
-                            Path = "/Systems/Envs",
+                            Path = "/Server/Env",
                             Roles = new List<string> { "Admin", "환경 - 환경변수" }
                         },
                         new Menu
                         {
                             Id = 203,
                             Name = "기본 설정",
-                            Path = "/Systems/Basic",
+                            Path = "/Config/Basic",
                             Roles = new List<string> { "Admin", "환경 - 기본 설정" }
                         },
                         new Menu
                         {
                             Id = 204,
                             Name = "메타 태그",
-                            Path = "/Systems/Meta",
+                            Path = "/Config/Meta",
                             Roles = new List<string> { "Admin", "환경 - 메타 태그" }
                         },
                         new Menu
                         {
                             Id = 205,
                             Name = "회사 정보",
-                            Path = "/Systems/Company",
+                            Path = "/Config/Company",
                             Roles = new List<string> { "Admin", "환경 - 회사 정보" }
                         },
                         new Menu
                         {
                             Id = 207,
                             Name = "회원 설정",
-                            Path = "/Systems/Register",
+                            Path = "/Config/Register",
                             Roles = new List<string> { "Admin", "환경 - 회원 설정" }
                         },
                         new Menu
                         {
                             Id = 208,
                             Name = "알림 발송 확인",
-                            Path = "/Systems/Test",
+                            Path = "/Config/Test/Email",
                             Roles = new List<string> { "Admin", "환경 - 알림 발송 확인" }
                         },
                         new Menu
                         {
                             Id = 209,
                             Name = "알림 발송 양식",
-                            Path = "/Systems/Template/Email",
+                            Path = "/Config/Template/Email",
                             Roles = new List<string> { "Admin", "환경 - 알림 발송 양식" }
                         },
                         new Menu
                         {
                             Id = 211,
                             Name = "API 설정",
-                            Path = "/Systems/External",
+                            Path = "/Config/External",
                             Roles = new List<string> { "Admin", "환경 - API 설정" }
                         },
                         new Menu
-                        {
-                            Id = 212,
-                            Name = "결제 설정",
-                            Path = "/Systems/Payment",
-                            Roles = new List<string> { "Admin", "환경 - 결제 설정" }
-                        },
-                        new Menu
                         {
                             Id = 213,
                             Name = "관리자",
@@ -446,7 +439,7 @@ namespace Admin.Constants
                     }
                 },
 
-                new Menu
+                /*new Menu
                 {
                     Id = 900,
                     Name = "후원",
@@ -470,9 +463,9 @@ namespace Admin.Constants
                             Roles = new List<string> { "Admin", "후원 - 후원 알림" }
                         }
                     }
-                },
+                },*/
 
-                new Menu
+                /*new Menu
                 {
                     Id = 1000,
                     Name = "정산",
@@ -503,7 +496,7 @@ namespace Admin.Constants
                             Roles = new List<string> { "Admin", "정산 - 회원 정산 내역" }
                         }
                     }
-                }
+                }*/
             };
         }
 

+ 12 - 0
Admin/Extensions/NavActiveExtension.cs

@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Admin.Extensions
+{
+    public static class NavActiveExtensions
+    {
+        public static string IsActive(this IHtmlHelper html, string page)
+        {
+            return string.Equals(html.ViewContext.RouteData.Values["page"]?.ToString(), page, StringComparison.OrdinalIgnoreCase) ? "active" : "";
+        }
+    }
+}

+ 182 - 0
Admin/Pages/Config/Basic.cshtml

@@ -0,0 +1,182 @@
+@page
+@model Admin.Pages.Config.BasicModel
+
+@{
+    ViewData["Title"] = "기본 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+    <partial name="Config/_navTabs" />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SiteName" class="col-sm-2 col-form-label">관리자 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SiteName" class="form-control" maxlength="100" />
+                <span asp-validation-for="Input.Basic.SiteName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SiteURL" class="col-sm-2 col-form-label">사이트 주소</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SiteURL" type="url" class="form-control" maxlength="100" />
+                <div class="form-text text-muted">웹 서비스 주소를 지정합니다.</div>
+                <span asp-validation-for="Input.Basic.SiteURL" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.FromEmail" class="col-sm-2 col-form-label">송수신 이메일</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.FromEmail" type="email" class="form-control" maxlength="100" />
+                <div class="form-text text-muted">관리자가 보내고 받는 용도로 사용하는 메일 주소를 입력합니다.</div>
+                <span asp-validation-for="Input.Basic.FromEmail" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.FromName" class="col-sm-2 col-form-label">송수신자 이름</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.FromName" class="form-control" maxlength="30" />
+                <div class="form-text text-muted">관리자가 보내고 받는 용도로 사용하는 메일의 발송자 이름을 입력합니다.</div>
+                <span asp-validation-for="Input.Basic.FromName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpServer" class="col-sm-2 col-form-label">SMTP Server</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SmtpServer" class="form-control" maxlength="200" />
+                <span asp-validation-for="Input.Basic.SmtpServer" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpPort" class="col-sm-2 col-form-label">SMTP Port</label>
+            <div class="col-sm-10 col-md-3">
+                <input asp-for="Input.Basic.SmtpPort" type="number" class="form-control" max="65535" />
+                <span asp-validation-for="Input.Basic.SmtpPort" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpEnableSSL" class="col-sm-2 col-form-label">SMTP Enable SSL</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check">
+                    <input asp-for="Input.Basic.SmtpEnableSSL" class="form-check-input" />
+                    <label class="form-check-label" for="Input_Basic_SmtpEnableSSL">사용</label>
+                </div>
+                <span asp-validation-for="Input.Basic.SmtpEnableSSL" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpUsername" class="col-sm-2 col-form-label">SMTP Username</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SmtpUsername" class="form-control" maxlength="100" />
+                <span asp-validation-for="Input.Basic.SmtpUsername" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.SmtpPassword" class="col-sm-2 col-form-label">SMTP Password</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.SmtpPassword" type="password" class="form-control" maxlength="200" />
+                <span asp-validation-for="Input.Basic.SmtpPassword" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.AdminWhiteIPList" class="col-sm-2 col-form-label">관리자단 접근 가능 IP</label>
+            <div class="col-sm-10">
+                <div class="form-text text-muted pb-2">
+                    해당 IP 에서만 관리자 페이지에 접근이 가능합니다. <br />
+                    IP 주소 입력형식 <br />
+                    1. 와일드카드 (*) 사용가능(예: 192.168.0.*) <br />
+                    2. 하이픈 (-)을 사용하여 대역으로 입력가능 <br />
+                    (단, 대역폭으로 입력할 경우 와일드카드 사용불가. 예: 192.168.0.1 ~ 192.168.0.254) <br />
+                    3. 여러개의 항목은 줄을 바꾸어 입력하세요.
+                </div>
+
+                <textarea asp-for="Input.Basic.AdminWhiteIPList" class="form-control" rows="3" maxlength="1000"></textarea>
+                <div class="form-text text-muted">해당 IP 에서만 관리자에 접근이 가능합니다.</div>
+                <span asp-validation-for="Input.Basic.AdminWhiteIPList" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.FrontWhiteIPList" class="col-sm-2 col-form-label">사용자단 접근 가능 IP</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Basic.FrontWhiteIPList" class="form-control" rows="2" maxlength="1000"></textarea>
+                <div class="form-text text-muted">해당 IP 에서만 사이트에 접근이 가능합니다.</div>
+                <span asp-validation-for="Input.Basic.FrontWhiteIPList" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.BlockAlertTitle" class="col-sm-2 col-form-label">차단 시 안내문 제목</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Basic.BlockAlertTitle" class="form-control" maxlength="200" />
+                <span asp-validation-for="Input.Basic.BlockAlertTitle" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.BlockAlertContent" class="col-sm-2 col-form-label">차단 시 안내문 내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Basic.BlockAlertContent" id="blockAlertContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.Basic.BlockAlertContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.IsMaintenance" class="col-sm-2 col-form-label">점검 여부</label>
+            <div class="col-sm-10 align-content-center">
+                <div class="form-check-inline">
+                    <input asp-for="Input.Basic.IsMaintenance" class="form-check-input" />
+                    <label class="form-check-label" for="Input_Basic_IsMaintenance">점검을 진행합니다.</label>
+                </div>
+                <span asp-validation-for="Input.Basic.IsMaintenance" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Basic.MaintenanceContent" class="col-sm-2 col-form-label">점검 내용</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Basic.MaintenanceContent" id="maintenanceContent" class="ck-editor"></textarea>
+                <span asp-validation-for="Input.Basic.MaintenanceContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 56 - 0
Admin/Pages/Config/Basic.cshtml.cs

@@ -0,0 +1,56 @@
+using Application.Features.Config;
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config
+{
+    public class BasicModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public async Task OnGetAsync(CancellationToken cancellationToken)
+        {
+            var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+            if (config is not null)
+            {
+                Input = InputModel.From(config);
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            await mediator.Send(Input.ToCommand(), cancellationToken);
+
+            return RedirectToPage();
+        }
+
+        public sealed class InputModel
+        {
+            public ConfigDto.BasicConfigDto Basic { get; set; } = new();
+
+            public static InputModel From(ConfigDto config)
+            {
+                return new()
+                {
+                    Basic = config.Basic
+                };
+            }
+
+            public UpdateConfigCommand ToCommand()
+            {
+                return new(
+                    Basic
+                );
+            }
+        }
+    }
+}

+ 161 - 0
Admin/Pages/Config/Company.cshtml

@@ -0,0 +1,161 @@
+@page
+@model Admin.Pages.Config.CompanyModel
+@{
+    ViewData["Title"] = "회사 정보";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form id="fAdminWrite" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="row mb-2">
+            <label asp-for="Input.Company.Name" class="col-sm-2 col-form-label">상호 명</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.Name" class="form-control" />
+                <span asp-validation-for="Input.Company.Name" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.RegNo" class="col-sm-2 col-form-label">사업자 등록 번호</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.RegNo" class="form-control" />
+                <span asp-validation-for="Input.Company.RegNo" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.Address" class="col-sm-2 col-form-label">사업장 소재지</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.Address" class="form-control" />
+                <span asp-validation-for="Input.Company.Address" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.ZipCode" class="col-sm-2 col-form-label">사업장 우편번호</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.ZipCode" class="form-control" />
+                <span asp-validation-for="Input.Company.ZipCode" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.Owner" class="col-sm-2 col-form-label">대표자 명</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.Owner" class="form-control" />
+                <span asp-validation-for="Input.Company.Owner" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.Tel" class="col-sm-2 col-form-label">대표 전화번호</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.Tel" class="form-control" />
+                <span asp-validation-for="Input.Company.Tel" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.Fax" class="col-sm-2 col-form-label">FAX 번호</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.Fax" class="form-control" />
+                <span asp-validation-for="Input.Company.Fax" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.RetailSaleNo" class="col-sm-2 col-form-label">통신판매업 신고번호</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.RetailSaleNo" class="form-control" />
+                <span asp-validation-for="Input.Company.RetailSaleNo" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.AddedSaleNo" class="col-sm-2 col-form-label">부가통신 사업자번호</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.AddedSaleNo" class="form-control" />
+                <span asp-validation-for="Input.Company.AddedSaleNo" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.Hosting" class="col-sm-2 col-form-label">호스팅 서비스</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.Hosting" class="form-control" />
+                <span asp-validation-for="Input.Company.Hosting" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.AdminName" class="col-sm-2 col-form-label">정보관리책임자</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.AdminName" class="form-control" />
+                <span asp-validation-for="Input.Company.AdminName" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.AdminEmail" class="col-sm-2 col-form-label">정보관리책임자 이메일</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.AdminEmail" class="form-control" />
+                <span asp-validation-for="Input.Company.AdminEmail" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.SiteUrl" class="col-sm-2 col-form-label">사이트 주소</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Company.SiteUrl" class="form-control" />
+                <span asp-validation-for="Input.Company.SiteUrl" class="text-danger"></span>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Company.BankCode" class="col-sm-2 col-form-label">입금계좌</label>
+            <div class="col-sm-10">
+                <div class="row">
+                    <label asp-for="Input.Company.BankCode" class="col-auto col-form-label">은행</label>
+                    <div class="col-sm">
+                        <select asp-for="Input.Company.BankCode" class="form-select" asp-items="@(Model.BankCodes.Select(x => new SelectListItem(x.Text, x.Value)))">
+                            <option value="">은행을 선택하세요.</option>
+                        </select>
+                        <span asp-validation-for="Input.Company.BankCode" class="text-danger"></span>
+                    </div>
+
+                    <label asp-for="Input.Company.BankOwner" class="col-auto col-form-label">예금주</label>
+                    <div class="col-sm">
+                        <input asp-for="Input.Company.BankOwner" class="form-control" />
+                        <span asp-validation-for="Input.Company.BankOwner" class="text-danger"></span>
+                    </div>
+
+                    <label asp-for="Input.Company.BankNumber" class="col-auto col-form-label">계좌번호</label>
+                    <div class="col-sm">
+                        <input asp-for="Input.Company.BankNumber" class="form-control" />
+                        <span asp-validation-for="Input.Company.BankNumber" class="text-danger"></span>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <hr />
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 72 - 0
Admin/Pages/Config/Company.cshtml.cs

@@ -0,0 +1,72 @@
+using Application.Features.Config;
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using Application.Features.ReferenceData.Dtos;
+using Application.Features.ReferenceData.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config
+{
+    public class CompanyModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public IReadOnlyList<BankCodeDto> BankCodes { get; set; } = [];
+
+        private async Task BindBankCodesAsync(CancellationToken cancellationToken)
+        {
+            BankCodes = await mediator.Send(new GetBankCodesQuery(), cancellationToken);
+        }
+
+        public async Task OnGetAsync(CancellationToken cancellationToken)
+        {
+            await BindBankCodesAsync(cancellationToken);
+
+            var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+            if (config is not null)
+            {
+                Input = InputModel.From(config);
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+        {
+            await BindBankCodesAsync(cancellationToken);
+
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            await mediator.Send(Input.ToCommand(), cancellationToken);
+
+            return RedirectToPage();
+        }
+
+        public sealed class InputModel
+        {
+            public ConfigDto.CompanyConfigDto Company { get; set; } = new();
+
+            public static InputModel From(ConfigDto config)
+            {
+                return new()
+                {
+                    Company = config.Company
+                };
+            }
+
+            public UpdateConfigCommand ToCommand()
+            {
+                return new(
+                    null,
+                    null,
+                    null,
+                    Company
+                );
+            }
+        }
+    }
+}

+ 91 - 0
Admin/Pages/Config/External.cshtml

@@ -0,0 +1,91 @@
+@page
+@model Admin.Pages.Config.ExternalModel
+
+@{
+    ViewData["Title"] = "API 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="alert alert-secondary" role="alert">
+            현재 운영 환경에 맞는 값을 입력해주세요.
+        </div>
+
+        <hr />
+
+        <!-- YouTube -->
+        <details open>
+            <summary class="fs-5 mb-3">YouTube</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.YouTubeApiName" class="col-sm-2 col-form-label">API Name</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.YouTubeApiName" type="text" class="form-control" />
+                    <span asp-validation-for="Input.External.YouTubeApiName" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.YouTubeApiKeyEnc" class="col-sm-2 col-form-label">API Key</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.YouTubeApiKeyEnc" type="text" class="form-control" autocomplete="off" />
+                    <span asp-validation-for="Input.External.YouTubeApiKeyEnc" class="text-danger"></span>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <!-- Google -->
+        <details open>
+            <summary class="fs-5 mb-3">Google OAuth 2.0</summary>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.GoogleClientId" class="col-sm-2 col-form-label">Google Client ID</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.GoogleClientId" type="text" class="form-control" />
+                    <span asp-validation-for="Input.External.GoogleClientId" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.GoogleClientSecretEnc" class="col-sm-2 col-form-label">Google Client Secret</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.GoogleClientSecretEnc" type="text" class="form-control" autocomplete="off" />
+                    <span asp-validation-for="Input.External.GoogleClientSecretEnc" class="text-danger"></span>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.External.GoogleAppId" class="col-sm-2 col-form-label">Google App ID</label>
+                <div class="col-sm-10">
+                    <input asp-for="Input.External.GoogleAppId" type="text" class="form-control" />
+                    <span asp-validation-for="Input.External.GoogleAppId" class="text-danger"></span>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 56 - 0
Admin/Pages/Config/External.cshtml.cs

@@ -0,0 +1,56 @@
+using Application.Features.Config;
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config
+{
+    public class ExternalModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public async Task OnGetAsync(CancellationToken cancellationToken)
+        {
+            var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+            if (config is not null)
+            {
+                Input = InputModel.From(config);
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            await mediator.Send(Input.ToCommand(), cancellationToken);
+
+            return RedirectToPage();
+        }
+
+        public sealed class InputModel
+        {
+            public ConfigDto.ExternalApiConfigDto External { get; set; } = new();
+
+            public static InputModel From(ConfigDto config)
+            {
+                return new()
+                {
+                    External = config.External
+                };
+            }
+
+            public UpdateConfigCommand ToCommand()
+            {
+                return new(
+                    External : External
+                );
+            }
+        }
+    }
+}

+ 213 - 0
Admin/Pages/Config/Images.cshtml

@@ -0,0 +1,213 @@
+@page
+@model Admin.Pages.Config.ImagesModel
+@{
+    ViewData["Title"] = "이미지 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+    <partial name="Config/_navTabs" />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8" enctype="multipart/form-data">
+
+        <!-- Favicon -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.FaviconFile" class="col-sm-2 col-form-label">Favicon</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.FaviconPath))
+                {
+                    <input asp-for="Input.Images.FaviconFile" class="form-control" accept=".ico,image/x-icon" />
+                    <span asp-validation-for="Input.Images.FaviconFile" class="text-danger"></span>
+                    <div class="form-text text-muted">브라우저 탭/즐겨찾기에 표시됩니다. 권장: .ico, 32x32 또는 48x48</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.FaviconPath" alt="favicon" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_favicon" id="deleteFavicon" />
+                        <label class="form-check-label" for="deleteFavicon">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Logo Square -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.LogoSquareFile" class="col-sm-2 col-form-label">Logo-square</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.LogoSquarePath))
+                {
+                    <input asp-for="Input.Images.LogoSquareFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" />
+                    <span asp-validation-for="Input.Images.LogoSquareFile" class="text-danger"></span>
+                    <div class="form-text text-muted">정사각형 로고(프로필/아이콘 영역)에 사용됩니다. 권장: 512x512 PNG 투명 배경</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.LogoSquarePath" alt="logo-square" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_logo_square" id="deleteLogoSquare" />
+                        <label class="form-check-label" for="deleteLogoSquare">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Logo Horizontal -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.LogoHorizontalFile" class="col-sm-2 col-form-label">Logo-horizontal</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.LogoHorizontalPath))
+                {
+                    <input asp-for="Input.Images.LogoHorizontalFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" />
+                    <span asp-validation-for="Input.Images.LogoHorizontalFile" class="text-danger"></span>
+                    <div class="form-text text-muted">헤더/상단 영역에 사용되는 가로형 로고입니다. 권장: 가로 200~400px 이상</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.LogoHorizontalPath" alt="logo-horizontal" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_logo_horizontal" id="deleteLogoHorizontal" />
+                        <label class="form-check-label" for="deleteLogoHorizontal">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- OG Default -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.OgDefaultFile" class="col-sm-2 col-form-label">og-default</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.OgDefaultPath))
+                {
+                    <input asp-for="Input.Images.OgDefaultFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.OgDefaultFile" class="text-danger"></span>
+                    <div class="form-text text-muted">SNS 공유(Open Graph) 기본 이미지입니다. 권장: 1200x630</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.OgDefaultPath" alt="og-default" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_og_default" id="deleteOgDefault" />
+                        <label class="form-check-label" for="deleteOgDefault">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Twitter Image -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.TwitterImageFile" class="col-sm-2 col-form-label">Twitter-image</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.TwitterImagePath))
+                {
+                    <input asp-for="Input.Images.TwitterImageFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.TwitterImageFile" class="text-danger"></span>
+                    <div class="form-text text-muted">트위터 카드 공유 이미지입니다. 권장: 1200x600 또는 1200x628</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.TwitterImagePath" alt="twitter-image" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_twitter_image" id="deleteTwitterImage" />
+                        <label class="form-check-label" for="deleteTwitterImage">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- Apple Touch Icon -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.AppleTouchIconFile" class="col-sm-2 col-form-label">Apple-touch-icon</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.AppleTouchIconPath))
+                {
+                    <input asp-for="Input.Images.AppleTouchIconFile" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.AppleTouchIconFile" class="text-danger"></span>
+                    <div class="form-text text-muted">iOS 홈 화면에 추가 시 아이콘으로 사용됩니다. 권장: 180x180 PNG</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.AppleTouchIconPath" alt="apple-touch-icon" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_apple_touch_icon" id="deleteAppleTouchIcon" />
+                        <label class="form-check-label" for="deleteAppleTouchIcon">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- App Icon 192 -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.AppIcon192File" class="col-sm-2 col-form-label">App-icon-192</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.AppIcon192Path))
+                {
+                    <input asp-for="Input.Images.AppIcon192File" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.AppIcon192File" class="text-danger"></span>
+                    <div class="form-text text-muted">PWA/앱 아이콘(192)으로 사용됩니다. 권장: 192x192 PNG</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.AppIcon192Path" alt="app-icon-192" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_app_icon_192" id="deleteAppIcon192" />
+                        <label class="form-check-label" for="deleteAppIcon192">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr />
+
+        <!-- App Icon 512 -->
+        <div class="row mb-2 align-items-center">
+            <label asp-for="Input.Images.AppIcon512File" class="col-sm-2 col-form-label">App-icon-512</label>
+            <div class="col-sm-10">
+                @if (string.IsNullOrEmpty(Model.Input.Images.AppIcon512Path))
+                {
+                    <input asp-for="Input.Images.AppIcon512File" class="form-control" accept="image/png,image/jpeg,image/gif,image/webp" />
+                    <span asp-validation-for="Input.Images.AppIcon512File" class="text-danger"></span>
+                    <div class="form-text text-muted">PWA/앱 아이콘(512)으로 사용됩니다. 권장: 512x512 PNG</div>
+                }
+                else
+                {
+                    <img src="@Model.Input.Images.AppIcon512Path" alt="app-icon-512" class="img-fluid rounded" />
+                    <div class="form-check mt-2">
+                        <input class="form-check-input" type="checkbox" value="1" name="delete_app_icon_512" id="deleteAppIcon512" />
+                        <label class="form-check-label" for="deleteAppIcon512">삭제</label>
+                    </div>
+                }
+            </div>
+        </div>
+
+        <hr/>
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    <partial name="_ValidationScriptsPartial" />
+}

+ 70 - 0
Admin/Pages/Config/Images.cshtml.cs

@@ -0,0 +1,70 @@
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using Application.Features.Config;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config;
+
+public sealed class ImagesModel(IMediator mediator) : PageModel
+{
+    [BindProperty]
+    public InputModel Input { get; set; } = new();
+
+    public async Task OnGetAsync(CancellationToken cancellationToken)
+    {
+        var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+        if (config is not null)
+        {
+            Input = InputModel.From(config);
+        }
+    }
+
+    public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+    {
+        if (!ModelState.IsValid)
+        {
+            return Page();
+        }
+
+        await mediator.Send(Input.ToCommand(Request), cancellationToken);
+
+        return RedirectToPage();
+    }
+
+    public sealed class InputModel
+    {
+        public ConfigDto.ImagesConfigDto Images { get; set; } = new();
+
+        public static InputModel From(ConfigDto config)
+        {
+            return new()
+            {
+                Images = config.Images
+            };
+        }
+
+        public UpdateConfigCommand ToCommand(HttpRequest request)
+        {
+            bool IsChecked(string key) => request.Form.TryGetValue(key, out var v) && v.Count > 0;
+
+            var delete = new UpdateConfigCommand.ImagesDeleteFlags(
+                Favicon: IsChecked("delete_favicon"),
+                LogoSquare: IsChecked("delete_logo_square"),
+                LogoHorizontal: IsChecked("delete_logo_horizontal"),
+                OgDefault: IsChecked("delete_og_default"),
+                TwitterImage: IsChecked("delete_twitter_image"),
+                AppleTouchIcon: IsChecked("delete_apple_touch_icon"),
+                AppIcon192: IsChecked("delete_app_icon_192"),
+                AppIcon512: IsChecked("delete_app_icon_512")
+            );
+
+            return new(
+                null,
+                Images,
+                ImagesDelete: delete
+            );
+        }
+    }
+}

+ 0 - 27
Admin/Pages/Config/Index.cshtml

@@ -1,27 +0,0 @@
-@page
-@model Admin.Pages.Config.IndexModel
-@{
-    ViewData["Title"] = "Config";
-}
-
-<h1>Config</h1>
-
-<form method="post">
-    <div>
-        <label asp-for="Input.Basic.SiteName"></label>
-        <input asp-for="Input.Basic.SiteName" />
-        <span asp-validation-for="Input.Basic.SiteName"></span>
-    </div>
-
-    <div>
-        <label asp-for="Input.Basic.DefaultFeeRate"></label>
-        <input asp-for="Input.Basic.DefaultFeeRate" />
-        <span asp-validation-for="Input.Basic.DefaultFeeRate"></span>
-    </div>
-
-    <button type="submit">Save</button>
-</form>
-
-@section Scripts {
-    <partial name="_ValidationScriptsPartial" />
-}

+ 0 - 73
Admin/Pages/Config/Index.cshtml.cs

@@ -1,73 +0,0 @@
-using Application.Features.Config.Commands;
-using Application.Features.Config.Queries;
-using Application.Features.Config;
-using MediatR;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using Microsoft.AspNetCore.Authorization;
-
-namespace Admin.Pages.Config;
-
-public sealed class IndexModel(IMediator mediator) : PageModel
-{
-    [BindProperty]
-    public InputModel Input { get; set; } = new();
-
-    public async Task OnGetAsync(CancellationToken cancellationToken)
-    {
-        var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
-        if (config is not null)
-        {
-            Input = InputModel.From(config);
-        }
-    }
-
-    public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
-    {
-        if (!ModelState.IsValid)
-        {
-            return Page();
-        }
-
-        await mediator.Send(Input.ToCommand(), cancellationToken);
-        return RedirectToPage();
-    }
-
-    public sealed class InputModel
-    {
-        public ConfigDto.BasicConfigDto Basic { get; set; } = new();
-        public ConfigDto.MetaConfigDto Meta { get; set; } = new();
-        public ConfigDto.CompanyConfigDto Company { get; set; } = new();
-        public ConfigDto.AccountConfigDto Account { get; set; } = new();
-        public ConfigDto.EmailTemplateConfigDto EmailTemplate { get; set; } = new();
-        public ConfigDto.ExternalApiConfigDto External { get; set; } = new();
-        public ConfigDto.PaymentConfigDto Payment { get; set; } = new();
-
-        public static InputModel From(ConfigDto config)
-        {
-            return new()
-            {
-                Basic = config.Basic,
-                Meta = config.Meta,
-                Company = config.Company,
-                Account = config.Account,
-                EmailTemplate = config.EmailTemplate,
-                External = config.External,
-                Payment = config.Payment
-            };
-        }
-
-        public UpdateConfigCommand ToCommand()
-        {
-            return new(
-                Basic,
-                Meta,
-                Company,
-                Account,
-                EmailTemplate,
-                External,
-                Payment
-            );
-        }
-    }
-}

+ 97 - 0
Admin/Pages/Config/Meta.cshtml

@@ -0,0 +1,97 @@
+@page
+@model Admin.Pages.Config.MetaModel
+
+@{
+    ViewData["Title"] = "메타 태그";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.Keywords" class="col-sm-2 col-form-label">Meta Keywords</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Meta.Keywords" class="form-control" maxlength="255" />
+                <div class="form-text text-muted">검색엔진 키워드</div>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.Description" class="col-sm-2 col-form-label">Meta Description</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Meta.Description" class="form-control" maxlength="255" />
+                <div class="form-text text-muted">사이트 설명</div>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.Author" class="col-sm-2 col-form-label">Meta Author</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Meta.Author" class="form-control" maxlength="255" />
+                <div class="form-text text-muted">사이트 제작자</div>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.Viewport" class="col-sm-2 col-form-label">Meta Viewport</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Meta.Viewport" class="form-control" maxlength="255" />
+                <div class="form-text text-muted">페이지 크기와 비율 지정</div>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.ApplicationName" class="col-sm-2 col-form-label">Meta Application Name</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Meta.ApplicationName" class="form-control" maxlength="255" />
+                <div class="form-text text-muted">프로그램 이름</div>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.Generator" class="col-sm-2 col-form-label">Meta Generator</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Meta.Generator" class="form-control" maxlength="255" />
+                <div class="form-text text-muted">
+                    문서 작성에 사용한 저작 도구를 지정, 저작자가 저작 도구를 사용하지 않고 직접 작성한 때에는 이 속성값을 사용하지 않음
+                </div>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.Robots" class="col-sm-2 col-form-label">Meta Robots</label>
+            <div class="col-sm-10">
+                <input asp-for="Input.Meta.Robots" class="form-control" maxlength="255" />
+                <div class="form-text text-muted">협조적인 크롤러, 또는 "로봇"의 동작을 지정</div>
+            </div>
+        </div>
+
+        <div class="row mb-2">
+            <label asp-for="Input.Meta.Adds" class="col-sm-2 col-form-label">Meta Adds</label>
+            <div class="col-sm-10">
+                <textarea asp-for="Input.Meta.Adds" class="form-control" rows="6" maxlength="1000"></textarea>
+                <div class="form-text text-muted">직접 추가할 메타 태그를 작성합니다.</div>
+            </div>
+        </div>
+
+        <hr />
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 58 - 0
Admin/Pages/Config/Meta.cshtml.cs

@@ -0,0 +1,58 @@
+using Application.Features.Config;
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config
+{
+    public class MetaModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public async Task OnGetAsync(CancellationToken cancellationToken)
+        {
+            var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+            if (config is not null)
+            {
+                Input = InputModel.From(config);
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            await mediator.Send(Input.ToCommand(), cancellationToken);
+
+            return RedirectToPage();
+        }
+
+        public sealed class InputModel
+        {
+            public ConfigDto.MetaConfigDto Meta { get; set; } = new();
+
+            public static InputModel From(ConfigDto config)
+            {
+                return new()
+                {
+                    Meta = config.Meta
+                };
+            }
+
+            public UpdateConfigCommand ToCommand()
+            {
+                return new(
+                    null,
+                    null,
+                    Meta
+                );
+            }
+        }
+    }
+}

+ 263 - 0
Admin/Pages/Config/Register.cshtml

@@ -0,0 +1,263 @@
+@page
+@model Admin.Pages.Config.RegisterModel
+@{
+    ViewData["Title"] = "회원 설정";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <form name="f_admin_write" id="fAdminWrite" method="post" autocomplete="off" accept-charset="UTF-8">
+        <details open>
+            <summary class="fs-5">회원 가입 시</summary>
+            <hr />
+
+            <div class="row">
+                <label asp-for="Input.Account.IsRegisterBlock" class="col-sm-2 col-form-label">회원가입 차단</label>
+                <div class="col-sm-10 align-content-center">
+                    <input asp-for="Input.Account.IsRegisterBlock" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.Account.IsRegisterBlock">회원가입을 차단합니다.</label>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.IsRegisterEmailAuth" class="col-sm-2 col-form-label">회원가입 시 이메일 인증</label>
+                <div class="col-sm-10 align-content-center">
+                    <input asp-for="Input.Account.IsRegisterEmailAuth" class="form-check-input" />
+                    <label class="form-check-label" asp-for="Input.Account.IsRegisterEmailAuth">가입 시 이메일 인증을 확인합니다.</label>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.PasswordMinLength" class="col-sm-2 col-form-label">비밀번호 최소 길이</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.PasswordMinLength" type="number" class="form-control" min="6" />
+                                <span class="input-group-text" id="Input_Account_PasswordMinLength">자</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.PasswordMinLength" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">비밀번호 최소 길이입니다. (권장: 8 이상)</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.PasswordUppercaseLength" class="col-sm-2 col-form-label">비밀번호 최소 대문자 수</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.PasswordUppercaseLength" type="number" class="form-control" min="0" />
+                                <span class="input-group-text" id="Input_Account_PasswordUppercaseLength">개</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.PasswordUppercaseLength" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">대문자 포함을 강제하지 않으려면 0을 입력합니다.</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.PasswordNumbersLength" class="col-sm-2 col-form-label">비밀번호 최소 숫자 수</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.PasswordNumbersLength" type="number" class="form-control" min="0" />
+                                <span class="input-group-text" id="Input_Account_PasswordNumbersLength">개</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.PasswordNumbersLength" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">숫자 포함을 강제하지 않으려면 0을 입력합니다.</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.PasswordSpecialcharsLength" class="col-sm-2 col-form-label">비밀번호 최소 특수문자 수</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.PasswordSpecialcharsLength" type="number" class="form-control" min="0" />
+                                <span class="input-group-text" id="Input_Account_PasswordSpecialcharsLength">개</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.PasswordSpecialcharsLength" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">
+                        비밀번호 길이는 최소 6자 이상이어야 하며, 대문자/숫자/특수문자를 원하지 않으면 0을 입력하면 됩니다.
+                        이 규칙은 회원가입/정보수정 시 적용되며, 기존 회원 로그인에는 적용되지 않습니다.
+                    </small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.DeniedEmailList" class="col-sm-2 col-form-label">금지 이메일</label>
+                <div class="col-sm-10">
+                    <textarea asp-for="Input.Account.DeniedEmailList" class="form-control" rows="3"></textarea>
+                    <span asp-validation-for="Input.Account.DeniedEmailList" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">제한하고 싶은 이메일을 쉼표로 구분하여 입력해주세요.</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.DeniedNameList" class="col-sm-2 col-form-label">금지 별명</label>
+                <div class="col-sm-10">
+                    <textarea asp-for="Input.Account.DeniedNameList" class="form-control" rows="3"></textarea>
+                    <span asp-validation-for="Input.Account.DeniedNameList" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">제한하고 싶은 별명을 쉼표로 구분하여 입력해주세요.</small>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <details open>
+            <summary class="fs-5">회원 수정 시</summary>
+            <hr />
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.ChangeEmailDay" class="col-sm-2 col-form-label">이메일 갱신 주기</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.ChangeEmailDay" type="number" class="form-control" min="0" max="365" />
+                                <span class="input-group-text" id="Input_Account_ChangeEmailDay">일</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.ChangeEmailDay" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">이메일 변경 후 해당일 동안 바꿀 수 없습니다. 0이면 항상 변경 가능</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.ChangeNameDay" class="col-sm-2 col-form-label">별명 갱신 주기</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.ChangeNameDay" type="number" class="form-control" min="0" max="365" />
+                                <span class="input-group-text" id="Input_Account_ChangeNameDay">일</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.ChangeNameDay" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">별명 변경 후 해당일 동안 바꿀 수 없습니다. 0이면 항상 변경 가능</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.ChangePasswordDay" class="col-sm-2 col-form-label">비밀번호 갱신 주기</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.ChangePasswordDay" type="number" class="form-control" min="0" max="365" />
+                                <span class="input-group-text" id="Input_Account_ChangePasswordDay">일</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.ChangePasswordDay" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">일, 일정기간이 지나면 비밀번호 변경을 유도합니다. 0이면 사용하지 않음</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.ChangeSummaryDay" class="col-sm-2 col-form-label">한마디 갱신 주기</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.ChangeSummaryDay" type="number" class="form-control" min="0" max="365" />
+                                <span class="input-group-text" id="Input_Account_ChangeSummaryDay">일</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.ChangeSummaryDay" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">한마디 변경 후 해당일 동안 바꿀 수 없습니다. 0이면 항상 변경 가능</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.ChangeIntroDay" class="col-sm-2 col-form-label">자기소개 갱신 주기</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.ChangeIntroDay" type="number" class="form-control" min="0" max="365" />
+                                <span class="input-group-text" id="Input_Account_ChangeIntroDay">일</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.ChangeIntroDay" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">자기소개 변경 후 해당일 동안 바꿀 수 없습니다. 0이면 항상 변경 가능</small>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <details open>
+            <summary class="fs-5">로그인 시</summary>
+            <hr />
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.MaxLoginTryCount" class="col-sm-2 col-form-label">로그인 시도</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.MaxLoginTryCount" type="number" class="form-control" min="0" />
+                                <span class="input-group-text" id="Input_Account_MaxLoginTryCount">회</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.MaxLoginTryCount" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">짧은 시간 동안 하나의 IP에서 시도할 수 있는 로그인 횟수 제한</small>
+                </div>
+            </div>
+
+            <div class="row mb-2">
+                <label asp-for="Input.Account.MaxLoginTryLimitSecond" class="col-sm-2 col-form-label">로그인 제한</label>
+                <div class="col-sm-10">
+                    <div class="row">
+                        <div class="col-sm-auto col-lg-3">
+                            <div class="input-group">
+                                <input asp-for="Input.Account.MaxLoginTryLimitSecond" type="number" class="form-control" min="0" />
+                                <span class="input-group-text" id="Input_Account_MaxLoginTryLimitSecond">초</span>
+                            </div>
+                        </div>
+                    </div>
+                    <span asp-validation-for="Input.Account.MaxLoginTryLimitSecond" class="text-danger"></span>
+                    <small class="text-muted form-text d-block">실패 횟수 초과 시 해당 시간 동안 로그인 시도 불가</small>
+                </div>
+            </div>
+        </details>
+
+        <hr />
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 56 - 0
Admin/Pages/Config/Register.cshtml.cs

@@ -0,0 +1,56 @@
+using Application.Features.Config;
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config
+{
+    public class RegisterModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public async Task OnGetAsync(CancellationToken cancellationToken)
+        {
+            var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+            if (config is not null)
+            {
+                Input = InputModel.From(config);
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            await mediator.Send(Input.ToCommand(), cancellationToken);
+
+            return RedirectToPage();
+        }
+
+        public sealed class InputModel
+        {
+            public ConfigDto.AccountConfigDto Account { get; set; } = new();
+
+            public static InputModel From(ConfigDto config)
+            {
+                return new()
+                {
+                    Account = config.Account
+                };
+            }
+
+            public UpdateConfigCommand ToCommand()
+            {
+                return new(
+                    Account: Account
+                );
+            }
+        }
+    }
+}

+ 129 - 0
Admin/Pages/Config/Template/Email.cshtml

@@ -0,0 +1,129 @@
+@page
+@model Admin.Pages.Config.Template.EmailModel
+
+@{
+    ViewData["Title"] = "알림 발송 양식";
+}
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+    <partial name="_Editor" />
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="row mb-3">
+            <label asp-for="Input.EmailTemplate.RegisterEmailFormTitle" class="col-sm-2 col-form-label">회원가입 시</label>
+            <div class="col-sm-10">
+                <div class="form-label">
+                    <input asp-for="Input.EmailTemplate.RegisterEmailFormTitle" type="text" class="form-control" />
+                    <span asp-validation-for="Input.EmailTemplate.RegisterEmailFormTitle" class="text-danger"></span>
+                </div>
+                <textarea asp-for="Input.EmailTemplate.RegisterEmailFormContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.EmailTemplate.RegisterEmailFormContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-3">
+            <label asp-for="Input.EmailTemplate.RegistrationEmailFormTitle" class="col-sm-2 col-form-label">회원가입 완료</label>
+            <div class="col-sm-10">
+                <div class="form-label">
+                    <input asp-for="Input.EmailTemplate.RegistrationEmailFormTitle" type="text" class="form-control" />
+                    <span asp-validation-for="Input.EmailTemplate.RegistrationEmailFormTitle" class="text-danger"></span>
+                </div>
+                <textarea asp-for="Input.EmailTemplate.RegistrationEmailFormContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.EmailTemplate.RegistrationEmailFormContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-3">
+            <label asp-for="Input.EmailTemplate.ResetPasswordEmailFormTitle" class="col-sm-2 col-form-label">비밀번호 재설정</label>
+            <div class="col-sm-10">
+                <div class="form-label">
+                    <input asp-for="Input.EmailTemplate.ResetPasswordEmailFormTitle" type="text" class="form-control" />
+                    <span asp-validation-for="Input.EmailTemplate.ResetPasswordEmailFormTitle" class="text-danger"></span>
+                </div>
+                <textarea asp-for="Input.EmailTemplate.ResetPasswordEmailFormContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.EmailTemplate.ResetPasswordEmailFormContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-3">
+            <label asp-for="Input.EmailTemplate.ChangedPasswordEmailFormTitle" class="col-sm-2 col-form-label">비밀번호 변경 완료</label>
+            <div class="col-sm-10">
+                <div class="form-label">
+                    <input asp-for="Input.EmailTemplate.ChangedPasswordEmailFormTitle" type="text" class="form-control" />
+                    <span asp-validation-for="Input.EmailTemplate.ChangedPasswordEmailFormTitle" class="text-danger"></span>
+                </div>
+                <textarea asp-for="Input.EmailTemplate.ChangedPasswordEmailFormContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.EmailTemplate.ChangedPasswordEmailFormContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-3">
+            <label asp-for="Input.EmailTemplate.WithdrawEmailFormTitle" class="col-sm-2 col-form-label">회원탈퇴 시</label>
+            <div class="col-sm-10">
+                <div class="form-label">
+                    <input asp-for="Input.EmailTemplate.WithdrawEmailFormTitle" type="text" class="form-control" />
+                    <span asp-validation-for="Input.EmailTemplate.WithdrawEmailFormTitle" class="text-danger"></span>
+                </div>
+                <textarea asp-for="Input.EmailTemplate.WithdrawEmailFormContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.EmailTemplate.WithdrawEmailFormContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-3">
+            <label asp-for="Input.EmailTemplate.EmailVerifyFormTitle" class="col-sm-2 col-form-label">이메일 변경 시</label>
+            <div class="col-sm-10">
+                <div class="form-label">
+                    <input asp-for="Input.EmailTemplate.EmailVerifyFormTitle" type="text" class="form-control" />
+                    <span asp-validation-for="Input.EmailTemplate.EmailVerifyFormTitle" class="text-danger"></span>
+                </div>
+                <textarea asp-for="Input.EmailTemplate.EmailVerifyFormContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.EmailTemplate.EmailVerifyFormContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row mb-3">
+            <label asp-for="Input.EmailTemplate.ChangedEmailFormTitle" class="col-sm-2 col-form-label">이메일 변경 완료</label>
+            <div class="col-sm-10">
+                <div class="form-label">
+                    <input asp-for="Input.EmailTemplate.ChangedEmailFormTitle" type="text" class="form-control" />
+                    <span asp-validation-for="Input.EmailTemplate.ChangedEmailFormTitle" class="text-danger"></span>
+                </div>
+                <textarea asp-for="Input.EmailTemplate.ChangedEmailFormContent" class="form-control ck-editor"></textarea>
+                <span asp-validation-for="Input.EmailTemplate.ChangedEmailFormContent" class="text-danger"></span>
+            </div>
+        </div>
+
+        <hr />
+
+        <div class="row">
+            <div class="col text-center p-3">
+                <button type="submit" class="btn btn-success">저장하기</button>
+            </div>
+        </div>
+
+        <br />
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 56 - 0
Admin/Pages/Config/Template/Email.cshtml.cs

@@ -0,0 +1,56 @@
+using Application.Features.Config;
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Admin.Pages.Config.Template
+{
+    public class EmailModel(IMediator mediator) : PageModel
+    {
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public async Task OnGetAsync(CancellationToken cancellationToken)
+        {
+            var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+            if (config is not null)
+            {
+                Input = InputModel.From(config);
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            await mediator.Send(Input.ToCommand(), cancellationToken);
+
+            return RedirectToPage();
+        }
+
+        public sealed class InputModel
+        {
+            public ConfigDto.EmailTemplateConfigDto EmailTemplate { get; set; } = new();
+
+            public static InputModel From(ConfigDto config)
+            {
+                return new()
+                {
+                    EmailTemplate = config.EmailTemplate
+                };
+            }
+
+            public UpdateConfigCommand ToCommand()
+            {
+                return new(
+                    EmailTemplate : EmailTemplate
+                );
+            }
+        }
+    }
+}

+ 70 - 0
Admin/Pages/Config/Test/Email.cshtml

@@ -0,0 +1,70 @@
+@page
+@model Admin.Pages.Config.Test.EmailModel
+
+@{
+    ViewData["Title"] = "알림 발송 확인";
+}
+
+
+<div class="container">
+    <h3>@ViewData["Title"]</h3>
+    <hr />
+
+    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
+    <partial name="_StatusMessage" />
+
+    <div class="row row-cols-1 row-cols-sm-4 g-2 mb-2">
+        <div class="col">
+            <strong>SMTP Server</strong>
+        </div>
+        <div class="col">
+            (@(Model.Input.Basic.SmtpEnableSSL ? "SSL" : "TLS")) @Model.Input.Basic.SmtpServer
+        </div>
+        <div class="col">
+            <strong>SMTP Port</strong>
+        </div>
+        <div class="col">
+            @Model.Input.Basic.SmtpPort
+        </div>
+    </div>
+    <div class="row row-cols-1 row-cols-sm-4 g-2">
+        <div class="col">
+            <strong>SMTP From Email</strong>
+        </div>
+        <div class="col">
+            @Model.Input.Basic.SmtpUsername
+        </div>
+        <div class="col">
+            <strong>SMTP Email Password</strong>
+        </div>
+        <div class="col">
+            @Model.Input.Basic.SmtpPassword
+        </div>
+    </div>
+    <hr />
+    <div class="row">
+        <label class="col-sm-2 col-form-label">Send E-mail</label>
+        <div class="col-sm-10">
+            <input type="text" readonly class="form-control-plaintext" value="@(Model.Settings.SMTP.FromName ?? "-")" />
+        </div>
+    </div>
+
+    <form name="f_admin_write" id="fAdminWrite" class="mt-2" method="post" autocomplete="off" accept-charset="UTF-8">
+        <div class="row">
+            <label asp-for="ToAddress" class="col-sm-2 col-form-label">Receive E-mail</label>
+            <div class="col-sm-10">
+                <input asp-for="ToAddress" class="form-control" placeholder="수신받을 이메일을 입력하세요." />
+            </div>
+        </div>
+        <hr/>
+        <div class="text-center">
+            <button type="submit" class="btn btn-success">보내기</button>
+        </div>
+    </form>
+</div>
+
+@section Scripts {
+    @{
+        await Html.RenderPartialAsync("_ValidationScriptsPartial");
+    }
+}

+ 87 - 0
Admin/Pages/Config/Test/Email.cshtml.cs

@@ -0,0 +1,87 @@
+using Application.Features.Config;
+using Application.Features.Config.Commands;
+using Application.Features.Config.Queries;
+using Application.Abstractions.Messaging.Email;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Options;
+using SharedKernel;
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Admin.Pages.Config.Test
+{
+    public class EmailModel(IMediator mediator, IOptions<AppSettings> options, IMailService mailService) : PageModel
+    {
+        public readonly AppSettings Settings = options.Value;
+
+        [BindProperty]
+        [MaxLength(256)]
+        [DataType(DataType.EmailAddress)]
+        [DisplayName("To Address")]
+        public string? ToAddress { get; set; }
+
+        [BindProperty]
+        public InputModel Input { get; set; } = new();
+
+        public async Task OnGetAsync(CancellationToken cancellationToken)
+        {
+            var config = await mediator.Send(new GetConfigQuery(), cancellationToken);
+            if (config is not null)
+            {
+                Input = InputModel.From(config);
+            }
+        }
+
+        public async Task<IActionResult> OnPostAsync(CancellationToken cancellationToken)
+        {
+            if (!ModelState.IsValid)
+            {
+                return Page();
+            }
+
+            await mailService.SendAsync(
+                new SendData(
+                    ToAddress: ToAddress!,
+                    Subject: $"[TEST] SMTP 이메일 발송 테스트 - {DateTime.Now:yyyy-MM-dd HH:mm:ss}",
+                    MessageHtml: $"""
+                                 <h3>테스트 메일</h3>
+                                 <ul>
+                                     <li>From: {Settings.SMTP.FromName} &lt;{Settings.SMTP.FromEmail}&gt;</li>
+                                     <li>To: {ToAddress}</li>
+                                     <li>Host: {Settings.SMTP.Host}:{Settings.SMTP.Port}</li>
+                                     <li>TLS: {Settings.SMTP.UseStartTls}</li>
+                                 </ul>
+                                 """,
+                    MessageText: $"TEST MAIL\nFrom={Settings.SMTP.FromEmail}\nTo={ToAddress}\nHost={Settings.SMTP.Host}:{Settings.SMTP.Port}\nTLS={Settings.SMTP.UseStartTls}"
+                ),
+                cancellationToken
+            );
+
+            TempData["SuccessMessage"] = "테스트 이메일을 발송했습니다.";
+
+            return RedirectToPage();
+        }
+
+        public sealed class InputModel
+        {
+            public ConfigDto.BasicConfigDto Basic { get; set; } = new();
+
+            public static InputModel From(ConfigDto config)
+            {
+                return new()
+                {
+                    Basic = config.Basic
+                };
+            }
+
+            public UpdateConfigCommand ToCommand()
+            {
+                return new(
+                    Basic
+                );
+            }
+        }
+    }
+}

+ 40 - 0
Admin/Pages/Server/Env.cshtml

@@ -0,0 +1,40 @@
+@page
+@model Admin.Pages.Server.EnvModel
+@{
+    ViewData["Title"] = "환경 변수";
+}
+
+<div class="container-fluid">
+    <h3 class="pb-2">@ViewData["Title"]</h3>
+
+    @if (Model.EnvVars != null)
+    {
+        <table class="table table-striped table-bordered table-sm">
+            <colgroup>
+                <col style="width: 20%;" />
+                <col style="width: 80%" />
+            </colgroup>
+            <thead>
+                <tr class="text-center">
+                    <th>Key</th>
+                    <th>Value</th>
+                </tr>
+            </thead>
+            <tbody>
+                @foreach (var row in Model.EnvVars)
+                {
+                    <tr class="align-middle">
+                        <td class="p-3">@row.Key</td>
+                        <td>
+                            <textarea class="form-control" rows="1" readonly disabled>@row.Value</textarea>
+                        </td>
+                    </tr>
+                }
+            </tbody>
+        </table>
+    }
+    else
+    {
+        <p>Environment variables not available.</p>
+    }
+</div>

+ 21 - 0
Admin/Pages/Server/Env.cshtml.cs

@@ -0,0 +1,21 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.Collections;
+
+namespace Admin.Pages.Server
+{
+    public class EnvModel : PageModel
+    {
+        public IReadOnlyList<KeyValuePair<string, string>> EnvVars { get; set; } = [];
+
+        public void OnGet()
+        {
+            EnvVars = Environment.GetEnvironmentVariables()
+               .Cast<DictionaryEntry>()
+               .Select(e => new KeyValuePair<string, string>(
+                   e.Key?.ToString() ?? string.Empty,
+                   e.Value?.ToString() ?? string.Empty))
+               .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
+               .ToArray();
+        }
+    }
+}

+ 246 - 0
Admin/Pages/Server/Info.cshtml

@@ -0,0 +1,246 @@
+@page
+@model Admin.Areas.Identity.Pages.Server.InfoModel
+@{
+    ViewData["Title"] = "서버 정보";
+}
+
+<div class="container-fluid">
+    <div class="row row-cols row-cols-lg-2 g-2">
+        <div class="col">
+            <h4 class="pb-2">@ViewData["Title"]</h4>
+            <div class="table-responsive">
+                <table class="table table-bordered">
+                    <colgroup>
+                        <col width="40%" />
+                        <col width="60%" />
+                    </colgroup>
+                    <tbody>
+                        <tr>
+                            <th>Environment Name</th>
+                            <td>@Model.EnvironmentName</td>
+                        </tr>
+                        <tr>
+                            <th>Content Root Path</th>
+                            <td>@Model.ContentRootPath</td>
+                        </tr>
+                        <tr>
+                            <th>Application Name</th>
+                            <td>@Model.ApplicationName</td>
+                        </tr>
+                        <tr>
+                            <th>OS Description</th>
+                            <td>@Model.OSDescription</td>
+                        </tr>
+                        <tr>
+                            <th>OS Architecture</th>
+                            <td>@Model.OSArchitecture</td>
+                        </tr>
+                        <tr>
+                            <th>.NET Framework Description</th>
+                            <td>@Model.FrameworkDescription</td>
+                        </tr>
+                        <tr>
+                            <th>Process Architecture</th>
+                            <td>@Model.ProcessArchitecture</td>
+                        </tr>
+                        <tr>
+                            <th>Machine Name</th>
+                            <td>@Model.MachineName</td>
+                        </tr>
+                        <tr>
+                            <th>Current Directory</th>
+                            <td>@Model.CurrentDirectory</td>
+                        </tr>
+                        <tr>
+                            <th>System Directory</th>
+                            <td>@Model.SystemDirectory</td>
+                        </tr>
+                        <tr>
+                            <th>Is 64 Bit OS</th>
+                            <td>@Model.Is64BitOperatingSystem</td>
+                        </tr>
+                        <tr>
+                            <th>Is 64 Bit Process</th>
+                            <td>@Model.Is64BitProcess</td>
+                        </tr>
+                        <tr>
+                            <th>Processor Count</th>
+                            <td>@Model.ProcessorCount</td>
+                        </tr>
+                        <tr>
+                            <th>TickCount (ms since startup)</th>
+                            <td>@Model.TickCount</td>
+                        </tr>
+                        <tr>
+                            <th>WorkingSet (Memory)</th>
+                            <td>@Model.WorkingSet MB</td>
+                        </tr>
+                        <tr>
+                            <th>Total Cpu Time</th>
+                            <td>@Model.TotalCpuTime</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <div class="col">
+            <h4 class="pb-2">CPU</h4>
+            <div class="table-responsive">
+                <table class="table table-bordered">
+                    <colgroup>
+                        <col width="40%" />
+                        <col width="60%" />
+                    </colgroup>
+                    <tbody>
+                        @foreach (var row in Model.CPU!)
+                        {
+                            <tr>
+                                <th>Device ID</th>
+                                <td>@row["DeviceID"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>SocketDesignation</th>
+                                <td>@row["SocketDesignation"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Load Percentage</th>
+                                <td>@row["LoadPercentage"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Cores</th>
+                                <td>@row["NumberOfCores"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Current Clock Speed</th>
+                                <td>@row["CurrentClockSpeed"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>AddressWidth</th>
+                                <td>@row["AddressWidth"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Architecture</th>
+                                <td>@row["Architecture"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Cores</th>
+                                <td>@row["NumberOfCores"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Thread</th>
+                                <td>@row["NumberOfLogicalProcessors"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>L2 Cache Size</th>
+                                <td>@row["L2CacheSize"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>L3 Cache Size</th>
+                                <td>@row["L3CacheSize"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Status</th>
+                                <td>@row["Status"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Virtualization Firmware Enabled</th>
+                                <td>@row["VirtualizationFirmwareEnabled"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>VMMonitor Mode Extensions</th>
+                                <td>@row["VMMonitorModeExtensions"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Manufacturer</th>
+                                <td>@row["Manufacturer"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Description</th>
+                                <td>@row["Description"]?.ToString()</td>
+                            </tr>
+                        }
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <div class="col">
+            <h4 class="pb-2">Memory</h4>
+            <div class="table-responsive">
+                <table class="table table-bordered">
+                    <colgroup>
+                        <col width="40%" />
+                        <col width="60%" />
+                    </colgroup>
+                    <tbody>
+                        @foreach (var row in Model.Memory!)
+                        {
+                            foreach (var prop in row.Properties)
+                            {
+                                if (prop.Value is not null)
+                                {
+                                    <tr>
+                                        <th>@prop.Name</th>
+                                        <td>@prop.Value</td>
+                                    </tr>
+                                }
+                            }
+                        }
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <div class="col">
+            <h4 class="pb-2">Disk</h4>
+            <div class="table-responsive">
+                <table class="table table-bordered">
+                    <colgroup>
+                        <col width="40%" />
+                        <col width="60%" />
+                    </colgroup>
+                    <tbody>
+                        @foreach (var row in Model.Disk!)
+                        {
+                            <tr>
+                                <th>Index</th>
+                                <td>@row["Index"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Interface Type</th>
+                                <td>@row["InterfaceType"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Model</th>
+                                <td>@row["Model"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Size</th>
+                                <td>
+                                    @{
+                                        double microseconds = double.Parse(row["Size"]?.ToString());
+                                        var ts = TimeSpan.FromMicroseconds(microseconds);
+                                    }
+                                    @ts.ToString()
+                                </td>
+                            </tr>
+                            <tr>
+                                <th>Caption</th>
+                                <td>@row["Caption"]?.ToString()</td>
+                            </tr>
+                            <tr>
+                                <th>Description</th>
+                                <td>@row["Description"]?.ToString()</td>
+                            </tr>
+                        }
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    </div>
+</div>
+
+@section Styles {
+    <link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
+}

+ 91 - 0
Admin/Pages/Server/Info.cshtml.cs

@@ -0,0 +1,91 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.Diagnostics;
+using System.Management;
+using System.Runtime.InteropServices;
+
+namespace Admin.Areas.Identity.Pages.Server
+{
+    public class InfoModel : PageModel
+    {
+        private readonly IHostEnvironment _env;
+
+        // 앱, 호스트(Host)
+        public required string EnvironmentName { get; set; }
+        public required string ContentRootPath { get; set; }
+        public required string ApplicationName { get; set; }
+
+        // OS, Framework
+        public required string OSDescription { get; set; }
+        public required string OSArchitecture { get; set; }
+        public required string FrameworkDescription { get; set; }
+        public required string ProcessArchitecture { get; set; }
+
+        public long WorkingSet { get; set; }
+        public TimeSpan TotalCpuTime { get; set; }
+
+        // 시스템
+        public required string MachineName { get; set; }
+        public required string CurrentDirectory { get; set; }
+        public required string SystemDirectory { get; set; }
+        public bool Is64BitOperatingSystem { get; set; }
+        public bool Is64BitProcess { get; set; }
+        public int ProcessorCount { get; set; }
+
+        public required string TickCount { get; set; }
+
+        public ManagementObjectCollection? CPU { get; private set; }
+        public ManagementObjectCollection? Memory { get; private set; }
+        public ManagementObjectCollection? Disk { get; private set; }
+
+        public InfoModel(IHostEnvironment env)
+        {
+            _env = env;
+        }
+
+        public async Task<IActionResult> OnGetAsync()
+        {
+            var process = Process.GetCurrentProcess();
+
+            // 앱, 호스트(Host) 관련
+            EnvironmentName = _env.EnvironmentName;
+            ContentRootPath = _env.ContentRootPath;
+            ApplicationName = _env.ApplicationName;
+
+            // OS, Framework 관련
+            OSDescription = RuntimeInformation.OSDescription;
+            OSArchitecture = RuntimeInformation.OSArchitecture.ToString();
+            FrameworkDescription = RuntimeInformation.FrameworkDescription;
+            ProcessArchitecture = RuntimeInformation.ProcessArchitecture.ToString();
+
+            WorkingSet = process.WorkingSet64 / 1048576; // 물리 메모리 사용량(바이트)
+            TotalCpuTime = process.TotalProcessorTime; // 프로세스 실행 시간
+
+            // 시스템(환경변수) 관련
+            MachineName = Environment.MachineName;
+            CurrentDirectory = Environment.CurrentDirectory;
+            SystemDirectory = Environment.SystemDirectory;
+            Is64BitOperatingSystem = Environment.Is64BitOperatingSystem;
+            Is64BitProcess = Environment.Is64BitProcess;
+            ProcessorCount = Environment.ProcessorCount;
+
+            // 시작 시각 등
+            TickCount = TimeSpan.FromMicroseconds(Environment.TickCount).ToString(); // 시스템 시작 후 경과 시간(밀리초)
+
+            // 플랫폼 조건부 코드로 Windows에서만 실행되도록 수정
+            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+            {
+                // CPU 정보
+                CPU = new ManagementObjectSearcher("select * from Win32_Processor").Get();
+
+                // 메모리 정보
+                Memory = new ManagementObjectSearcher("select * from Win32_MemoryDevice").Get();
+
+                // 디스크 정보
+                Disk = new ManagementObjectSearcher("select * from Win32_DiskDrive").Get();
+            }
+
+            return Page();
+        }
+    }
+}

+ 7 - 0
Admin/Pages/Shared/Components/Layout/Default.cshtml

@@ -0,0 +1,7 @@
+@model Admin.Pages.Shared.Layout.LayoutViewModel
+
+@*
+    This view is intentionally empty.
+    The `Layout` view component is invoked from `Pages/Shared/_Layout.cshtml` only to build a `LayoutViewModel`.
+    Its HTML is rendered by the layout itself.
+*@

+ 8 - 0
Admin/Pages/Shared/Config/_navTabs.cshtml

@@ -0,0 +1,8 @@
+<ul class="nav nav-tabs">
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Config/Basic")" asp-page="/Config/Basic">기본</a>
+    </li>
+    <li class="nav-item">
+        <a class="nav-link @Html.IsActive("/Config/Images")" asp-page="/Config/Images">이미지</a>
+    </li>
+</ul>

+ 13 - 6
Admin/Pages/Shared/Layout/LayoutDataProvider.cs

@@ -2,6 +2,9 @@
 using Admin.Constants;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.Extensions.Options;
+using Microsoft.AspNetCore.Identity;
+using Infrastructure.Persistence.Identity;
+using System.Security.Claims;
 
 namespace Admin.Pages.Shared.Layout
 {
@@ -9,26 +12,30 @@ namespace Admin.Pages.Shared.Layout
     {
         private readonly AppSettings _settings;
         private readonly IAuthorizationService _authorizationService;
+        private readonly UserManager<ApplicationUser> _userManager;
 
-        public LayoutDataProvider(IOptions<AppSettings> options, IAuthorizationService authorizationService)
+        public LayoutDataProvider(IOptions<AppSettings> options, IAuthorizationService authorizationService, UserManager<ApplicationUser> userManager)
         {
             _settings = options.Value;
             _authorizationService = authorizationService;
+            _userManager = userManager;
         }
 
         public async Task<LayoutViewModel> CreateAsync(HttpContext context)
         {
-            var user = context.User;
+            var principal = context.User;
+
+            var appUser = await _userManager.GetUserAsync(principal);
 
             // Identity 권한/역할 기반 메뉴 필터링
-            var filteredMenus = await Menus.FilterForUserAsync(user, _authorizationService);
+            var filteredMenus = await Menus.FilterForUserAsync(principal, _authorizationService);
 
             return new LayoutViewModel
             {
-                UserName = user.Identity?.Name ?? "Guest",
-                Role = user.FindFirst("role")?.Value ?? string.Empty,
+                UserName = appUser?.UserName ?? appUser?.Email ?? principal.Identity?.Name ?? string.Empty,
+                Role = principal.FindFirst(ClaimTypes.Role)?.Value ?? principal.FindFirst("role")?.Value ?? string.Empty,
                 AppSettings = _settings,
-                Menus = filteredMenus
+                Menus = Menus.GetMenus()
             };
         }
     }

+ 241 - 0
Admin/Pages/Shared/_Editor.cshtml

@@ -0,0 +1,241 @@
+<link rel="stylesheet" href="~/lib/ckeditor/browser/ckeditor5.css" asp-append-version="true" />
+
+<script type="module">
+    import {
+        ClassicEditor, Essentials, Paragraph, Bold, Italic, Underline, Strikethrough, Code, Subscript, Superscript, RemoveFormat, List, TodoList,
+        Indent, Heading, Font, Highlight, Alignment, Link, Image, ImageToolbar, ImageCaption, ImageStyle, ImageResize, ImageUpload, MediaEmbed, CodeBlock,
+        HtmlEmbed, SpecialCharacters, HorizontalLine, PageBreak, SourceEditing, FindAndReplace, SelectAll, BlockQuote, Table, TableToolbar, TextPartLanguage
+    } from "/lib/ckeditor/browser/ckeditor5.js";
+
+    class CustomImageAttributesPlugin {
+        constructor(editor) {
+            this.editor = editor;
+        }
+
+        init() {
+            const editor = this.editor;
+            const schema = editor.model.schema;
+
+            // data-* 속성을 추가
+            const attributes = ['data-name', 'data-extension', 'data-size', 'data-type', 'data-width', 'data-height'];
+            schema.extend('imageBlock', {allowAttributes: attributes});
+            schema.extend('imageInline', {allowAttributes: attributes});
+
+            editor.plugins.get('ImageUploadEditing').on('uploadComplete', (evt, { data, imageElement }) => {
+                editor.model.change((writer) => {
+                    writer.setAttribute('data-name', data.name, imageElement);
+                    writer.setAttribute('data-extension', data.extension, imageElement);
+                    writer.setAttribute('data-size', data.size, imageElement);
+                    writer.setAttribute('data-type', data.type, imageElement);
+                });
+            });
+
+            attributes.forEach(attr => {
+                editor.conversion.for('upcast').attributeToAttribute({ model: attr, view: attr });
+            });
+
+            attributes.forEach(attr => {
+                editor.conversion.for('downcast').attributeToAttribute({ model: attr, view: attr });
+                editor.conversion.for('editingDowncast').attributeToAttribute({ model: attr, view: attr });
+            });
+
+            const updateDataAttribute = (evt, data, conversionApi) => {
+                const viewWriter = conversionApi.writer;
+                const viewElement = conversionApi.mapper.toViewElement(data.item);
+
+                if (viewElement) {
+                    const imgElement = viewElement.getChild(0);
+                    if (imgElement && imgElement.is('element', 'img')) {
+                        viewWriter.setAttribute(data.attributeKey, data.attributeNewValue, imgElement);
+                    }
+                }
+            };
+
+            attributes.forEach(attribute => {
+                editor.conversion.for('downcast').add(dispatcher => {
+                    dispatcher.on(`attribute:${attribute}:imageBlock`, (evt, data, conversionApi) => {
+                        updateDataAttribute(evt, data, conversionApi);
+                    });
+                });
+
+                editor.conversion.for('editingDowncast').add(dispatcher => {
+                    dispatcher.on(`attribute:${attribute}:imageInline`, (evt, data, conversionApi) => {
+                        updateDataAttribute(evt, data, conversionApi);
+                    });
+                });
+            });
+        }
+    }
+
+    class CustomUploadAdapter {
+        constructor(loader) {
+            this.loader = loader;
+        }
+
+        upload() {
+            return this.loader.file.then(file => {
+                return new Promise((resolve, reject) => {
+                    const reader = new FileReader();
+
+                    reader.onload = () => {
+                        resolve({
+                            default: reader.result,
+                            name: file.name,
+                            extension: file.name.split('.').pop(),
+                            size: file.size,
+                            type: file.type
+                        });
+                    };
+
+                    reader.onerror = error => reject(error);
+                    reader.readAsDataURL(file);
+                });
+            });
+        }
+
+        abort() {
+            console.log('업로드가 취소되었습니다.');
+        }
+    }
+
+    // CKEditor 저장, 전역 변수로 해서 다른 곳에서 접근할 수 있도록 함
+    window.editorInstances = window.editorInstances || new Map();
+
+    // CKEditor 초기화
+    window.initEditor = async function(domID, config = {})
+    {
+        const el = document.getElementById(domID);
+        if (!el || window.editorInstances.has(domID)) {
+            return;
+        }
+
+        return ClassicEditor
+            .create(el, {
+                licenseKey: 'GPL',
+                extraPlugins: [CustomUploadAdapter, CustomImageAttributesPlugin],
+                plugins: [
+                    Essentials, Paragraph, Bold, Italic, Underline, Strikethrough, Code,
+                    Subscript, Superscript, RemoveFormat, List, TodoList, Indent, Heading,
+                    Font, Highlight, Alignment, Link, Image, ImageToolbar, ImageCaption,
+                    ImageStyle, ImageResize, ImageUpload, MediaEmbed, CodeBlock, HtmlEmbed,
+                    SpecialCharacters, HorizontalLine, PageBreak, SourceEditing,
+                    FindAndReplace, SelectAll, BlockQuote, Table, TableToolbar, TextPartLanguage
+                ],
+                toolbar: {
+                    items: [
+                        'findAndReplace', 'selectAll', '|',
+                        'heading', '|',
+                        'bold', 'italic', 'strikethrough', 'underline', 'code', 'subscript', 'superscript', 'removeFormat', '|',
+                        'bulletedList', 'numberedList', 'todoList', '|',
+                        'outdent', 'indent', '|',
+                        'undo', 'redo', '|',
+                        'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'highlight', '|',
+                        'alignment', '|',
+                        'link', 'insertImage', 'blockQuote', 'insertTable', 'mediaEmbed', 'codeBlock', 'htmlEmbed', '|',
+                        'specialCharacters', 'horizontalLine', 'pageBreak', '|',
+                        'textPartLanguage', '|',
+                        'sourceEditing'
+                    ],
+                    shouldNotGroupWhenFull: true
+                },
+                list: {
+                    properties: {
+                        styles: true,
+                        startIndex: true,
+                        reversed: true
+                    }
+                },
+                heading: {
+                    options: [
+                        { model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
+                        { model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
+                        { model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
+                        { model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
+                        { model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' },
+                        { model: 'heading5', view: 'h5', title: 'Heading 5', class: 'ck-heading_heading5' },
+                        { model: 'heading6', view: 'h6', title: 'Heading 6', class: 'ck-heading_heading6' }
+                    ]
+                },
+                placeholder: '내용을 입력해주세요.',
+                fontFamily: {
+                    options: [
+                        'default',
+                        'Arial, Helvetica, sans-serif',
+                        'Courier New, Courier, monospace',
+                        'Georgia, serif',
+                        'Lucida Sans Unicode, Lucida Grande, sans-serif',
+                        'Tahoma, Geneva, sans-serif',
+                        'Times New Roman, Times, serif',
+                        'Trebuchet MS, Helvetica, sans-serif',
+                        'Verdana, Geneva, sans-serif'
+                    ],
+                    supportAllValues: true
+                },
+                fontSize: {
+                    options: [10, 12, 14, 'default', 18, 20, 22],
+                    supportAllValues: true
+                },
+                htmlSupport: {
+                    allow: [
+                        {
+                            name: /.*/,
+                            attributes: true,
+                            classes: true,
+                            styles: true
+                        }
+                    ]
+                },
+                htmlEmbed: {
+                    showPreviews: true
+                },
+                link: {
+                    decorators: {
+                        addTargetToExternalLinks: true,
+                        defaultProtocol: 'https://',
+                        toggleDownloadable: {
+                            mode: 'manual',
+                            label: 'Downloadable',
+                            attributes: {
+                                download: 'file'
+                            }
+                        }
+                    }
+                },
+                image: {
+                    toolbar: [
+                        'imageStyle:inline', 'imageStyle:block', 'imageStyle:side', '|',
+                        'toggleImageCaption', 'imageTextAlternative'
+                    ]
+                },
+                table: {
+                    contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells']
+                },
+                simpleUpload: {
+                    uploadUrl: null
+                },
+                ...config
+            })
+            .then(editor => {
+                editor.plugins.get('FileRepository').createUploadAdapter = loader => new CustomUploadAdapter(loader);
+
+                // 에디터 저장
+                window.editorInstances.set(domID, editor);
+            })
+            .catch(error => {
+                console.error('Error initializing CKEditor:', error);
+            });
+    };
+
+    // 에디터 제거
+    window.destroyEditor = async function(domID) {
+        const editor = window.editorInstances.get(domID);
+        if (editor) {
+            await editor.destroy();
+            window.editorInstances.delete(domID);
+        }
+    };
+
+    document.querySelectorAll('.ck-editor').forEach(e => {
+        initEditor(e.id);
+    });
+</script>

+ 9 - 6
Admin/Pages/Shared/_Layout.cshtml

@@ -1,6 +1,7 @@
 @using Admin.Pages.Shared.Layout
+@inject ILayoutDataProvider LayoutDataProvider
 @{
-	var layoutViewModel = Context.Items["layoutViewModel"] as LayoutViewModel;
+	var layoutViewModel = await LayoutDataProvider.CreateAsync(Context);
 }
 <!DOCTYPE html>
 <html lang="ko">
@@ -27,7 +28,7 @@
 	<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
 	<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.min.css" />
 	<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
-	<link rel="stylesheet" href="~/Admin.styles.css" asp-append-version="true" />
+	<link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
 
 	@await RenderSectionAsync("Styles", required: false)
 </head>
@@ -54,14 +55,16 @@
 					</button>
 				</div>
 				<div class="col text-center">
-					<strong class="logo">
-						<img src="/images/favicon.ico" /> @(layoutViewModel?.AppSettings?.App.Name ?? string.Empty)
-					</strong>
+					<a href="/" ref="home" target="_self" class="logo">
+						<strong>
+							<img src="/images/favicon.ico" /> @(layoutViewModel?.AppSettings?.App.Name ?? string.Empty)
+						</strong>
+					</a>
 				</div>
 				<div class="col text-end">
 					<div class="dropdown profile">
 						<button class="btn btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
-							<span class="profile-text">@(User.Identity?.Name ?? "-")</span>
+							<span class="profile-text">@layoutViewModel?.UserName</span>
 							<i class="bi bi-person-fill profile-icon"></i>
 						</button>
 						<ul class="dropdown-menu">

+ 62 - 0
Admin/Pages/Shared/_MenuItem.cshtml

@@ -0,0 +1,62 @@
+@model Admin.Constants.Menu
+@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContextAccessor
+@{
+    // 현재 요청 경로 가져오기
+    var currentPath = HttpContextAccessor.HttpContext?.Request?.Path.ToString()?.TrimEnd('/');
+
+    // 고정된 메뉴 경로
+    var menuPath = (Model.Path?.TrimEnd('/') ?? string.Empty).ToLower();
+
+    bool isActive = false;
+    bool isParentActive = false;
+
+    // "게시판 관리" 특별 처리
+    if (Model.Name.Equals("게시판 관리", StringComparison.OrdinalIgnoreCase))
+    {
+        string[] activePrefixes = { "/forum/board/meta", "/forum/board/prefix", "/forum/board/manager", "/forum/board/list" };
+        isActive = currentPath != null && activePrefixes.Any(prefix =>
+            currentPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+        );
+    }
+    else if (Model.Children != null && Model.Children.Any())
+    {
+        // 자식 메뉴 중 하나가 활성화되었는지 확인
+        isParentActive = Model.Children.Any(child =>
+            !string.IsNullOrEmpty(currentPath) &&
+            !string.IsNullOrEmpty(child.Path) &&
+            (currentPath.Equals(child.Path, StringComparison.OrdinalIgnoreCase) || currentPath.StartsWith(child.Path + "/", StringComparison.OrdinalIgnoreCase))
+        );
+
+        // 부모 메뉴 자체는 active 주지 않음
+        isActive = false;
+    }
+    else
+    {
+        // 일반 메뉴 active 여부 판단
+        isActive = !string.IsNullOrEmpty(currentPath) && !string.IsNullOrEmpty(menuPath) && (currentPath.Equals(menuPath, StringComparison.OrdinalIgnoreCase) || currentPath.StartsWith(menuPath + "/", StringComparison.OrdinalIgnoreCase));
+    }
+}
+<li class="nav-item">
+    <a href="@Model.Path" class="nav-link @(isActive ? "active" : "") @Html.Raw(Model.Children == null || !Model.Children.Any() ? "collapsed" : "")"
+        @Html.Raw(Model.Children != null && Model.Children.Any() ? "data-bs-toggle=\"collapse\"" : "")
+        @Html.Raw(Model.Children != null && Model.Children.Any() ? $"data-bs-target=\"#menu-{Model.Id}\"" : "")
+    >
+        @if (!string.IsNullOrEmpty(Model.Icon))
+        {
+            @Html.Raw(Model.Icon);
+        }
+
+        @Model.Name
+    </a>
+
+    @if (Model.Children != null && Model.Children.Any())
+    {
+        <ul id="menu-@Model.Id" class="nav flex-column flex-nowrap ps-3 collapse">
+            @foreach (var child in Model.Children)
+            {
+                @* @Html.Partial("_MenuItem", child); *@
+                <partial name="_MenuItem" model="child" />
+            }
+        </ul>
+    }
+</li>

+ 41 - 0
Admin/Pages/Shared/_StatusMessage.cshtml

@@ -0,0 +1,41 @@
+@{
+    var successMessage = TempData["SuccessMessage"] as string;
+    var errorMessages = TempData["ErrorMessages"] as string;
+}
+
+<div class="pt-2">
+    <!-- 성공 메시지 표시 -->
+    @if (!string.IsNullOrEmpty(successMessage))
+    {
+        <div class="alert alert-success alert-dismissible fade show" role="alert">
+            @successMessage
+            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+        </div>
+    }
+
+    <!-- 실패 메시지 표시 -->
+    @if (!string.IsNullOrEmpty(errorMessages))
+    {
+        <div class="alert alert-danger alert-dismissible fade show" role="alert">
+            @errorMessages
+            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+        </div>
+    }
+
+    <!-- 유효성 검증 오류 메시지 표시 -->
+    @if (!ViewData.ModelState.IsValid)
+    {
+        <div class="alert alert-danger alert-dismissible fade show" role="alert">
+            <ul>
+                @foreach (var modelState in ViewData.ModelState.Values)
+                {
+                    foreach (var error in modelState.Errors)
+                    {
+                        <li>@error.ErrorMessage</li>
+                    }
+                }
+            </ul>
+            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
+        </div>
+    }
+</div>

+ 2 - 1
Admin/Pages/Shared/_Sub.cshtml

@@ -1,6 +1,7 @@
 @using Admin.Pages.Shared.Layout
+@inject ILayoutDataProvider LayoutDataProvider
 @{
-	var layoutViewModel = Context.Items["layoutViewModel"] as LayoutViewModel;
+	var layoutViewModel = await LayoutDataProvider.CreateAsync(Context);
 }
 <!DOCTYPE html>
 <html lang="ko">

+ 1 - 0
Admin/Pages/_ViewImports.cshtml

@@ -1,3 +1,4 @@
 @using Admin
+@using Admin.Extensions
 @namespace Admin.Pages
 @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

+ 2 - 2
Admin/Program.cs

@@ -54,10 +54,10 @@ var app = builder.Build();
 // 환경변수 불러오기
 var env = Environment.GetEnvironmentVariable("environmentVariables");
 
-// 환경변수를 static 변수로 저장
+
 app.Use(async (context, next) =>
 {
-    context.Items["env"] = env;
+    context.Items["env"] = env; // 환경변수를 static 변수로 저장
     await next();
 });
 

+ 13 - 6
Admin/appsettings.json

@@ -6,11 +6,14 @@
             "Microsoft.EntityFrameWorkCore.Database.Command": "Warning"
         }
     },
+
     "AllowedHosts": "*",
+
     "ConnectionStrings": {
         "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=AdminContext-87208056-7d2d-412a-b7f8-8e6c942ea8c8;Trusted_Connection=True;MultipleActiveResultSets=true",
         "IdentityDbContextConnection": "Server=(localdb)\\mssqllocaldb;Database=Admin;Trusted_Connection=True;MultipleActiveResultSets=true"
     },
+
     "Kestrel": {
         "Endpoints": {
             "Http": {
@@ -21,6 +24,7 @@
             "MaxRequestBodySize": 52428800
         }
     },
+
     "App": {
         "Name": "bitforum Admin",
         "Company": "PLAYR",
@@ -28,22 +32,25 @@
         "ApiURL": "https://localhost:4000",
         "FrontURL": "https://localhost:3000"
     },
+
     "Redis": {
-        "DefaultConnection": "localhost:6379,password=bluescreen!!,defaultDatabase=4",
+        "DefaultConnection": "192.168.0.100:6379,password=bluescreen!!,defaultDatabase=4",
         "CachePrefix": "Admin:Cache",
         "AuthTicketPrefix": "Admin:Auth:Ticket",
         "DataProtectionKey": "Admin:DataProtection-Keys",
         "DefaultKeyLifetime": "90.00:00:00"
     },
+
     "SMTP": {
-        "Server": "mail.web.or.kr",
+        "Host": "mail.web.or.kr",
         "Port": 587,
-        "EnableSSL": true,
-        "Username": "dev@web.or.kr",
+        "User": "dev@web.or.kr",
         "Password": "@@17125942KKh",
-        "FromEmail": "help@bitforum.io",
-        "FromName": "Admin"
+        "UseStartTls": true,
+        "FromEmail": "support@bitforum.io",
+        "FromName": "bitforum"
     },
+
     "ForwardedHeaders": {
         "ForwardLimit": 5,
         "KnownProxies": [

+ 1 - 1
Admin/wwwroot/css/account.css

@@ -1 +1 @@
-#loginForm section:first-of-type,#registForm section:first-of-type,#forgotPasswordForm section:first-of-type,#resendEmailConfirmForm section:first-of-type,#resetPasswordForm section:first-of-type{max-width:410px;margin:0 auto}#loginForm section:first-of-type p,#registForm section:first-of-type p,#forgotPasswordForm section:first-of-type p,#resendEmailConfirmForm section:first-of-type p,#resetPasswordForm section:first-of-type p{margin-bottom:0.438rem}#loginForm section:first-of-type p a,#registForm section:first-of-type p a,#forgotPasswordForm section:first-of-type p a,#resendEmailConfirmForm section:first-of-type p a,#resetPasswordForm section:first-of-type p a{-webkit-text-decoration:none;text-decoration:none}#loginForm section:first-of-type p a:hover,#registForm section:first-of-type p a:hover,#forgotPasswordForm section:first-of-type p a:hover,#resendEmailConfirmForm section:first-of-type p a:hover,#resetPasswordForm section:first-of-type p a:hover{text-decoration:underline}#loginForm section:first-of-type .field-validation-error,#registForm section:first-of-type .field-validation-error,#forgotPasswordForm section:first-of-type .field-validation-error,#resendEmailConfirmForm section:first-of-type .field-validation-error,#resetPasswordForm section:first-of-type .field-validation-error{display:block;padding:7px 0 0 0}
+#loginForm section:first-of-type,#registForm section:first-of-type,#forgotPasswordForm section:first-of-type,#resendEmailConfirmForm section:first-of-type,#resetPasswordForm section:first-of-type{max-width:410px;margin:0 auto}#loginForm section:first-of-type p,#registForm section:first-of-type p,#forgotPasswordForm section:first-of-type p,#resendEmailConfirmForm section:first-of-type p,#resetPasswordForm section:first-of-type p{margin-bottom:.438rem}#loginForm section:first-of-type p a,#registForm section:first-of-type p a,#forgotPasswordForm section:first-of-type p a,#resendEmailConfirmForm section:first-of-type p a,#resetPasswordForm section:first-of-type p a{-webkit-text-decoration:none;text-decoration:none}#loginForm section:first-of-type p a:hover,#registForm section:first-of-type p a:hover,#forgotPasswordForm section:first-of-type p a:hover,#resendEmailConfirmForm section:first-of-type p a:hover,#resetPasswordForm section:first-of-type p a:hover{text-decoration:underline}#loginForm section:first-of-type .field-validation-error,#registForm section:first-of-type .field-validation-error,#forgotPasswordForm section:first-of-type .field-validation-error,#resendEmailConfirmForm section:first-of-type .field-validation-error,#resetPasswordForm section:first-of-type .field-validation-error{display:block;padding:7px 0 0 0}

+ 1 - 1
Admin/wwwroot/css/admin.css

@@ -1 +1 @@
-table{width:100%}table tr th,table tr td{-ms-word-wrap:inherit;word-wrap:inherit;overflow-wrap:break-word;text-align:center;vertical-align:middle}table thead tr th{background-color:#f7f7f6 !important}table thead tr th,table thead tr td{text-align:center}table tbody.striped:hover tr td{background-color:#f1f1f1 !important}#fAdminWrite div.row>label>span,#fAdminEdit div.row>label>span{color:red}.select-search-member{z-index:1050;position:relative;width:100%;top:115%;left:0;right:0;display:none;border-width:1px;border-color:red;overflow-y:auto}.select-search-member .list-group-item{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.select-search-member .list-group-item.active{color:#fff}.select-search-member .list-group-item:hover{color:#ff0}.select-search-member .no-results{color:#6c757d}
+table{width:100%}table tr th,table tr td{-ms-word-wrap:inherit;word-wrap:inherit;overflow-wrap:break-word;text-align:center;vertical-align:middle}table thead tr th{background-color:#f7f7f6 !important}table thead tr th,table thead tr td{text-align:center}table tbody.striped:hover tr td{background-color:#f1f1f1 !important}#fAdminWrite div.row>label>span,#fAdminEdit div.row>label>span{color:red}.select-search-member{z-index:1050;position:relative;width:100%;top:115%;left:0;right:0;display:none;border-width:1px;border-color:red;overflow-y:auto}.select-search-member .list-group-item{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}.select-search-member .list-group-item.active{color:#fff}.select-search-member .list-group-item:hover{color:#ff0}.select-search-member .no-results{color:#6c757d}.field-validation-error{display:block;padding:7px 0 0 0}

+ 1 - 1
Admin/wwwroot/css/site.css

@@ -1 +1 @@
-html,body{height:100vh;margin:0;padding:0}body{display:grid;grid-template-rows:auto 1fr;grid-template-columns:minmax(200px, 0.17fr) 1fr;grid-gap:0}body.hidden-aside{grid-template-columns:0 auto}body.hidden-aside aside{transform:translateX(-100%);opacity:0}body aside{position:relative;height:inherit;background-color:#f9f9f9;border-right:1px solid #ddd;overflow-y:auto;transition:transform .3s ease,opacity .3s ease;transform:translateX(0);display:flex;-ms-flex-direction:inherit;-webkit-flex-direction:inherit;flex-direction:column}body aside>ul.nav{flex-grow:1}body aside>ul.nav>li.nav-item:first-of-type>a{border-bottom:1px solid #ddd}body aside>ul.nav>li.nav-item:not(:first-of-type){border-bottom:1px solid #ddd}body aside>ul.nav>li.nav-item:not(:first-of-type)>a:not(.collapsed){background-color:#f1f1f1}body aside>ul.nav>li.nav-item>ul{padding:.625rem .625rem .625rem 0;border-top:1px solid #ddd}body aside>ul.nav>li.nav-item ul{transition:none}body aside>ul.nav>li.nav-item a{display:block;padding:.438rem .781rem;color:#333;text-decoration:none;cursor:pointer}body aside>ul.nav>li.nav-item a:hover{background-color:#f0f0f0}body aside>ul.nav>li.nav-item a.active{background-color:#e9e9e9;outline:1px solid #ddd}body aside footer{padding:.781rem;text-align:center;border-top:1px solid #ddd}body aside footer a{color:#333;text-decoration:none}body aside footer a:hover{color:blue;text-decoration:underline}body main{background-color:#fff;height:inherit;overflow-y:auto}body main>header{border-bottom:1px solid #ddd;padding:.313rem .781rem}body main>header .logo img{position:relative;top:-2px}body main>header .profile-icon{display:none}body main>div.container-fluid{padding:.781rem}@media(max-width: 768px){body main>header .profile-text{display:none}body main>header .profile-icon{display:inline-block}}
+html,body{height:100vh;margin:0;padding:0}body{display:grid;grid-template-rows:auto 1fr;grid-template-columns:minmax(200px, 0.17fr) 1fr;grid-gap:0}body.hidden-aside{grid-template-columns:0 auto}body.hidden-aside aside{transform:translateX(-100%);opacity:0}body aside{position:relative;height:inherit;background-color:#f9f9f9;border-right:1px solid #ddd;overflow-y:auto;transition:transform .3s ease,opacity .3s ease;transform:translateX(0);display:flex;-ms-flex-direction:inherit;-webkit-flex-direction:inherit;flex-direction:column}body aside>ul.nav{flex-grow:1}body aside>ul.nav>li.nav-item:first-of-type>a{border-bottom:1px solid #ddd}body aside>ul.nav>li.nav-item:not(:first-of-type){border-bottom:1px solid #ddd}body aside>ul.nav>li.nav-item:not(:first-of-type)>a:not(.collapsed){background-color:#f1f1f1}body aside>ul.nav>li.nav-item>ul{padding:.625rem .625rem .625rem 0;border-top:1px solid #ddd}body aside>ul.nav>li.nav-item ul{transition:none}body aside>ul.nav>li.nav-item a{display:block;padding:.438rem .781rem;color:#333;text-decoration:none;cursor:pointer}body aside>ul.nav>li.nav-item a:hover{background-color:#f0f0f0}body aside>ul.nav>li.nav-item a.active{background-color:#e9e9e9;outline:1px solid #ddd}body aside footer{padding:.781rem;text-align:center;border-top:1px solid #ddd}body aside footer a{color:#333;text-decoration:none}body aside footer a:hover{color:blue;text-decoration:underline}body main{background-color:#fff;height:inherit;overflow-y:auto}body main>header{border-bottom:1px solid #ddd;padding:.313rem .781rem}body main>header .logo{color:#333;text-decoration:none}body main>header .logo:hover{text-decoration:underline}body main>header .logo img{position:relative;top:-2px}body main>header .profile-icon{display:none}body main>div.container-fluid{padding:.781rem}@media(max-width: 768px){body main>header .profile-text{display:none}body main>header .profile-icon{display:inline-block}}

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5-content.css


Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5-editor.css


Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5.css.map


Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5.js.map


Файловите разлики са ограничени, защото са твърде много
+ 10 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5.umd.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
Admin/wwwroot/lib/ckeditor/browser/ckeditor5.umd.js.map


+ 493 - 0
Admin/wwwroot/lib/ckeditor/ckeditor5-content.css

@@ -0,0 +1,493 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+:root{
+	--ck-color-mention-background:hsla(341, 100%, 30%, 0.1);
+	--ck-color-mention-text:hsl(341, 100%, 30%);
+}
+.ck-content .mention{
+	background:var(--ck-color-mention-background);
+	color:var(--ck-color-mention-text);
+}
+
+.ck-content code{
+	background-color:hsla(0, 0%, 78%, 0.3);
+	padding:.15em;
+	border-radius:2px;
+}
+
+.ck-content blockquote{
+	overflow:hidden;
+	padding-right:1.5em;
+	padding-left:1.5em;
+
+	margin-left:0;
+	margin-right:0;
+	font-style:italic;
+	border-left:solid 5px hsl(0, 0%, 80%);
+}
+
+.ck-content[dir="rtl"] blockquote{
+	border-left:0;
+	border-right:solid 5px hsl(0, 0%, 80%);
+}
+
+.ck-content pre{
+	padding:1em;
+	color:hsl(0, 0%, 20.8%);
+	background:hsla(0, 0%, 78%, 0.3);
+	border:1px solid hsl(0, 0%, 77%);
+	border-radius:2px;
+	text-align:left;
+	direction:ltr;
+
+	tab-size:4;
+	white-space:pre-wrap;
+	font-style:normal;
+	min-width:200px;
+}
+
+.ck-content pre code{
+		background:unset;
+		padding:0;
+		border-radius:0;
+	}
+.ck-content .text-tiny{
+		font-size:.7em;
+	}
+.ck-content .text-small{
+		font-size:.85em;
+	}
+.ck-content .text-big{
+		font-size:1.4em;
+	}
+.ck-content .text-huge{
+		font-size:1.8em;
+	}
+
+:root{
+	--ck-highlight-marker-yellow:hsl(60, 97%, 73%);
+	--ck-highlight-marker-green:hsl(120, 93%, 68%);
+	--ck-highlight-marker-pink:hsl(345, 96%, 73%);
+	--ck-highlight-marker-blue:hsl(201, 97%, 72%);
+	--ck-highlight-pen-red:hsl(0, 85%, 49%);
+	--ck-highlight-pen-green:hsl(112, 100%, 27%);
+}
+
+.ck-content .marker-yellow{
+		background-color:var(--ck-highlight-marker-yellow);
+	}
+.ck-content .marker-green{
+		background-color:var(--ck-highlight-marker-green);
+	}
+.ck-content .marker-pink{
+		background-color:var(--ck-highlight-marker-pink);
+	}
+.ck-content .marker-blue{
+		background-color:var(--ck-highlight-marker-blue);
+	}
+
+.ck-content .pen-red{
+		color:var(--ck-highlight-pen-red);
+		background-color:transparent;
+	}
+.ck-content .pen-green{
+		color:var(--ck-highlight-pen-green);
+		background-color:transparent;
+	}
+
+.ck-content hr{
+	margin:15px 0;
+	height:4px;
+	background:hsl(0, 0%, 87%);
+	border:0;
+}
+
+:root{
+	--ck-color-image-caption-background:hsl(0, 0%, 97%);
+	--ck-color-image-caption-text:hsl(0, 0%, 20%);
+}
+.ck-content .image > figcaption{
+	display:table-caption;
+	caption-side:bottom;
+	word-break:break-word;
+	color:var(--ck-color-image-caption-text);
+	background-color:var(--ck-color-image-caption-background);
+	padding:.6em;
+	font-size:.75em;
+	outline-offset:-1px;
+}
+@media (forced-colors: active){
+.ck-content .image > figcaption{
+		background-color:unset;
+		color:unset;
+}
+	}
+.ck-content img.image_resized{
+	height:auto;
+}
+
+.ck-content .image.image_resized{
+	max-width:100%;
+	display:block;
+	box-sizing:border-box;
+}
+
+.ck-content .image.image_resized img{
+		width:100%;
+	}
+
+.ck-content .image.image_resized > figcaption{
+		display:block;
+	}
+
+:root{
+	--ck-image-style-spacing:1.5em;
+	--ck-inline-image-style-spacing:calc(var(--ck-image-style-spacing) / 2);
+}
+
+.ck-content .image.image-style-block-align-left,
+		.ck-content .image.image-style-block-align-right{
+			max-width:calc(100% - var(--ck-image-style-spacing));
+		}
+
+.ck-content .image.image-style-align-left,
+		.ck-content .image.image-style-align-right{
+			clear:none;
+		}
+
+.ck-content .image.image-style-side{
+			float:right;
+			margin-left:var(--ck-image-style-spacing);
+			max-width:50%;
+		}
+
+.ck-content .image.image-style-align-left{
+			float:left;
+			margin-right:var(--ck-image-style-spacing);
+		}
+
+.ck-content .image.image-style-align-right{
+			float:right;
+			margin-left:var(--ck-image-style-spacing);
+		}
+
+.ck-content .image.image-style-block-align-right{
+			margin-right:0;
+			margin-left:auto;
+		}
+
+.ck-content .image.image-style-block-align-left{
+			margin-left:0;
+			margin-right:auto;
+		}
+
+.ck-content .image-style-align-center{
+		margin-left:auto;
+		margin-right:auto;
+	}
+
+.ck-content .image-style-align-left{
+		float:left;
+		margin-right:var(--ck-image-style-spacing);
+	}
+
+.ck-content .image-style-align-right{
+		float:right;
+		margin-left:var(--ck-image-style-spacing);
+	}
+
+.ck-content p + .image.image-style-align-left,
+	.ck-content p + .image.image-style-align-right,
+	.ck-content p + .image.image-style-side{
+		margin-top:0;
+	}
+
+.ck-content .image-inline.image-style-align-left,
+		.ck-content .image-inline.image-style-align-right{
+			margin-top:var(--ck-inline-image-style-spacing);
+			margin-bottom:var(--ck-inline-image-style-spacing);
+		}
+
+.ck-content .image-inline.image-style-align-left{
+			margin-right:var(--ck-inline-image-style-spacing);
+		}
+
+.ck-content .image-inline.image-style-align-right{
+			margin-left:var(--ck-inline-image-style-spacing);
+		}
+
+.ck-content .image{
+		display:table;
+		clear:both;
+		text-align:center;
+		margin:0.9em auto;
+		min-width:50px;
+	}
+
+.ck-content .image img{
+			display:block;
+			margin:0 auto;
+			max-width:100%;
+			min-width:100%;
+			height:auto;
+		}
+
+.ck-content .image-inline{
+		display:inline-flex;
+		max-width:100%;
+		align-items:flex-start;
+	}
+
+.ck-content .image-inline picture{
+			display:flex;
+		}
+
+.ck-content .image-inline picture,
+		.ck-content .image-inline img{
+			flex-grow:1;
+			flex-shrink:1;
+			max-width:100%;
+		}
+
+.ck-content ol{
+	list-style-type:decimal;
+}
+
+.ck-content ol ol{
+		list-style-type:lower-latin;
+	}
+
+.ck-content ol ol ol{
+			list-style-type:lower-roman;
+		}
+
+.ck-content ol ol ol ol{
+				list-style-type:upper-latin;
+			}
+
+.ck-content ol ol ol ol ol{
+					list-style-type:upper-roman;
+				}
+
+.ck-content ul{
+	list-style-type:disc;
+}
+
+.ck-content ul ul{
+		list-style-type:circle;
+	}
+
+.ck-content ul ul ul{
+			list-style-type:square;
+		}
+
+.ck-content ul ul ul ul{
+				list-style-type:square;
+			}
+
+:root{
+	--ck-todo-list-checkmark-size:16px;
+}
+.ck-content .todo-list{
+	list-style:none;
+}
+.ck-content .todo-list li{
+		position:relative;
+		margin-bottom:5px;
+	}
+.ck-content .todo-list li .todo-list{
+			margin-top:5px;
+		}
+.ck-content .todo-list .todo-list__label > input{
+			-webkit-appearance:none;
+			display:inline-block;
+			position:relative;
+			width:var(--ck-todo-list-checkmark-size);
+			height:var(--ck-todo-list-checkmark-size);
+			vertical-align:middle;
+			border:0;
+			left:-25px;
+			margin-right:-15px;
+			right:0;
+			margin-left:0;
+		}
+.ck-content[dir=rtl] .todo-list .todo-list__label > input{
+		left:0;
+		margin-right:0;
+		right:-25px;
+		margin-left:-15px;
+	}
+.ck-content .todo-list .todo-list__label > input::before{
+		display:block;
+		position:absolute;
+		box-sizing:border-box;
+		content:'';
+		width:100%;
+		height:100%;
+		border:1px solid hsl(0, 0%, 20%);
+		border-radius:2px;
+		transition:250ms ease-in-out box-shadow;
+	}
+@media (prefers-reduced-motion: reduce){
+.ck-content .todo-list .todo-list__label > input::before{
+			transition:none;
+	}
+		}
+.ck-content .todo-list .todo-list__label > input::after{
+		display:block;
+		position:absolute;
+		box-sizing:content-box;
+		pointer-events:none;
+		content:'';
+		left:calc( var(--ck-todo-list-checkmark-size) / 3);
+		top:calc( var(--ck-todo-list-checkmark-size) / 5.3);
+		width:calc( var(--ck-todo-list-checkmark-size) / 5.3);
+		height:calc( var(--ck-todo-list-checkmark-size) / 2.6);
+		border-style:solid;
+		border-color:transparent;
+		border-width:0 calc( var(--ck-todo-list-checkmark-size) / 8) calc( var(--ck-todo-list-checkmark-size) / 8) 0;
+		transform:rotate(45deg);
+	}
+.ck-content .todo-list .todo-list__label > input[checked]::before{
+			background:hsl(126, 64%, 41%);
+			border-color:hsl(126, 64%, 41%);
+		}
+.ck-content .todo-list .todo-list__label > input[checked]::after{
+			border-color:hsl(0, 0%, 100%);
+		}
+.ck-content .todo-list .todo-list__label .todo-list__label__description{
+			vertical-align:middle;
+		}
+.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type=checkbox]{
+			position:absolute;
+		}
+
+.ck-content .media{
+	clear:both;
+	margin:0.9em 0;
+	display:block;
+	min-width:15em;
+}
+
+.ck-content .page-break{
+	position:relative;
+	clear:both;
+	padding:5px 0;
+	display:flex;
+	align-items:center;
+	justify-content:center;
+}
+
+.ck-content .page-break::after{
+		content:'';
+		position:absolute;
+		border-bottom:2px dashed hsl(0, 0%, 77%);
+		width:100%;
+	}
+
+.ck-content .page-break__label{
+	position:relative;
+	z-index:1;
+	padding:.3em .6em;
+	display:block;
+	text-transform:uppercase;
+	border:1px solid hsl(0, 0%, 77%);
+	border-radius:2px;
+	font-family:Helvetica, Arial, Tahoma, Verdana, Sans-Serif;
+	font-size:0.75em;
+	font-weight:bold;
+	color:hsl(0, 0%, 20%);
+	background:hsl(0, 0%, 100%);
+	box-shadow:2px 2px 1px hsla(0, 0%, 0%, 0.15);
+	-webkit-user-select:none;
+	-moz-user-select:none;
+	-ms-user-select:none;
+	user-select:none;
+}
+@media print{
+	.ck-content .page-break{
+		padding:0;
+	}
+
+		.ck-content .page-break::after{
+			display:none;
+		}
+	.ck-content *:has(+ .page-break){
+		margin-bottom:0;
+	}
+}
+
+.ck-content .table{
+	margin:0.9em auto;
+	display:table;
+}
+
+.ck-content .table table{
+		border-collapse:collapse;
+		border-spacing:0;
+		width:100%;
+		height:100%;
+		border:1px double hsl(0, 0%, 70%);
+	}
+
+.ck-content .table table td,
+		.ck-content .table table th{
+			min-width:2em;
+			padding:.4em;
+			border:1px solid hsl(0, 0%, 75%);
+		}
+
+.ck-content .table table th{
+			font-weight:bold;
+			background:hsla(0, 0%, 0%, 5%);
+		}
+@media print{
+	.ck-content .table table{
+		height:initial;
+	}
+}
+.ck-content[dir="rtl"] .table th{
+	text-align:right;
+}
+
+.ck-content[dir="ltr"] .table th{
+	text-align:left;
+}
+
+:root{
+	--ck-color-selector-caption-background:hsl(0, 0%, 97%);
+	--ck-color-selector-caption-text:hsl(0, 0%, 20%);
+}
+.ck-content .table > figcaption{
+	display:table-caption;
+	caption-side:top;
+	word-break:break-word;
+	text-align:center;
+	color:var(--ck-color-selector-caption-text);
+	background-color:var(--ck-color-selector-caption-background);
+	padding:.6em;
+	font-size:.75em;
+	outline-offset:-1px;
+}
+@media (forced-colors: active){
+		.ck-content .table > figcaption{
+		background-color:unset;
+		color:unset;
+		}
+	}
+
+.ck-content .table .ck-table-resized{
+	table-layout:fixed;
+}
+
+.ck-content .table table{
+	overflow:hidden;
+}
+
+.ck-content .table td,
+.ck-content .table th{
+	overflow-wrap:break-word;
+	position:relative;
+}

Файловите разлики са ограничени, защото са твърде много
+ 2802 - 0
Admin/wwwroot/lib/ckeditor/ckeditor5-editor.css


Файловите разлики са ограничени, защото са твърде много
+ 4172 - 0
Admin/wwwroot/lib/ckeditor/ckeditor5.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
Admin/wwwroot/lib/ckeditor/ckeditor5.css.map


+ 63 - 0
Admin/wwwroot/lib/ckeditor/ckeditor5.js

@@ -0,0 +1,63 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+export * from '@ckeditor/ckeditor5-adapter-ckfinder/dist/index.js';
+export * from '@ckeditor/ckeditor5-alignment/dist/index.js';
+export * from '@ckeditor/ckeditor5-autoformat/dist/index.js';
+export * from '@ckeditor/ckeditor5-autosave/dist/index.js';
+export * from '@ckeditor/ckeditor5-basic-styles/dist/index.js';
+export * from '@ckeditor/ckeditor5-block-quote/dist/index.js';
+export * from '@ckeditor/ckeditor5-bookmark/dist/index.js';
+export * from '@ckeditor/ckeditor5-ckbox/dist/index.js';
+export * from '@ckeditor/ckeditor5-ckfinder/dist/index.js';
+export * from '@ckeditor/ckeditor5-clipboard/dist/index.js';
+export * from '@ckeditor/ckeditor5-cloud-services/dist/index.js';
+export * from '@ckeditor/ckeditor5-code-block/dist/index.js';
+export * from '@ckeditor/ckeditor5-core/dist/index.js';
+export * from '@ckeditor/ckeditor5-easy-image/dist/index.js';
+export * from '@ckeditor/ckeditor5-editor-balloon/dist/index.js';
+export * from '@ckeditor/ckeditor5-editor-classic/dist/index.js';
+export * from '@ckeditor/ckeditor5-editor-decoupled/dist/index.js';
+export * from '@ckeditor/ckeditor5-editor-inline/dist/index.js';
+export * from '@ckeditor/ckeditor5-editor-multi-root/dist/index.js';
+export * from '@ckeditor/ckeditor5-emoji/dist/index.js';
+export * from '@ckeditor/ckeditor5-engine/dist/index.js';
+export * from '@ckeditor/ckeditor5-enter/dist/index.js';
+export * from '@ckeditor/ckeditor5-essentials/dist/index.js';
+export * from '@ckeditor/ckeditor5-find-and-replace/dist/index.js';
+export * from '@ckeditor/ckeditor5-font/dist/index.js';
+export * from '@ckeditor/ckeditor5-heading/dist/index.js';
+export * from '@ckeditor/ckeditor5-highlight/dist/index.js';
+export * from '@ckeditor/ckeditor5-horizontal-line/dist/index.js';
+export * from '@ckeditor/ckeditor5-html-embed/dist/index.js';
+export * from '@ckeditor/ckeditor5-html-support/dist/index.js';
+export * from '@ckeditor/ckeditor5-image/dist/index.js';
+export * from '@ckeditor/ckeditor5-indent/dist/index.js';
+export * from '@ckeditor/ckeditor5-language/dist/index.js';
+export * from '@ckeditor/ckeditor5-link/dist/index.js';
+export * from '@ckeditor/ckeditor5-list/dist/index.js';
+export * from '@ckeditor/ckeditor5-markdown-gfm/dist/index.js';
+export * from '@ckeditor/ckeditor5-media-embed/dist/index.js';
+export * from '@ckeditor/ckeditor5-mention/dist/index.js';
+export * from '@ckeditor/ckeditor5-minimap/dist/index.js';
+export * from '@ckeditor/ckeditor5-page-break/dist/index.js';
+export * from '@ckeditor/ckeditor5-paragraph/dist/index.js';
+export * from '@ckeditor/ckeditor5-paste-from-office/dist/index.js';
+export * from '@ckeditor/ckeditor5-remove-format/dist/index.js';
+export * from '@ckeditor/ckeditor5-restricted-editing/dist/index.js';
+export * from '@ckeditor/ckeditor5-select-all/dist/index.js';
+export * from '@ckeditor/ckeditor5-show-blocks/dist/index.js';
+export * from '@ckeditor/ckeditor5-source-editing/dist/index.js';
+export * from '@ckeditor/ckeditor5-special-characters/dist/index.js';
+export * from '@ckeditor/ckeditor5-style/dist/index.js';
+export * from '@ckeditor/ckeditor5-table/dist/index.js';
+export * from '@ckeditor/ckeditor5-typing/dist/index.js';
+export * from '@ckeditor/ckeditor5-ui/dist/index.js';
+export * from '@ckeditor/ckeditor5-undo/dist/index.js';
+export * from '@ckeditor/ckeditor5-upload/dist/index.js';
+export * from '@ckeditor/ckeditor5-utils/dist/index.js';
+export * from '@ckeditor/ckeditor5-watchdog/dist/index.js';
+export * from '@ckeditor/ckeditor5-widget/dist/index.js';
+export * from '@ckeditor/ckeditor5-word-count/dist/index.js';
+//# sourceMappingURL=ckeditor5.js.map

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
Admin/wwwroot/lib/ckeditor/ckeditor5.js.map


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/af.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/af.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/af.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/ar.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/ar.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/ar.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/ast.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/ast.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/ast.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/az.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/az.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/az.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/bg.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/bg.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/bg.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/bn.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/bn.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/bn.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/bs.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/bs.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/bs.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/ca.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/ca.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/ca.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/cs.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/cs.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/cs.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/da.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/da.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/da.umd.js


+ 8 - 0
Admin/wwwroot/lib/ckeditor/translations/de-ch.d.ts

@@ -0,0 +1,8 @@
+/**
+ * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
+ */
+
+import type { Translations } from '@ckeditor/ckeditor5-utils';
+declare const translations: Translations;
+export default translations;

Файловите разлики са ограничени, защото са твърде много
+ 4 - 0
Admin/wwwroot/lib/ckeditor/translations/de-ch.js


Файловите разлики са ограничени, защото са твърде много
+ 6 - 0
Admin/wwwroot/lib/ckeditor/translations/de-ch.umd.js


Някои файлове не бяха показани, защото твърде много файлове са промени