This commit is contained in:
2025-09-24 17:29:33 +09:00
parent 7af46ac655
commit 74e88d7d89
17 changed files with 851 additions and 178 deletions
@@ -29,11 +29,10 @@
<p th:if="${srcPost.writeTime != null and srcPost.writeTime > 0}"
th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')}"></p>
</header>
<div style="text-align: right; margin-bottom: 2em;">
<label for="post-published-switch" style="font-weight: bold; color: #555; vertical-align: middle; margin-right: 10px;">
게시물 공개
</label>
<input type="checkbox" id="post-published-switch" name="posting" th:checked="${srcPost.posting}" />
<div style="text-align: right; margin-bottom: 2em; display: flex; align-items: center; justify-content: flex-end;">
<span style="font-weight: bold; color: #555; margin-right: 10px;">게시물 공개</span>
<input type="checkbox" id="post-published-switch" name="posting" th:checked="${srcPost.posting}" class="custom-checkbox" />
<label for="post-published-switch" class="custom-label"></label>
</div>
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
<div class="write_option btn-example controlbox-category" to="#popLayer1">
@@ -54,8 +53,13 @@
<th:block sec:authorize="isAuthenticated()">
<div class="container">
<div id="editor"></div>
<button id="save" class="button fit" style="margin-top: 1em;" onclick="save()">저장하기</button>
<div style="display: flex; gap: 1em; margin-top: 1em;">
<button id="save" class="button fit primary" onclick="save()">저장하기</button>
<button id="delete" class="button fit alt"
th:attr="data-post-id=${srcPost.id}"
onclick="deleteCurrentPost(this)"
th:if="${srcPost.id != null}">삭제하기</button>
</div>
</div>
</th:block>
</section>
+15 -2
View File
@@ -7,7 +7,11 @@
<th:block layout:fragment="content" id="content">
<section id="banner"
th:styleappend="${randomBannerImage != null} ? |background-image: url('${apiBaseUrl}${randomBannerImage}');| : ''">
<header>
<header th:if="${gibberish != null}">
<h2>Bum's Gibberish: <em>[[${gibberish}]]</em></h2>
<a th:href="@{/blog/viewer/{id}(id=${gibberishId})}" class="button">코멘트 남기기<br>[Leave a Comment]</a>
</header>
<header th:if="${gibberish == null}">
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
</header>
@@ -102,10 +106,19 @@
</div>
</div>
</section>
<section class="wrapper style1" sec:authorize="isAuthenticated()">
<div class="container">
<div id="gibberish-form" class="box post">
<h4>오늘의 Gibberish 남기기 (100자 이내)</h4>
<textarea id="gibberish-content" rows="3" maxlength="100" placeholder="문득 떠오른 생각을 적어보세요..."></textarea>
<button class="button" style="margin-top: 1em;" onclick="submitGibberish()">등록</button>
</div>
</div>
</section>
<section id="cta2" class="wrapper style3">
<div class="container">
<header>
<h2>Are you ready to continue your quest?</h2>
<!-- <h2>Are you ready to continue your quest?</h2>-->
</header>
</div>
</section>
@@ -1,8 +1,8 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.w3.org/1999/xhtml" layout:decorate="~{layout/default_layout}">
<head>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
</head>
@@ -11,7 +11,7 @@
<section class="wrapper style1">
<div class="container">
<div id="license-content-container">
<p>#lun ##Dependency License Report <em>2025-09-15 13:34:42 KST</em></p>
<p>#lun ##Dependency License Report <em>2025-09-24 15:26:44 KST</em></p>
<h2>Apache 2.0</h2>
<p><strong>1</strong> <strong>Group:</strong> <code>com.google.android</code> <strong>Name:</strong> <code>annotations</code> <strong>Version:</strong> <code>4.1.1.4</code></p>
<blockquote>
@@ -41,7 +41,7 @@
<li><strong>POM License</strong>: Apache 2.0 - <a href="http://www.apache.org/licenses/LICENSE-2.0.txt">http://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
</ul>
</blockquote>
<p><strong>5</strong> <strong>Group:</strong> <code>com.google.errorprone</code> <strong>Name:</strong> <code>error_prone_annotations</code> <strong>Version:</strong> <code>2.27.0</code></p>
<p><strong>5</strong> <strong>Group:</strong> <code>com.google.errorprone</code> <strong>Name:</strong> <code>error_prone_annotations</code> <strong>Version:</strong> <code>2.26.1</code></p>
<blockquote>
<ul>
<li><strong>Manifest Project URL</strong>: <a href="https://errorprone.info/error_prone_annotations">https://errorprone.info/error_prone_annotations</a></li>
@@ -766,10 +766,10 @@
<li><strong>POM License</strong>: Apache-2.0 - <a href="https://www.apache.org/licenses/LICENSE-2.0.txt">https://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
</ul>
</blockquote>
<p><strong>100</strong> <strong>Group:</strong> <code>com.google.code.gson</code> <strong>Name:</strong> <code>gson</code> <strong>Version:</strong> <code>2.11.0</code></p>
<p><strong>100</strong> <strong>Group:</strong> <code>com.google.code.gson</code> <strong>Name:</strong> <code>gson</code> <strong>Version:</strong> <code>2.10.1</code></p>
<blockquote>
<ul>
<li><strong>Manifest Project URL</strong>: <a href="https://github.com/google/gson">https://github.com/google/gson</a></li>
<li><strong>Manifest Project URL</strong>: <a href="https://github.com/google/gson/gson">https://github.com/google/gson/gson</a></li>
<li><strong>Manifest License</strong>: "Apache-2.0";link="https://www.apache.org/licenses/LICENSE-2.0.txt" (Not Packaged)</li>
<li><strong>POM License</strong>: Apache-2.0 - <a href="https://www.apache.org/licenses/LICENSE-2.0.txt">https://www.apache.org/licenses/LICENSE-2.0.txt</a></li>
</ul>
@@ -17,7 +17,7 @@
<div id="content_inner">
<article>
<th:block th:each="post, iterStat : ${postsPage.content}">
<section>
<section th:id="'post-section-' + ${post.id}">
<div class="box post" th:id="${post.id}">
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left">
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
@@ -40,7 +40,8 @@
<footer sec:authorize="isAuthenticated()"
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
style="text-align: right; margin-top: 1em;">
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small alt">수정</a>
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small">수정</a>
<button class="button small alt" th:onclick="handleDeletePostInList([[${post.id}]], event)">삭제</button>
</footer>
</div>
</section>
@@ -93,5 +94,51 @@
</div>
</div>
</section>
<script th:inline="javascript">
// CSRF 토큰을 meta 태그에서 읽어옴 (POST, DELETE 요청 시 필요)
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
/**
* 게시물 목록에서 게시물을 삭제하는 함수
* @param {string} postId - 삭제할 게시물의 ID
* @param {Event} event - 클릭 이벤트 객체
*/
function handleDeletePostInList(postId, event) {
// 이벤트 버블링을 막아 게시물 상세보기로 이동하는 것을 방지합니다.
event.stopPropagation();
const cleanPostId = postId.replace(/^"|"$/g, '');
if (confirm('이 게시물을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
fetch(`/blog/post/${cleanPostId}`, {
method: 'DELETE',
headers: {
[csrfHeader]: csrfToken
}
})
.then(response => {
if (response.ok) {
return response.json();
} else {
// 서버에서 에러 메시지를 보냈을 경우를 대비
return response.json().then(err => { throw new Error(err.message || '삭제에 실패했습니다.') });
}
})
.then(data => {
alert(data.message || '게시물이 성공적으로 삭제되었습니다.');
// UI에서 해당 게시물 섹션을 제거하여 즉시 반영
const postSection = document.getElementById(`post-section-${cleanPostId}`);
if (postSection) {
postSection.remove();
}
})
.catch(error => {
console.error('Error:', error);
alert('삭제 처리 중 오류가 발생했습니다: ' + error.message);
});
}
}
</script>
</th:block>
</html>
@@ -246,6 +246,15 @@
</div>
</div>
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box" style="border: 2px solid #e67e22; background: #fff3e0;">
<h4>데이터 마이그레이션 (주의!)</h4>
<p>
게시물 데이터 구조를 새 버전으로 업그레이드합니다.
<strong>이 작업은 시스템 전체에 영향을 미치며, 반드시 한 번만 실행해야 합니다.</strong>
</p>
<button id="run-migration-btn" class="button alt">게시물 데이터 마이그레이션 실행</button>
<div id="migration-result" style="margin-top: 1em; font-weight: bold;"></div>
</div>
<div class="box">
<h4>권한 요청</h4>
<ul id="permission-requests-list" class="user-list">
@@ -281,8 +290,13 @@
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">게시물 제목</a>
<div>
<span th:if="${post.isBlocked}" style="color: red; margin-right: 1em;">(차단됨)</span>
<button th:if="${!post.isBlocked}" class="button small alt" th:onclick="handleContent('[[${post.id}]]', 'block')">차단</button>
<a th:href="@{/user/admin/posts/{postId}/history(postId=${post.id})}" class="button small">히스토리</a>
<button th:if="${!post.isBlocked}" class="button small" th:onclick="handleContent('[[${post.id}]]', 'block')">차단</button>
<button th:if="${post.isBlocked}" class="button small" th:onclick="handleContent('[[${post.id}]]', 'unblock')">차단 해제</button>
<button class="button small alt" th:onclick="handleDeletePost('[[${post.id}]]')">삭제</button>
</div>
</li>
</ul>
@@ -418,6 +432,43 @@
.catch(error => console.error('Error:', error));
}
const migrationBtn = document.getElementById('run-migration-btn');
if (migrationBtn) {
migrationBtn.addEventListener('click', async function() {
if (!await showConfirm('경고', '정말로 데이터 마이그레이션을 실행하시겠습니까? 이 작업은 되돌릴 수 없으며, 반드시 한 번만 실행해야 합니다.')) {
return;
}
const resultDiv = document.getElementById('migration-result');
resultDiv.innerHTML = '마이그레이션 진행 중... 페이지를 닫지 마세요.';
migrationBtn.disabled = true;
try {
const response = await fetch('/user/admin/migrate-posts', {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
});
const report = await response.json();
if (response.ok) {
resultDiv.innerHTML = `✅ 마이그레이션 성공!<br>
- 처리된 게시물 그룹: ${report.processedGroups}<br>
- 최신 글로 이전됨: ${report.latestPostsMigrated}<br>
- 히스토리로 이전됨: ${report.historyPostsMigrated}`;
resultDiv.style.color = 'green';
} else {
throw new Error(report.errors.join(', '));
}
} catch (error) {
resultDiv.innerHTML = `❌ 마이그레이션 실패: ${error.message}`;
resultDiv.style.color = 'red';
migrationBtn.disabled = false;
}
});
}
/**
* (관리자) 콘텐츠(게시물)를 차단하거나 해제하는 함수
* @param {string} postId - 대상 게시물 ID
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="content">
<section class="wrapper style1">
<div class="container">
<header class="major">
<h2 th:text="${pageTitle}">게시물 수정 히스토리</h2>
<p><strong>원본글 ID:</strong> <span th:text="${currentPost.id}"></span></p>
</header>
<div class="box">
<h4>현재 버전</h4>
<p><strong>수정일:</strong> <span th:text="${#dates.format(currentPost.modifyTime, 'yyyy-MM-dd HH:mm')}"></span></p>
<a th:href="@{/blog/viewer/{postId}(postId=${currentPost.id})}" class="button small">현재 글 보기</a>
<a th:href="@{/blog/edit/{postId}(postId=${currentPost.id})}" class="button small alt">현재 글 수정</a>
</div>
<div class="box" style="margin-top: 2em;">
<h4>과거 버전 (히스토리)</h4>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>보관된 시간</th>
<th>제목</th>
<th>작성자</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<tr th:each="h : ${historyList}">
<td th:text="${#dates.format(h.archivedAt, 'yyyy-MM-dd HH:mm:ss')}"></td>
<td th:text="${h.title}"></td>
<td th:text="${h.writer}"></td>
<td>
<span th:if="${h.posting}" class="tag-item" style="background: #a8dadc;">공개</span>
<span th:unless="${h.posting}" class="tag-item" style="background: #f1faee;">비공개</span>
</td>
<td>
<a href="#" class="button small primary">내용 보기</a>
<a href="#" class="button small alt">이 버전으로 복구</a>
</td>
</tr>
<tr th:if="${#lists.isEmpty(historyList)}">
<td colspan="5" style="text-align: center;">저장된 히스토리가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</th:block>
</html>