Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32c5a3d042 | |||
| 68291867f9 | |||
| d24f3f58db |
+72
-3
@@ -130,10 +130,15 @@ boto3, cryptography, Jinja2, jsonschema, fail2ban 등 시스템 레벨로 설치
|
||||
|
||||
# 1. TaxBaik 홈페이지 (taxbaik.com, www.taxbaik.com)
|
||||
server {
|
||||
listen 80;
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
|
||||
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
|
||||
location /admin {
|
||||
return 301 $scheme://$host/taxbaik$request_uri;
|
||||
}
|
||||
|
||||
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001/taxbaik/;
|
||||
@@ -159,11 +164,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
|
||||
# 2. Gitea (gitea.taxbaik.com)
|
||||
server {
|
||||
listen 80;
|
||||
server_name gitea.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
@@ -178,11 +190,17 @@ server {
|
||||
proxy_connect_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
|
||||
# 3. QuantEngine (quant.taxbaik.com)
|
||||
server {
|
||||
listen 80;
|
||||
server_name quant.taxbaik.com;
|
||||
|
||||
location / {
|
||||
@@ -196,6 +214,57 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/taxbaik.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/taxbaik.com/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = www.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
if ($host = taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = gitea.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name gitea.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
server {
|
||||
if ($host = quant.taxbaik.com) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80;
|
||||
server_name quant.taxbaik.com;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -22,14 +22,22 @@
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="d-flex pa-4 gap-4 align-center">
|
||||
<MudTextField @bind-Value="searchQuery" Placeholder="공지사항 제목 검색..." Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||
</div>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (announcements is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (!announcements.Any())
|
||||
else if (!FilteredAnnouncements.Any())
|
||||
{
|
||||
<MudText Class="pa-4 text-muted">등록된 공지사항이 없습니다.</MudText>
|
||||
<div class="pa-6 text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Campaign" Style="font-size:3rem; opacity:.3;" />
|
||||
<MudText Class="mt-2 text-muted">검색 조건에 맞는 공지사항이 없습니다.</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -45,7 +53,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in announcements)
|
||||
@foreach (var item in FilteredAnnouncements)
|
||||
{
|
||||
<tr>
|
||||
<td>@item.Title</td>
|
||||
@@ -86,6 +94,9 @@
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||
검색 결과 @(FilteredAnnouncements.Count())개 · 총 @(announcements.Count)개
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
@@ -94,6 +105,12 @@
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Announcement>? announcements;
|
||||
private string searchQuery = "";
|
||||
|
||||
private IEnumerable<Announcement> FilteredAnnouncements => announcements?
|
||||
.Where(a => string.IsNullOrEmpty(searchQuery) ||
|
||||
a.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(a => a.SortOrder) ?? Enumerable.Empty<Announcement>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
|
||||
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@@ -32,8 +32,24 @@
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
|
||||
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
|
||||
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
|
||||
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
|
||||
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
|
||||
@if (string.IsNullOrWhiteSpace(model.Content))
|
||||
{
|
||||
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@((MarkupString)model.Content)
|
||||
}
|
||||
</div>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
@@ -32,8 +32,8 @@ else
|
||||
{
|
||||
<MudPaper Class="pa-4 mt-4" Elevation="1">
|
||||
<MudForm @ref="form">
|
||||
<MudTextField @bind-Value="model.Title" Label="제목"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" />
|
||||
<MudTextField @bind-Value="model.Title" Label="제목 *"
|
||||
Variant="Variant.Outlined" Class="mb-4" Required="true" RequiredError="제목을 입력하세요." Counter="100" MaxLength="100" />
|
||||
|
||||
<MudSelect T="int?" @bind-Value="model.CategoryId" Label="카테고리"
|
||||
Variant="Variant.Outlined" Class="mb-4">
|
||||
@@ -43,8 +43,24 @@ else
|
||||
}
|
||||
</MudSelect>
|
||||
|
||||
<MudTextField @bind-Value="model.Content" Label="본문"
|
||||
Variant="Variant.Outlined" Lines="10" Class="mb-4" Required="true" />
|
||||
<MudTabs Elevation="2" Rounded="true" ApplyEffectsToContainer="true" PanelClass="pa-4" Class="mb-4">
|
||||
<MudTabPanel Text="에디터" Icon="@Icons.Material.Filled.Edit">
|
||||
<MudTextField @bind-Value="model.Content" Label="본문 내용 *"
|
||||
Variant="Variant.Outlined" Lines="15" Required="true" RequiredError="본문 내용을 입력하세요." Counter="10000" MaxLength="10000" HelperText="HTML 태그를 사용해 꾸밀 수 있습니다." />
|
||||
</MudTabPanel>
|
||||
<MudTabPanel Text="실시간 미리보기" Icon="@Icons.Material.Filled.Visibility">
|
||||
<div class="border rounded pa-4 article-body lh-lg" style="min-height: 330px; max-height: 500px; overflow-y: auto; background-color: #fafafa;">
|
||||
@if (string.IsNullOrWhiteSpace(model.Content))
|
||||
{
|
||||
<p class="text-muted small text-center my-8">작성 중인 본문 내용이 이곳에 실시간으로 표시됩니다.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@((MarkupString)model.Content)
|
||||
}
|
||||
</div>
|
||||
</MudTabPanel>
|
||||
</MudTabs>
|
||||
|
||||
<MudTextField @bind-Value="model.Tags" Label="태그 (쉼표로 구분)"
|
||||
Variant="Variant.Outlined" Class="mb-4" />
|
||||
|
||||
@@ -15,14 +15,19 @@
|
||||
Href="/taxbaik/admin/blog/create">새 포스트 작성</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="d-flex pa-4 gap-4 align-center">
|
||||
<MudTextField @bind-Value="searchQuery" Placeholder="블로그 제목 또는 본문 검색..." Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||
</div>
|
||||
|
||||
<MudPaper Class="admin-surface mb-4" Elevation="0">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.subtitle1">@($"전체 포스트 {totalPosts}개")</MudText>
|
||||
<MudText Typo="Typo.subtitle1">@($"검색 결과 {FilteredPosts.Count()}개 / 전체 포스트 {totalPosts}개")</MudText>
|
||||
<MudText Typo="Typo.body2">페이지 @currentPage / @totalPages</MudText>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
<MudDataGrid Items="@posts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||
<MudDataGrid Items="@FilteredPosts" Striped="true" Hoverable="true" Loading="@isLoading" Class="admin-grid">
|
||||
<Columns>
|
||||
<PropertyColumn Property="x => x.Title" Title="제목" />
|
||||
<PropertyColumn Property="x => x.IsPublished" Title="발행">
|
||||
@@ -54,12 +59,18 @@
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<TaxBaik.Domain.Entities.BlogPost> posts = [];
|
||||
private string searchQuery = "";
|
||||
private bool isLoading = true;
|
||||
private int currentPage = 1;
|
||||
private int totalPages = 1;
|
||||
private int totalPosts = 0;
|
||||
private const int PageSize = 20;
|
||||
|
||||
private IEnumerable<TaxBaik.Domain.Entities.BlogPost> FilteredPosts => posts?
|
||||
.Where(p => string.IsNullOrEmpty(searchQuery) ||
|
||||
p.Title.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||
(p.Content != null && p.Content.Contains(searchQuery, StringComparison.OrdinalIgnoreCase))) ?? Enumerable.Empty<TaxBaik.Domain.Entities.BlogPost>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
|
||||
@@ -22,16 +22,21 @@
|
||||
</MudButton>
|
||||
</section>
|
||||
|
||||
<div class="d-flex pa-4 gap-4 align-center">
|
||||
<MudTextField @bind-Value="searchQuery" Placeholder="질문 또는 답변 검색..." Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="flex-grow-1" Immediate="true" Clearable="true" />
|
||||
</div>
|
||||
|
||||
<MudPaper Class="admin-surface" Elevation="0">
|
||||
@if (faqs is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" />
|
||||
}
|
||||
else if (!faqs.Any())
|
||||
else if (!FilteredFaqs.Any())
|
||||
{
|
||||
<div class="pa-6 text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.QuestionAnswer" Style="font-size:3rem; opacity:.3;" />
|
||||
<MudText Class="mt-2 text-muted">등록된 FAQ가 없습니다.</MudText>
|
||||
<MudText Class="mt-2 text-muted">검색 조건에 맞는 FAQ가 없습니다.</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -39,7 +44,7 @@
|
||||
<MudSimpleTable Striped="true" Dense="true" Class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:60px;">순서</th>
|
||||
<th style="width:110px;">순서</th>
|
||||
<th>질문</th>
|
||||
<th style="width:130px;">카테고리</th>
|
||||
<th style="width:90px;">상태</th>
|
||||
@@ -47,11 +52,15 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in faqs)
|
||||
@foreach (var item in FilteredFaqs)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<MudText Typo="Typo.body2">@item.SortOrder</MudText>
|
||||
<td>
|
||||
<div class="d-flex align-center justify-start gap-1">
|
||||
<MudText Typo="Typo.body2" Class="mr-2">@item.SortOrder</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropUp" Size="Size.Small" OnClick="@(() => MoveUpAsync(item))" Style="padding:2px;" Dense="true" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDropDown" Size="Size.Small" OnClick="@(() => MoveDownAsync(item))" Style="padding:2px;" Dense="true" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<MudText Typo="Typo.body2" Style="max-width:480px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
|
||||
@@ -77,10 +86,10 @@
|
||||
<td>
|
||||
<MudButtonGroup Size="Size.Small" Variant="Variant.Outlined">
|
||||
<MudButton @onclick="@(() => Navigation.NavigateTo($"/taxbaik/admin/faqs/{item.Id}/edit"))">
|
||||
수정
|
||||
수정
|
||||
</MudButton>
|
||||
<MudButton Color="Color.Error" @onclick="@(() => DeleteAsync(item))">
|
||||
삭제
|
||||
삭제
|
||||
</MudButton>
|
||||
</MudButtonGroup>
|
||||
</td>
|
||||
@@ -89,7 +98,7 @@
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudText Typo="Typo.caption" Class="pa-2 text-muted">
|
||||
총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||
검색 결과 @(FilteredFaqs.Count())개 · 총 @(faqs.Count)개 · 노출 중 @(faqs.Count(f => f.IsActive))개
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
@@ -99,6 +108,13 @@
|
||||
private Task<AuthenticationState>? AuthStateTask { get; set; }
|
||||
|
||||
private List<Faq>? faqs;
|
||||
private string searchQuery = "";
|
||||
|
||||
private IEnumerable<Faq> FilteredFaqs => faqs?
|
||||
.Where(f => string.IsNullOrEmpty(searchQuery) ||
|
||||
f.Question.Contains(searchQuery, StringComparison.OrdinalIgnoreCase) ||
|
||||
(f.Answer != null && f.Answer.Contains(searchQuery, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(f => f.SortOrder) ?? Enumerable.Empty<Faq>();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
@@ -120,7 +136,7 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
faqs = (await FaqClient.GetAllAsync()).ToList();
|
||||
faqs = (await FaqClient.GetAllAsync()).OrderBy(f => f.SortOrder).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -129,6 +145,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MoveUpAsync(Faq item)
|
||||
{
|
||||
if (faqs == null) return;
|
||||
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
|
||||
var index = sorted.IndexOf(item);
|
||||
if (index <= 0) return;
|
||||
|
||||
var prev = sorted[index - 1];
|
||||
var temp = item.SortOrder;
|
||||
item.SortOrder = prev.SortOrder;
|
||||
prev.SortOrder = temp;
|
||||
|
||||
if (item.SortOrder == prev.SortOrder)
|
||||
{
|
||||
prev.SortOrder = item.SortOrder + 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await FaqClient.UpdateAsync(item.Id, item);
|
||||
await FaqClient.UpdateAsync(prev.Id, prev);
|
||||
Snackbar.Add("순서가 상향되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MoveDownAsync(Faq item)
|
||||
{
|
||||
if (faqs == null) return;
|
||||
var sorted = faqs.OrderBy(f => f.SortOrder).ToList();
|
||||
var index = sorted.IndexOf(item);
|
||||
if (index < 0 || index >= sorted.Count - 1) return;
|
||||
|
||||
var next = sorted[index + 1];
|
||||
var temp = item.SortOrder;
|
||||
item.SortOrder = next.SortOrder;
|
||||
next.SortOrder = temp;
|
||||
|
||||
if (item.SortOrder == next.SortOrder)
|
||||
{
|
||||
next.SortOrder = item.SortOrder + 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await FaqClient.UpdateAsync(item.Id, item);
|
||||
await FaqClient.UpdateAsync(next.Id, next);
|
||||
Snackbar.Add("순서가 하향되었습니다.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"순서 조정 실패: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Faq item)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBox(
|
||||
|
||||
@@ -266,6 +266,7 @@ builder.Services.AddMudServices(config =>
|
||||
{
|
||||
config.SnackbarConfiguration.HideTransitionDuration = 400;
|
||||
config.SnackbarConfiguration.ShowTransitionDuration = 300;
|
||||
config.PopoverOptions.ThrowOnDuplicateProvider = false;
|
||||
});
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddResponseCompression(opts => {
|
||||
|
||||
@@ -3,6 +3,12 @@ server {
|
||||
server_name taxbaik.com www.taxbaik.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
|
||||
# /admin 하위 요청을 /taxbaik/admin 으로 리다이렉트하여 Blazor Base Path 대응
|
||||
location /admin {
|
||||
return 301 $scheme://$host/taxbaik$request_uri;
|
||||
}
|
||||
|
||||
# 루트 경로 요청을 /taxbaik 으로 프록싱하여 base href /taxbaik/ 에 대응
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5001/taxbaik/;
|
||||
|
||||
Reference in New Issue
Block a user