This commit is contained in:
2025-09-18 17:55:32 +09:00
parent 17aea8b43b
commit 5e0db4ff03
38 changed files with 3011 additions and 1228 deletions
@@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s
api.base-url=ss
api.base-url=ss
build.config.run=local
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000
@@ -100,4 +100,7 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s
api.base-url=ss
api.base-url=ss
build.config.run=prd
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000
+5 -1
View File
@@ -100,4 +100,8 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
server.tomcat.connection-timeout=60s
# For reactive applications (like yours), also set this timeout
spring.webflux.response-timeout=60s
api.base-url=ss
api.base-url=ss
build.config.run=local
jwt.secret=your-very-long-and-super-secret-key-for-jwt-that-is-at-least-64-characters-long
jwt.expiration=86400000
+226
View File
@@ -233,6 +233,8 @@ window.addEventListener('DOMContentLoaded', () => {
closePopup();
});
}
checkUnreadMessages(); // 함수 호출 추가
});
/* --- (DOMContentLoaded 끝) --- */
@@ -815,6 +817,7 @@ function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); }
function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); }
function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); }
function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); }
function gotoBUMSpace() { document.location.replace(`${getMainPath()}/bums/face.bs`); }
function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); }
// [추가] 네모로직 업로드 페이지로 이동하는 함수
function gotoPuzzleUpload() { document.location.replace(`${getMainPath()}/puzzle/upload.bs`); }
@@ -1551,4 +1554,227 @@ async function showConfirm(title, text) {
cancelButtonText: '취소'
});
return result.isConfirmed;
}
function sendTlg(form, type,keyword) {
console.log(form)
let data = {
'name': form.querySelector("#name").value,
'email': form.querySelector("#email").value,
'message': form.querySelector("#message").value,
}
if (data.name != null && data.email != null && data.message != null && data.message.length > 0) {
if(confirm(JSON.stringify(data) + "\n해당 내용으로\n메시지 보내쉴?")) {
post(getMainPath()+"/tlg/repotToMe.bjx",type,JSON.stringify(data),keyword, function (resultData) {
showAlert("서버에 전달됨.")
})
} else {
}
}
return false
}
async function checkUnreadMessages() {
const isLoggedIn = !!document.querySelector('a[href="javascript:logout()"]');
if (!isLoggedIn) return; // 비로그인 상태면 실행 중단
try {
const response = await fetch('/messages/unread-count');
if (response.ok) {
const data = await response.json();
if (data.count > 0) {
const icon = document.getElementById('message-icon');
if (icon) {
icon.style.display = 'inline-block'; // 아이콘 표시
}
}
}
} catch (error) {
console.error('Failed to check for unread messages:', error);
}
}
function handleBookmarkVote(buttonElement, voteType) {
const controls = buttonElement.closest('.vote-controls');
const bookmarkId = controls.dataset.bookmarkId;
controls.querySelectorAll('button').forEach(btn => btn.disabled = true); // 중복 클릭 방지
// [수정] 북마크용 API 엔드포인트 사용
const url = `${getMainPath()}/bookmarks/${bookmarkId}/${voteType === 'like' ? 'like' : 'unlike'}`;
// CSRF 토큰 준비
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const headers = { 'X-CSRF-TOKEN': csrfToken };
fetch(url, { method: 'POST', headers: headers })
.then(res => res.json())
.then(data => {
controls.querySelector('.like-count').innerText = data.voteCount;
controls.querySelector('.unlike-count').innerText = data.unlikeCount;
})
.catch(error => console.error('Error handling bookmark vote:', error))
.finally(() => {
controls.querySelectorAll('button').forEach(btn => btn.disabled = false);
});
}
/**
* 특정 북마크의 댓글 섹션을 열거나 닫습니다.
*/
function toggleCommentSection(bookmarkId) {
const section = document.getElementById(`comment-section-${bookmarkId}`);
if (section.style.display === 'none') {
section.style.display = 'block';
fetchBookmarkComments(bookmarkId); // 처음 열 때 댓글 로드
} else {
section.style.display = 'none';
}
}
/**
* 특정 북마크의 댓글 목록을 불러옵니다.
*/
async function fetchBookmarkComments(bookmarkId) {
const listContainer = document.getElementById(`comments-list-${bookmarkId}`);
listContainer.innerHTML = '댓글 로딩 중...';
const response = await fetch(`${getMainPath()}/bookmarks/${bookmarkId}/comments`);
const data = await response.json();
listContainer.innerHTML = '';
if (data.resultCode === 0 && data.comments.length > 0) {
data.comments.forEach(comment => {
// 기존 블로그 댓글 HTML 생성 함수 재사용
listContainer.innerHTML += createCommentHTML(comment);
});
} else {
listContainer.innerHTML = '아직 댓글이 없습니다.';
}
}
/**
* 북마크에 댓글을 등록합니다.
*/
function submitBookmarkComment(bookmarkId) {
const input = document.getElementById(`comment-input-${bookmarkId}`);
const content = input.value.trim();
if (!content) {
showAlert('알림', '댓글 내용을 입력하세요.');
return;
}
// 블로그 댓글과 동일한 DTO 및 암호화 방식 사용
const commentData = { content: content, parentId: null };
const uploadUrl = `${getMainPath()}/bookmarks/${bookmarkId}/comments`;
// 기존 `post` 유틸리티 함수를 재사용하여 서버에 전송
post(uploadUrl, serverData.enc, JSON.stringify(commentData), serverData.keyword, (resultData) => {
const response = JSON.parse(resultData);
if (response.resultCode === 0) {
input.value = '';
fetchBookmarkComments(bookmarkId); // 댓글 목록 새로고침
} else {
showAlert('오류', '댓글 등록에 실패했습니다: ' + response.resultMsg);
}
});
}
/**
* 북마크 클릭 시 사용자에게 선택지를 보여주는 함수
* @param {HTMLElement} element - 클릭된 <a> 요소
*/
async function showBookmarkOptions(element) {
const url = element.dataset.url;
const title = element.dataset.title;
const result = await Swal.fire({
title: '어떻게 보시겠어요?',
text: title,
icon: 'question',
showDenyButton: true,
confirmButtonText: '새 탭에서 열기',
denyButtonText: '여기서 보기 (Iframe)',
confirmButtonColor: '#3085d6',
denyButtonColor: '#555',
});
if (result.isConfirmed) {
// '새 탭에서 열기' 선택 시
window.open(url, '_blank');
} else if (result.isDenied) {
// '여기서 보기 (Iframe)' 선택 시
openBookmarkInIframe(url, title);
}
}
/**
* iframe 로드 실패 시 일관된 처리를 위한 헬퍼 함수
* @param {string} title - 북마크 제목
* @param {string} url - 북마크 URL
*/
function handleIframeLoadFailure(title, url) {
closePopup(); // 팝업 닫기
if (confirm(`'${title}' 페이지를 내부에서 여는 데 실패했습니다.\n\n새 탭에서 여시겠습니까?`)) {
window.open(url, '_blank');
}
}
/**
* 지정된 URL을 Iframe 팝업으로 여는 함수 (try-catch 로직 적용)
* @param {string} url - 표시할 URL
* @param {string} title - 표시할 제목
*/
function openBookmarkInIframe(url, title) {
const popup = document.getElementById('iframe-viewer-popup');
const titleElement = document.getElementById('iframe-viewer-title');
const iframe = document.getElementById('bookmark-iframe');
const overlay = document.querySelector('.dim_layer');
const newTabLink = document.getElementById('iframe-open-new-tab-link');
if (!popup || !titleElement || !iframe || !overlay || !newTabLink) {
console.error('Iframe viewer elements not found!');
return;
}
// iframe의 로딩을 시작하기 전에 src를 초기화하여 이전 상태를 지웁니다.
iframe.src = 'about:blank';
// iframe의 onload 이벤트 핸들러
iframe.onload = () => {
console.log("iframe onload 이벤트 발생. 내부 문서 접근을 시도합니다...");
try {
// 동일 출처 정책(Same-Origin Policy)을 위반하는 접근 시도
// 이 코드가 오류를 발생시키면, 다른 출처의 문서가 로드된 것 (성공 또는 오류 페이지)
const dummyAccess = iframe.contentWindow.location.href;
// 만약 위 코드에서 오류가 발생하지 않았다면, iframe이 동일 출처이거나 비어있다는 의미.
// 외부 사이트 로드는 실패한 것으로 간주합니다.
console.warn("iframe 접근이 차단되지 않았습니다. 로드 실패로 간주합니다.");
handleIframeLoadFailure(title, url);
} catch (e) {
// SecurityError가 발생! 다른 출처의 문서가 성공적으로 로드되었다고 간주합니다.
// (이것이 실제 콘텐츠일 수도, 브라우저의 오류 페이지일 수도 있습니다)
console.log("iframe 접근이 보안 정책에 의해 차단되었습니다. 일단 성공으로 간주합니다.", e);
// 팝업을 그대로 유지
}
};
// 네트워크 오류 등으로 iframe 로드 자체가 실패했을 때를 위한 핸들러
iframe.onerror = () => {
console.error("iframe onerror 이벤트 발생. 로드 실패로 처리합니다.");
handleIframeLoadFailure(title, url);
};
// 제목과 새 탭 링크 설정
titleElement.textContent = title;
newTabLink.href = url;
// 실제 URL로 로딩 시작
iframe.src = url;
// 팝업과 오버레이 표시
overlay.style.display = 'block';
popup.style.display = 'block';
}
@@ -0,0 +1,38 @@
<!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="head">
</th:block>
<th:block layout:fragment="content" id="content">
<section class="wrapper style2">
<div class="container">
<header class="major">
<h2 th:text="${srcPost.title}">소개글 제목</h2>
<p>
최종 수정일: <span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.modifyTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')}"></span>
</p>
</header>
</div>
</section>
<section class="wrapper style1">
<div class="container">
<article>
<div id="editor"></div>
</article>
</div>
</section>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script>
// DOM 로드 완료 후 Quill 에디터를 읽기 전용(false)으로 초기화
document.addEventListener('DOMContentLoaded', function() {
initEditor(false);
});
</script>
</th:block>
</html>
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/default_layout}">
<head>
<title>Bookmarks</title>
</head>
<th:block layout:fragment="content">
<section class="wrapper style2">
<div class="container">
<header class="major">
<h2>Bookmarks</h2>
<p>다른 사용자들이 저장한 유용한 페이지들을 둘러보세요.</p>
</header>
</div>
</section>
<section class="wrapper style1">
<div class="container">
<div class="row">
<div class="col-12" th:if="${bookmarksPage.empty}">
<p style="text-align: center;">아직 저장된 페이지가 없습니다.</p>
</div>
<div class="col-4 col-6-medium col-12-small" th:each="bookmark : ${bookmarksPage.content}">
<section class="box feature">
<a href="javascript:void(0);"
th:data-url="${bookmark.url}"
th:data-title="${bookmark.title}"
onclick="showBookmarkOptions(this)" class="image featured">
<img th:src="${bookmark.thumbnailUrl ?: '/images/pic01.jpg'}" alt="Thumbnail" />
</a>
<div class="inner">
<header>
<h3 th:text="${bookmark.title}">북마크 제목</h3>
<p th:if="${bookmark.userComment}" th:text="${bookmark.userComment}" style="font-style: italic; color: #007bff;"></p>
</header>
<p th:text="${#strings.abbreviate(bookmark.description, 100)}"></p>
<div class="bookmark-controls" style="margin-top: 1em; padding-top: 1em; border-top: 1px solid #eee;">
<div class="vote-controls" th:data-bookmark-id="${bookmark.id}" style="text-align: center; margin-bottom: 1em;">
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'like')">
👍 (<span class="like-count" th:text="${bookmark.voteCount}">0</span>)
</button>
<button class="button small alt" th:onclick="handleBookmarkVote(this, 'unlike')" style="margin-left: 0.5em;">
👎 (<span class="unlike-count" th:text="${bookmark.unlikeCount}">0</span>)
</button>
</div>
<a href="javascript:void(0);" th:onclick="toggleCommentSection('[[${bookmark.id}]]')" class="button small fit">댓글 보기</a>
<div th:id="'comment-section-' + ${bookmark.id}" class="comment-section" style="display: none; margin-top: 1em;">
<div th:id="'comments-list-' + ${bookmark.id}" class="comments-list"></div>
<textarea th:id="'comment-input-' + ${bookmark.id}" placeholder="댓글을 입력하세요..." style="margin-top: 1em;"></textarea>
<button class="button small" th:onclick="submitBookmarkComment('[[${bookmark.id}]]')">등록</button>
</div>
</div>
<footer style="font-size: 0.8em; color: #888; text-align: right; margin-top: 1em;">
by <span th:text="${bookmark.userId}"></span>
</footer>
</div>
</section>
</div>
</div>
<nav th:if="${bookmarksPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em;">
<ul class="pagination">
<li th:classappend="${bookmarksPage.first} ? 'disabled'">
<a th:href="@{/bookmarks(page=${bookmarksPage.number - 1})}" class="button alt small">Prev</a>
</li>
<li th:each="pageNum : ${#numbers.sequence(0, bookmarksPage.totalPages - 1)}">
<a th:href="@{/bookmarks(page=${pageNum})}"
th:text="${pageNum + 1}"
th:class="${pageNum == bookmarksPage.number} ? 'button small' : 'button alt small'"></a>
</li>
<li th:classappend="${bookmarksPage.last} ? 'disabled'">
<a th:href="@{/bookmarks(page=${bookmarksPage.number + 1})}" class="button alt small">Next</a>
</li>
</ul>
</nav>
</div>
</section>
</th:block>
</html>
@@ -7,11 +7,7 @@
>
<th:block layout:fragment="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" />
<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>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block>
<body>
@@ -110,6 +106,11 @@
</div>
</div>
</div>
<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>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block>
</body>
</html>
@@ -1,7 +1,10 @@
<!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.quilljs.com/1.3.6/quill.snow.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>
<body>
<th:block layout:fragment="content" id="content">
@@ -0,0 +1,124 @@
<!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="head">
<style>
.message-list { list-style: none; padding-left: 0; }
.message-item { border: 1px solid #ddd; border-radius: 5px; margin-bottom: 1em; }
.message-header { padding: 10px 15px; background: #f7f7f7; cursor: pointer; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; }
.message-item.unread .message-header { font-weight: bold; background: #fffbe5; border-left: 3px solid #FFA500; }
.message-content { padding: 15px; display: none; border-top: 1px solid #ddd; }
.message-content.active { display: block; }
.reply-form { margin-top: 1em; }
.message-title { flex-grow: 1; }
</style>
</th:block>
<th:block layout:fragment="content">
<section class="wrapper style1">
<div class="container">
<header class="major">
<h2 th:text="${pageTitle}"></h2>
</header>
<div class="box">
<ul class="message-list">
<li th:if="${#lists.isEmpty(messages)}">받은 쪽지가 없습니다.</li>
<li th:each="msg : ${messages}" class="message-item" th:classappend="${!msg.isRead} ? 'unread'" th:data-message-id="${msg.id}">
<div class="message-header" onclick="toggleMessage(this)">
<div class="message-title">
<span th:text="${msg.title}"></span>
<small style="margin-left: 1em; color: #777;" th:text="'보낸 사람: ' + ${msg.senderId}"></small>
</div>
<small th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(msg.timestamp).atZone(T(java.time.ZoneId).systemDefault()), 'yyyy-MM-dd HH:mm')}"></small>
</div>
<div class="message-content">
<p style="white-space: pre-wrap;" th:text="${msg.content}"></p>
<hr/>
<div class="reply-form">
<h4>답장 보내기</h4>
<form onsubmit="sendMessage(event, this)">
<input type="hidden" name="receiverId" th:value="${msg.senderId}" />
<div class="row gtr-50">
<div class="col-12">
<input type="text" name="title" placeholder="제목" th:value="'RE: ' + ${msg.title}" required />
</div>
<div class="col-12">
<textarea name="content" placeholder="내용" rows="4" required></textarea>
</div>
<div class="col-12">
<button type="submit" class="button primary">답장 전송</button>
</div>
</div>
</form>
</div>
</div>
</li>
</ul>
</div>
</div>
</section>
<script th:inline="javascript">
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
async function toggleMessage(headerElement) {
const messageItem = headerElement.closest('.message-item');
const content = messageItem.querySelector('.message-content');
const messageId = messageItem.dataset.messageId;
const isOpening = !content.classList.contains('active');
// 모든 열린 쪽지 닫기
document.querySelectorAll('.message-content.active').forEach(c => {
if(c !== content) c.classList.remove('active');
});
content.classList.toggle('active');
if (isOpening && messageItem.classList.contains('unread')) {
const response = await fetch(`/messages/${messageId}/read`, {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
});
if (response.ok) {
messageItem.classList.remove('unread');
// 헤더의 아이콘도 업데이트 할 수 있지만, 페이지 새로고침 전까지는 유지됩니다.
}
}
}
async function sendMessage(event, form) {
event.preventDefault();
const formData = new FormData(form);
const data = {
receiverId: formData.get('receiverId'),
title: formData.get('title'),
content: formData.get('content')
};
const submitButton = form.querySelector('button[type="submit"]');
submitButton.disabled = true;
submitButton.textContent = '전송 중...';
const response = await fetch('/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
body: JSON.stringify(data)
});
if(response.ok) {
alert('답장을 성공적으로 보냈습니다.');
form.querySelector('textarea').value = ''; // 내용만 초기화
toggleMessage(form.closest('.message-item').querySelector('.message-header')); // 답장 후 창 닫기
} else {
alert('답장 보내기에 실패했습니다.');
}
submitButton.disabled = false;
submitButton.textContent = '답장 전송';
}
</script>
</th:block>
</html>
@@ -15,6 +15,49 @@
.user-list li, .post-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; }
.user-list li:last-child, .post-list li:last-child { border-bottom: none; }
.button.small { margin-left: 0.5em; }
.custom-radio {
display: none; /* 기본 라디오 버튼 숨기기 */
}
.custom-label {
position: relative;
padding-left: 25px; /* 라벨 왼쪽에 가짜 버튼을 위한 공간 확보 */
cursor: pointer;
line-height: 20px;
display: inline-block;
color: #555; /* 라벨 텍스트 색상 */
}
/* 가짜 라디오 버튼 (원) 만들기 */
.custom-label::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 18px;
height: 18px;
border: 2px solid #ddd;
border-radius: 50%; /* 원 모양 */
background: #fff;
}
/* 선택되었을 때 가짜 라디오 버튼 스타일 변경 */
.custom-radio:checked + .custom-label::before {
border-color: #FFA500; /* 테두리 색상 변경 (사이트의 포인트 색상) */
background: #FFA500; /* 배경 색상 채우기 */
}
/* 선택되었을 때 원 안에 작은 점 추가 */
.custom-radio:checked + .custom-label::after {
content: '';
position: absolute;
left: 6px;
top: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: white;
}
</style>
</th:block>
@@ -31,10 +74,15 @@
<div class="tab-link" onclick="openTab(event, 'myPosts')">내가 쓴 글</div>
<div class="tab-link" onclick="openTab(event, 'myComments')">내가 쓴 댓글</div>
<div class="tab-link" onclick="openTab(event, 'myRanks')">내 게임 랭킹</div>
<div class="tab-link" onclick="openTab(event, 'myMessages')">쪽지함</div>
<div class="tab-link" onclick="openTab(event, 'myBookmarks')">저장한 페이지</div>
<th:block sec:authorize="hasRole('ADMIN')">
<div class="tab-link" onclick="openTab(event, 'userManagement')">회원 관리</div>
<div class="tab-link" onclick="openTab(event, 'postManagement')">게시물 관리</div>
<div class="tab-link" onclick="openTab(event, 'bannerManagement')">배너 관리</div>
<div class="tab-link" onclick="openTab(event, 'aboutManagement')">사이트 소개 관리</div>
</th:block>
</div>
@@ -78,7 +126,57 @@
</ul>
</div>
</div>
<div id="myBookmarks" class="tab-content">
<div class="box">
<h4>새 페이지 저장하기</h4>
<div id="bookmark-form">
<input type="url" id="bookmark-url-input" placeholder="저장할 페이지 URL을 입력하세요" style="margin-bottom: 1em;">
<div id="og-preview" style="display:none; border: 1px solid #ddd; padding: 1em; margin-bottom: 1em; border-radius: 5px;">
<img id="og-image" src="" style="max-width: 150px; float: left; margin-right: 1em;">
<h5 id="og-title"></h5>
<p id="og-description" style="font-size: 0.9em; color: #555;"></p>
</div>
<textarea id="bookmark-comment-input" placeholder="이 페이지에 대한 나의 생각 (선택)" rows="3"></textarea>
<div id="visibility-selector" style="margin-top: 1em; display: flex; align-items: center; flex-wrap: wrap;">
<strong style="margin-right: 1.5em;">공개 범위:</strong>
<div style="display: flex; align-items: center; margin-right: 1.5em;">
<input type="radio" name="visibility" id="visibility-private" value="PRIVATE" class="custom-radio" checked>
<label for="visibility-private" class="custom-label">비공개</label>
</div>
<div style="display: flex; align-items: center; margin-right: 1.5em;">
<input type="radio" name="visibility" id="visibility-members" value="MEMBERS" class="custom-radio">
<label for="visibility-members" class="custom-label">회원 공개</label>
</div>
<div style="display: flex; align-items: center;">
<input type="radio" name="visibility" id="visibility-public" value="PUBLIC" class="custom-radio">
<label for="visibility-public" class="custom-label">전체 공개</label>
</div>
</div>
<button id="save-bookmark-btn" class="button primary" style="margin-top: 1em;">저장하기</button>
</div>
</div>
<div class="box" style="margin-top: 2em;">
<h4>저장된 목록</h4>
<div id="bookmarks-list" class="row">
<div class="col-4 col-12-medium">
<section class="box feature">
<a href="#" class="image featured"><img src="/images/pic01.jpg" alt="" /></a>
<div class="inner">
<header>
<h2>카드 제목</h2>
<p>사용자 코멘트가 여기에 들어갑니다.</p>
</header>
<p style="font-size: 0.8em; color: #888;">원본 페이지 설명...</p>
</div>
</section>
</div>
</div>
</div>
</div>
<div id="myRanks" class="tab-content">
<div class="box">
<ul class="post-list">
@@ -104,7 +202,27 @@
</ul>
</div>
</div>
<div id="myMessages" class="tab-content">
<div class="box">
<ul class="post-list">
<li th:if="${#lists.isEmpty(myMessages)}">주고받은 쪽지가 없습니다.</li>
<li th:each="msg : ${myMessages}">
<div style="display: flex; align-items: center; gap: 1em;">
<span th:if="${msg.senderId == user.user_id}" class="tag-item" style="background: #e0f7fa;">보냄</span>
<span th:if="${msg.receiverId == user.user_id}" class="tag-item" style="background: #fffbe5;">받음</span>
<div style="min-width: 150px;">
<strong th:if="${msg.senderId == user.user_id}" th:text="'To: ' + ${msg.receiverId}"></strong>
<strong th:if="${msg.receiverId == user.user_id}" th:text="'From: ' + ${msg.senderId}"></strong>
</div>
<a th:href="@{/messages}" th:text="${msg.title}">쪽지 제목</a>
</div>
<span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(msg.timestamp).atZone(T(java.time.ZoneId).systemDefault()), 'yyyy-MM-dd HH:mm')}"></span>
</li>
</ul>
</div>
</div>
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box">
<h4>권한 요청</h4>
@@ -154,7 +272,8 @@
<ul class="post-list">
<li th:each="image : ${allImages}" th:id="'image-row-' + ${image.id}">
<div style="display: flex; align-items: center; gap: 1em;">
<img th:src="@{'/api/images/' + ${image.fileName}}" alt="Image Thumbnail" style="width: 100px; height: 60px; object-fit: cover; border-radius: 4px;"/>
<!-- <img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />-->
<img th:src="${apiBaseUrl + '/api/images/' + image.fileName + '?type=thumbnail'}" alt="Image Thumbnail" style="width: 100px; height: 60px; object-fit: cover; border-radius: 4px;"/>
<div>
<strong th:text="${image.fileName}"></strong><br>
<span th:if="${image.isBannerCandidate}" style="color: #2a9d8f; font-weight: bold;">(배너로 사용 중)</span>
@@ -170,6 +289,27 @@
</ul>
</div>
</div>
<div id="aboutManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box">
<h4>사이트 소개글 관리</h4>
<p>
'사이트 소개' 페이지에 표시될 내용입니다. 글을 수정하면 이전 버전은 히스토리로 여기에 남게 됩니다.
</p>
<th:block th:with="latestAbout=${!#lists.isEmpty(aboutPostHistory) ? aboutPostHistory[0] : null}">
<a th:if="${latestAbout != null}" th:href="@{/blog/edit/{postId}(postId=${latestAbout.id})}" class="button primary">최신 소개글 수정</a>
<a th:if="${latestAbout == null}" th:href="@{/blog/edit(type='ABOUT_SITE')}" class="button">새 소개글 작성</a>
</th:block>
<h5 style="margin-top: 2em;">수정 히스토리</h5>
<ul class="post-list">
<li th:each="post : ${aboutPostHistory}">
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">수정된 버전 제목</a>
<span th:text="|수정일: ${#dates.format(post.modifyTime, 'yyyy-MM-dd HH:mm')}|"></span>
</li>
<li th:if="${#lists.isEmpty(aboutPostHistory)}">작성된 소개글이 없습니다.</li>
</ul>
</div>
</div>
</div>
</section>
@@ -315,6 +455,83 @@
alert('작업 중 오류가 발생했습니다.');
});
}
document.addEventListener('DOMContentLoaded', function() {
const urlInput = document.getElementById('bookmark-url-input');
const preview = document.getElementById('og-preview');
let ogData = {}; // OG 파싱 결과를 저장할 변수
// URL 입력 필드에서 포커스가 벗어났을 때(onblur) OG 정보 파싱 API 호출
urlInput.addEventListener('blur', async function() {
const url = this.value.trim();
if (!url) return;
try {
const response = await fetch(`/api/og/parse?url=${encodeURIComponent(url)}`);
if (!response.ok) throw new Error('파싱 실패');
ogData = await response.json();
// 미리보기 UI 업데이트
document.getElementById('og-title').textContent = ogData.title || '제목 없음';
document.getElementById('og-description').textContent = ogData.description || '';
const ogImage = document.getElementById('og-image');
if (ogData.thumbnailUrl) {
ogImage.src = ogData.thumbnailUrl;
ogImage.style.display = 'block';
} else {
ogImage.style.display = 'none';
}
preview.style.display = 'block';
} catch (error) {
console.error(error);
preview.style.display = 'none';
alert('페이지 정보를 가져오는 데 실패했습니다. URL을 확인해주세요.');
}
});
// 저장 버튼 클릭 이벤트
document.getElementById('save-bookmark-btn').addEventListener('click', async function() {
const comment = document.getElementById('bookmark-comment-input').value.trim();
// [수정] 선택된 공개 범위(visibility) 값을 읽어오는 코드 추가
const visibility = document.querySelector('input[name="visibility"]:checked').value;
const bookmarkData = {
url: urlInput.value.trim(),
// title, description 등은 ogData에서 가져오는 것은 그대로 유지
title: ogData.title,
description: ogData.description,
thumbnailUrl: ogData.thumbnailUrl,
userComment: comment,
// [수정] bookmarkData 객체에 visibility 프로퍼티 추가
visibility: visibility
};
if (!bookmarkData.url) {
alert('URL을 입력해주세요.');
return;
}
// 서버에 북마크 저장 요청 (이하 코드는 동일)
const response = await fetch('/user/bookmarks/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[csrfHeader]: csrfToken
},
body: JSON.stringify(bookmarkData)
});
if (response.ok) {
alert('페이지가 저장되었습니다.');
location.reload();
} else {
alert('저장에 실패했습니다.');
}
});
});
</script>
</th:block>
</html>
@@ -5,14 +5,7 @@
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="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" />
<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>
<script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block>
<th:block layout:fragment="content" id="content">
@@ -108,5 +101,14 @@
</div>
</div>
</section>
<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>
<script>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>
</th:block>
</html>
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">>
<th:block th:fragment="footer">
<script th:inline="javascript">
/*<![CDATA[*/
@@ -9,60 +10,66 @@
/*]]>*/
</script>
<div id="footer">
<div class="container">
<div class="row">
<section class="col-3 col-6-narrower col-12-mobilep">
<h3 id="ranking-title">Rank of Views</h3>
<ul class="rank_of_view" >
</ul>
</section>
<section class="col-3 col-6-narrower col-12-mobilep">
<h3>Recent of Posts</h3>
<ul class="recent_posts">
<div id="footer">
<div class="container">
<div class="row">
<section class="col-3 col-6-narrower col-12-mobilep">
<h3 id="ranking-title">Rank of Views</h3>
<ul class="rank_of_view" >
</ul>
</section>
<section class="col-3 col-6-narrower col-12-mobilep">
<h3>Recent of Posts</h3>
<ul class="recent_posts">
</ul>
</section>
<section class="col-6 col-12-narrower">
<h3>SEND TO ME(TELEGRAM BOT)</h3>
<div id="tlg_form" >
<div class="row gtr-50">
<div class="col-6 col-12-mobilep">
</ul>
</section>
<section class="col-6 col-12-narrower">
<h3>SEND TO ME(TELEGRAM BOT)</h3>
<div id="tlg_form" >
<div class="row gtr-50">
<div class="col-6 col-12-mobilep">
<div sec:authorize="isAuthenticated()">
<input type="text" name="name" id="name" placeholder="Name" th:value="${#authentication.principal.username}" readonly />
</div>
<div sec:authorize="isAnonymous()">
<input type="text" name="name" id="name" placeholder="Name" />
</div>
<div class="col-6 col-12-mobilep">
<input type="email" name="email" id="email" placeholder="Email" />
</div>
<div class="col-12">
<textarea name="message" id="message" placeholder="Message" rows="5"></textarea>
</div>
<div class="col-12">
<ul class="actions">
<li><input type="submit" class="button alt" value="Send Message" onclick="callSendTlg()" /></li>
</ul>
</div>
</div>
<div class="col-6 col-12-mobilep">
<input type="email" name="email" id="email" placeholder="Email" />
</div>
<div class="col-12">
<textarea name="message" id="message" placeholder="Message" rows="5"></textarea>
</div>
<div class="col-12">
<ul class="actions">
<li><input type="submit" class="button alt" value="Send Message" onclick="callSendTlg()" /></li>
</ul>
</div>
</div>
</section>
</div>
</div>
<!-- Icons -->
<ul class="icons">
<li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
<li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li><a href="#" class="icon brands fa-github"><span class="label">GitHub</span></a></li>
<li><a href="#" class="icon brands fa-linkedin-in"><span class="label">LinkedIn</span></a></li>
<li><a href="#" class="icon brands fa-google-plus-g"><span class="label">Google+</span></a></li>
</ul>
<!-- Copyright -->
<div class="copyright">
<ul class="menu">
<li>&copy;lunaticbum All rights reserved</li><li>Origin Design from:<a href="http://html5up.net">HTML5 UP</a></li>
</ul>
</div>
</section>
</div>
</div>
<!-- Icons -->
<ul class="icons">
<li><a href="#" class="icon brands fa-twitter"><span class="label">Twitter</span></a></li>
<li><a href="#" class="icon brands fa-facebook-f"><span class="label">Facebook</span></a></li>
<li><a href="#" class="icon brands fa-github"><span class="label">GitHub</span></a></li>
<li><a href="#" class="icon brands fa-linkedin-in"><span class="label">LinkedIn</span></a></li>
<li><a href="#" class="icon brands fa-google-plus-g"><span class="label">Google+</span></a></li>
</ul>
<!-- Copyright -->
<div class="copyright">
<ul class="menu">
<li>&copy;lunaticbum All rights reserved</li><li>Origin Design from:<a href="http://html5up.net">HTML5 UP</a></li>
</ul>
</div>
</div>
<script type="text/javascript">
@@ -15,19 +15,21 @@
<ul>
<li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</a></li>
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>
<li id="menu_2048"><a href="puzzle/2048">2048</a></li>
<li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li>
<li id="menu_spider"><a href="puzzle/spider">spider</a></li>
<!-- <li id="menu_sec"><a href="left-sidebar">Left Sidebar</a></li>-->
<!-- <li id="menu_thr"><a href="right-sidebar">Right Sidebar</a></li>-->
<!-- <li id="menu_four"><a href="two-sidebar">Two Sidebar</a></li>-->
<li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li>
<li id="menu_drop">
<a href="#">Game</a>
<ul>
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>
<li id="menu_2048"><a href="puzzle/2048">2048</a></li>
<li id="menu_sudoku"><a href="puzzle/sudoku">sudoku</a></li>
<li id="menu_spider"><a href="puzzle/spider">spider</a></li>
</ul>
</li>
<li id="menu_drop">
<a href="#">About</a>
<ul>
<li><a href="javascript:gotoWhere()">bums's where</a></li>
<li><a href="#">Magna phasellus</a></li>
<li><a href="#">Etiam sed tempus</a></li>
<li><a href="javascript:gotoBUMSpace()">BUM'sPase</a></li>
<li>
<a href="#">Submenu</a>
<ul>
@@ -46,9 +48,12 @@
</ul>
</li>
<th:block sec:authorize="isAuthenticated()">
<li><a th:href="@{/user/info}">내 정보</a></li>
</th:block>
<li sec:authorize="isAuthenticated()">
<a href="/user/info" th:text="${#authentication.principal.username}">사용자ID</a>
<a href="/messages" id="message-icon" style="display: none; color: #FFA500; margin-left: 5px;" title="새 쪽지">
쪽지함<i class="icon solid fa-envelope"></i>
</a>
</li>
<th:block sec:authorize="!isAuthenticated()">
<li id="menu_login">
<a class="open-login-popup" to="#loginPopup">Login</a>
@@ -73,7 +73,23 @@
</div>
</div>
</div>
<div id="iframe-viewer-popup" class="pop_layer" style="width: 90%; height: 90%; max-width: 1400px;">
<div class="pop_container" style="height: 100%; display: flex; flex-direction: column;">
<div class="pop_header" style="display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; border-bottom: 1px solid #eee; background: #f8f8f8;">
<h4 id="iframe-viewer-title" style="margin: 0; font-size: 1em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></h4>
<a href="#" class="btn_layerClose" style="font-size: 1.5em;" onclick="closePopup()">×</a>
</div>
<div class="pop_conts" style="flex-grow: 1; padding: 0;">
<iframe id="bookmark-iframe" src="" style="width: 100%; height: 100%; border: none;">
이 브라우저는 iframe을 지원하지 않습니다.
</iframe>
</div>
<div class="pop_footer" style="padding: 10px 20px; border-top: 1px solid #eee; background: #f8f8f8; text-align: center; font-size: 0.9em;">
콘텐츠가 표시되지 않나요?
<a id="iframe-open-new-tab-link" href="#" target="_blank" class="button small alt" style="margin-left: 1em; vertical-align: middle;" onclick="closePopup()">새 탭에서 열기</a>
</div>
</div>
</div>
<div id="unified-game-success-modal" class="pop_layer">
<div class="pop_container"> <div class="pop_conts"> <h2 id="ugsm-title">🎉 성공! 🎉</h2>
<p id="ugsm-message">여기에 성공 메시지가 표시됩니다.</p>