This commit is contained in:
2025-09-08 16:35:09 +09:00
parent 26a0f14e54
commit 51b97e2422
17 changed files with 1328 additions and 258 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ resource.handler=.
resource.location=.
server.forward-headers-strategy=framework
#>>>>>>> ab915d0a416c69708f1df1ad76d7a14c779c1f59
logging.level.org.thymeleaf=DEBUG
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
+210 -9
View File
@@ -140,24 +140,28 @@ a.btn_layerClose:hover {
margin-top: 2em;
padding: 0;
display: flex;
justify-content: space-between;
flex-direction: row; /* [수정] 아이템을 가로(row)로 정렬합니다. */
align-items: stretch; /* [수정] 모든 박스가 동일한 높이를 갖도록 stretch로 변경 */
list-style: none;
gap: 15px; /* Use gap for spacing */
}
.write_option {
display: flex; /* Use flexbox for centering */
justify-content: center;
align-items: center;
text-align: center;
width: 100%;
display: flex;
flex-direction: column; /* [수정] 자식(제목, 내용)을 세로로 쌓습니다. */
justify-content: flex-start; /* 내용을 상단부터 표시합니다. */
align-items: flex-start; /* 모든 내용을 왼쪽 정렬합니다. */
flex: 1 1 0%; /* [핵심] 1:1:1 비율로 동일한 너비를 갖도록 설정 */
min-width: 0; /* [추가] flex item이 내용 래핑을 위해 축소될 수 있도록 허용 */
padding: 0.75em;
margin: 0;
background: var(--pure-white, #fff);
border: solid 1px #e0e0e0;
border-radius: 5px;
color: inherit;
min-height: 48px; /* Set a minimum height */
min-height: 48px;
}
@@ -178,11 +182,35 @@ a.btn_layerClose:hover {
}
.ql-container.ql-snow {
min-width: unset;
background: var(--pure-white, #fff);
border: 1px solid #e0e0e0;
border-radius: 0 0 5px 5px;
color: var(--font-color_default, #474747);
}
/* * --- QUILL TOOLBAR SEPARATOR (구분선) ---
* 기본 'margin-right: 15px' 대신 패딩과 보더로 구분선을 추가합니다.
*/
.ql-toolbar.ql-snow .ql-formats {
margin-right: unset;
}
.ql-toolbar.ql-snow .ql-formats {
min-width: unset;
margin-right: 8px; /* 기본 마진 감소 */
padding-right: 8px; /* 보더 오른쪽에 패딩 추가 */
border-right: 1px solid #e0e0e0; /* 테마의 보더 색상으로 구분선 추가 */
}
/* 툴바의 마지막 그룹에는 구분선과 불필요한 여백을 제거합니다. */
.ql-toolbar.ql-snow .ql-formats:last-child {
min-width: unset;
margin-right: 0;
padding-right: 0;
border-right: none;
}
/* Ensure editor content uses the theme's default font and size */
.ql-editor {
@@ -191,6 +219,27 @@ a.btn_layerClose:hover {
line-height: 1.65em;
}
.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button {
background: none;
border: none;
cursor: pointer;
display: inline-block;
float: left;
height: 24px;
padding: 3px 5px;
min-width: unset;
}
.ql-snow .ql-picker-label {
cursor: pointer;
display: inline-block;
height: 100%;
padding-left: unset;
padding-right: unset;
position: relative;
width: 100%;
}
/*
* --- COMMENT SECTION ---
* Styled to fit the main theme.
@@ -239,13 +288,20 @@ a.btn_layerClose:hover {
word-wrap: break-word;
}
/* 읽기 모드 컨트롤 박스 내부의 태그 스타일 */
/* 컨트롤 박스 내부 공통 제목 스타일 */
.write_option .tag-title {
font-weight: 600;
margin-right: 0.5em;
margin-bottom: 0.35em; /* [수정] 제목과 내용 사이에 하단 여백을 줍니다. */
color: #555;
font-size: 0.85em; /* [추가] 제목 라벨 폰트를 살짝 작게 */
text-transform: uppercase; /* [추가] 라벨처럼 보이도록 영문 대문자 처리 */
/* 태그 제목(HASHTAGS)이 태그 아이템과 동일한 스타일을 공유할 경우 대비 (선택 사항) */
display: block;
width: 100%;
}
/* 컨트롤 박스 내부 공통 태그 아이템 스타일 (뷰어 모드용) */
.write_option .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
@@ -261,4 +317,149 @@ a.btn_layerClose:hover {
.write_option.controlbox-category:not(.btn-example),
.write_option.controlbox-hashtag:not(.btn-example) {
cursor: default;
}
/* [추가] 태그/내용을 감싸는 래퍼 (줄바꿈 담당) */
.tag-content-wrapper {
display: flex; /* 내부 아이템(태그 등)을 가로로 배치 */
flex-wrap: wrap; /* 태그가 많으면 자동 줄바꿈 (기본값) */
gap: 4px 6px; /* 태그 사이의 간격 (세로 4px, 가로 6px) */
line-height: 1.4; /* 내용 줄간격 */
width: 100%; /* 부모 박스(write_option) 너비를 채움 */
}
/* --- 컨트롤 박스 공통 스타일 (카테고리, 해시태그, 위치) --- */
/* [수정] 3개 박스에 공통 스타일을 적용합니다. */
.write_option.controlbox-category,
.write_option.controlbox-hashtag,
.write_option.controlbox-location {
/* 모든 박스는 .write_option의 flex: 1 1 0% 규칙을 따릅니다. */
box-sizing: border-box;
min-height: 50px; /* 모든 박스의 최소 높이 통일 */
}
/* --- 컨트롤 박스 내부 태그 아이템 공통 스타일 (팝업 편집용) --- */
/* (카테고리와 해시태그 박스 내부의 태그 아이템만 이 스타일을 공유) */
.write_option.controlbox-location .tag-item,
.write_option.controlbox-category .tag-item,
.write_option.controlbox-hashtag .tag-item {
background-color: var(--highlight-bg-color, #e9e9e9);
color: var(--highlight-text-color, #333);
border-radius: 5px;
padding: 3px 8px;
font-size: 0.9em;
white-space: normal;
margin-right: 0; /* .tag-content-wrapper의 gap이 간격을 제어하므로 마진 제거 */
}
/* --- 컨트롤 박스: 카테고리 (개별 내부 스타일) --- */
.write_option.controlbox-category .tag-title {
font-weight: bold;
color: #555;
/* margin-right: 8px; <-- 세로 레이아웃으로 변경되어 불필요 */
white-space: nowrap;
}
/* --- 컨트롤 박스: 해시태그 (개별 내부 스타일) --- */
.write_option.controlbox-hashtag {
/* 태그 사이의 내부 간격 (래퍼가 대신 처리) */
/* gap: 8px 10px; */
}
.write_option.controlbox-hashtag .tag-title {
font-weight: bold;
color: #555;
margin-right: 0px;
white-space: nowrap;
}
/* --- 컨트롤 박스: 콘텐츠 래퍼 동작 정의 --- */
/* 해시태그는 .tag-content-wrapper의 기본값(flex-wrap: wrap)을 그대로 사용합니다. */
/* [추가/그룹화] 카테고리와 위치 박스는 내용이 줄바꿈되지 않도록 공통 처리합니다. */
/*.write_option.controlbox-category .tag-content-wrapper,*/
/*.write_option.controlbox-location .tag-content-wrapper {*/
/* white-space: nowrap; !* 내용이 길어도 한 줄로 표시 (줄바꿈 방지) *!*/
/* overflow: hidden;*/
/* text-overflow: ellipsis;*/
/*}*/
/* 추가: 컨트롤 박스 자체의 레이아웃 (이전 CSS에서 남음. 현재 구조에서는 영향 없음) */
#main_container .write_option {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
/* === POPUP TAG LIST STYLES (신규 추가) === */
.pop_conts .tag-list {
display: flex; /* 태그들을 가로로 나열 */
flex-wrap: wrap; /* 공간이 부족하면 다음 줄로 자동 줄바꿈 */
gap: 8px; /* 태그 아이템 사이의 간격을 8px로 지정 */
margin-bottom: 1.5em; /* 태그 목록과 하단 입력창 사이의 간격 */
padding-bottom: 1.5em; /* 태그 목록 하단 패딩 */
border-bottom: 1px solid #e0e0e0; /* 입력창과 목록을 구분하는 선 */
}
.pop_conts .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
border: 1px solid #e0e0e0;
border-radius: 15px; /* 둥근 알약 모양 */
padding: 0.3em 0.9em; /* 내부 여백 */
font-size: 0.9em;
cursor: pointer; /* 클릭 가능하도록 커서 변경 */
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.pop_conts .tag-item:hover {
background-color: #e0e0e0; /* 호버 시 배경색 변경 */
border-color: #ccc;
}
/* === (신규 추가) POPUP STAGING AREA STYLES === */
.pop_conts .tag-list-label {
font-weight: 600;
font-size: 0.9em;
color: #777;
margin-top: 1.2em; /* 각 섹션 상단 여백 */
margin-bottom: 0.5em;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
/* 선택된 항목을 담는 스테이징 영역 스타일 */
.pop_conts .staging-area {
min-height: 40px;
padding: 0.5em;
background: #fdfdfd;
border: 1px dashed #ccc; /* 점선 테두리로 구분 */
border-radius: 5px;
}
/* 스테이징 영역 내부의 태그에서 'X' (삭제) 버튼 스타일 */
.staging-area .tag-item .remove-tag {
font-family: "Courier New", monospace; /* 'X' 버튼 글꼴 */
font-weight: bold;
color: #cc0000; /* 어두운 빨간색 */
margin-left: 8px;
cursor: pointer; /* 클릭 가능하도록 */
display: inline-block;
padding: 0 2px;
}
.staging-area .tag-item .remove-tag:hover {
color: #ff0000; /* 밝은 빨간색 */
background-color: #eee;
}
/* 카테고리 스테이징 영역은 flex가 아니므로, 내부 태그 마진을 별도 지정 */
#selected-category-area .tag-item {
margin: 0;
}
#selected-category-area i, #selected-hashtags-area i {
color: #999;
}
+128 -18
View File
@@ -1,18 +1,128 @@
/*.layer {*/
/* height: 100%;*/
/* place-content: space-between;*/
/* place-items: stretch;*/
/* display: grid;*/
/* gap: 10px;*/
/* grid-auto-rows: minmax(200px, auto);*/
/* grid-template-columns: repeat(auto-fill, minmax(200px, auto));*/
/* width: 100%;*/
/*}*/
/*!*.where_item {*!*/
/*!* justify-content: space-between;*!*/
/*!* flex-wrap: wrap;*!*/
/*!* flex-direction: row;*!*/
/*!* width: 100%;*!*/
/*!* border-radius: 10px;*!*/
/*!* background: #F0F0F524;*!*/
/*!*}*!*/
/* 기본 레이아웃 및 폰트 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f4f7f6;
color: #333;
line-height: 1.6;
}
#main_layer {
max-width: 900px;
margin: 30px auto;
background-color: #ffffff;
padding: 25px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.layer {
padding: 10px 0;
}
/* 개별 위치 로그 아이템 스타일 */
.location-item {
background-color: #fcfcfc;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 20px;
padding: 15px 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease-in-out;
}
.location-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* 헤더 (날짜 및 경과 시간) */
.location-header {
display: flex; /* 가로로 배치 */
justify-content: space-between; /* 양 끝 정렬 */
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.location-time {
font-weight: bold;
color: #555;
font-size: 1.1em;
}
.location-between {
font-size: 0.9em;
color: #888;
background-color: #eef;
padding: 3px 8px;
border-radius: 4px;
}
/* 본문 (주소 및 좌표) */
.location-body {
font-size: 0.95em;
}
.location-address {
margin-top: 0;
margin-bottom: 8px;
color: #444;
word-break: break-all; /* 긴 주소 자동 줄바꿈 */
}
.location-coords {
margin: 0;
color: #666;
font-style: italic;
display: flex; /* 위도, 경도, 국가명 가로 배치 */
gap: 15px; /* 각 요소 사이 간격 */
flex-wrap: wrap; /* 화면 좁아지면 줄바꿈 */
}
.location-coords b {
color: #333;
font-weight: bold;
}
.location-country {
color: #777;
font-size: 0.9em;
}
/* 페이지네이션 스타일 */
.pagination {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.pagination a, .pagination span {
display: inline-block;
padding: 8px 15px;
margin: 0 4px;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #007bff;
background-color: #f8f9fa;
transition: all 0.2s ease;
}
.pagination a:hover {
background-color: #e9ecef;
border-color: #007bff;
color: #0056b3;
}
.pagination span { /* 현재 페이지 또는 비활성화된 버튼 */
color: #6c757d;
background-color: #e2e6ea;
border-color: #e2e6ea;
cursor: default;
}
/* 기존 CSS에서 불필요한 부분 제거 또는 통합 */
.where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */
/* display: none; */
}
+405 -24
View File
@@ -8,6 +8,9 @@
* =================================================================================
*/
var stagedCategory = 'none';
var stagedHashtags = []; // 해시태그는 배열로 관리
// 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다.
var quill = null;
var currentLat = 0.0;
@@ -55,6 +58,83 @@ $(document).ready(function() {
$('.open-login-popup').on('click', function() {
openPopup(this);
});
/* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */
// --- 1. Category Popup Logic ---
const categoryInput = document.getElementById('category-input');
const addCategoryBtn = document.getElementById('add-category-btn');
const applyCategoryBtn = document.getElementById('apply-category-btn'); // (신규) 적용 버튼 선택
// "Add" 버튼 (입력창에서 추가) 로직 (수정됨)
if (addCategoryBtn) {
addCategoryBtn.addEventListener('click', function() {
const newCategory = categoryInput.value.trim();
if (newCategory) {
stagedCategory = newCategory; // 1. 임시 변수(stagedCategory) 업데이트
renderStagedCategory(); // 2. 스테이징 UI 새로고침
categoryInput.value = ''; // 3. 입력창 비우기 (팝업 유지)
}
});
}
// "Enter" 키 지원 (기존과 동일)
if (categoryInput) {
categoryInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter' || e.keyCode === 13) {
addCategoryBtn.click();
}
});
}
// (신규) "Apply" 버튼 로직
if (applyCategoryBtn) {
applyCategoryBtn.addEventListener('click', function(e) {
e.preventDefault(); // A태그 기본 동작(새로고침/이동) 방지
// "적용" 시점에만 실제 baseData를 임시 변수 값으로 덮어쓰기
baseData.category = stagedCategory;
updateControlBoxDisplay(); // 본문 에디터 UI 갱신
closePopup(); // 팝업 닫기
});
}
// --- 2. Hashtag Popup Logic ---
const hashtagInput = document.getElementById('hashtag-input');
const addHashtagBtn = document.getElementById('add-hashtag-btn');
const applyHashtagBtn = document.getElementById('apply-hashtag-btn'); // (신규) 적용 버튼 선택
// "Add" 버튼 (입력창에서 추가) 로직 (수정됨)
if (addHashtagBtn) {
addHashtagBtn.addEventListener('click', function() {
// 1. 임시 배열(stagedHashtags)에 추가 (신규 헬퍼 함수 사용)
if (addTagToStaged(hashtagInput.value)) {
renderStagedHashtags(); // 2. 추가 성공 시 스테이징 UI 새로고침
}
hashtagInput.value = ''; // 3. 입력창은 항상 비움
});
}
// "Enter" 키 지원 (기존과 동일)
if (hashtagInput) {
hashtagInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter' || e.keyCode === 13) {
addHashtagBtn.click();
}
});
}
// (신규) "Apply" 버튼 로직
if (applyHashtagBtn) {
applyHashtagBtn.addEventListener('click', function(e) {
e.preventDefault(); // A태그 기본 동작 방지
// "적용" 시점에 임시 배열을 쉼표(,)로 구분된 문자열로 변환하여 실제 baseData에 저장
baseData.tags = stagedHashtags.join(',');
updateControlBoxDisplay(); // 본문 에디터 UI 갱신
closePopup(); // 팝업 닫기
});
}
/* =============================================================== */
});
@@ -98,8 +178,8 @@ function initEditor(useEditor = false) {
['bold', 'italic', 'underline', 'strike'], [{ 'color': [] }, { 'background': [] }],
[{ 'header': 1 }, { 'header': 2 }, 'blockquote', 'code-block'],
[{ 'script': 'sub'}, { 'script': 'super' }], [{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }], ['link', 'image', 'video'],
['table-better'], [{ 'direction': 'rtl' }], [{ 'align': [] }], ['clean']
[{ 'indent': '-1'}, { 'indent': '+1' }], [{ 'align': [] }],
['table-better'], [{ 'direction': 'rtl' }], ['clean'], ['link', 'image', 'video'],
],
handlers: { image: function() { selectLocalImage(); }, video: function() { selectLocalVideo(); } }
},
@@ -248,56 +328,88 @@ function setupControlBox(mode) {
if (mode === 'edit') {
categoryBox.setAttribute('onclick', 'openPopup(this)');
hashtagBox.setAttribute('onclick', 'openPopup(this)');
categoryBox.innerText = '카테고리 설정';
hashtagBox.innerText = '해시태그 편집';
fetchCategoriesAndHashtags();
// === 수정된 부분 ===
// 기존 "innerText" 설정 줄을 삭제하고,
// 페이지 로드 시 현재 데이터로 UI를 업데이트하는 함수를 호출합니다.
updateControlBoxDisplay();
// ==================
fetchCategoriesAndHashtags(); // 팝업 목록 채우는 로직은 그대로 실행
} else {
// (읽기 모드 'else' 블록 수정)
categoryBox.removeAttribute('onclick');
hashtagBox.removeAttribute('onclick');
categoryBox.classList.remove('btn-example');
hashtagBox.classList.remove('btn-example');
categoryBox.innerHTML = `<span class="tag-title">카테고리: </span><span class="tag-item">${baseData.category || '지정되지 않음'}</span>`;
// [수정] 카테고리를 새 구조(제목 + 래퍼)로 변경
const categoryContent = `<span class="tag-item">${baseData.category || '지정되지 않음'}</span>`;
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
hashtagBox.innerHTML = '<span class="tag-title">태그: </span>';
// [수정] 해시태그를 새 구조(제목 + 래퍼)로 변경
let hashtagContent = '';
if (baseData.tags && baseData.tags.length > 0) {
baseData.tags.split(',').forEach(tag => {
hashtagBox.innerHTML += `<span class="tag-item">#${tag.trim()}</span>`;
});
hashtagContent = baseData.tags.split(',').map(tag => {
return `<span class="tag-item">#${tag.trim()}</span>`;
}).join(' '); // join으로 하나의 문자열로 만듭니다.
} else {
hashtagBox.innerHTML += '<span>없음</span>';
hashtagContent = '<span>없음</span>';
}
hashtagBox.innerHTML = `<span class="tag-title">HASHTAGS</span><div class="tag-content-wrapper">${hashtagContent}</div>`;
}
}
/**
* 백엔드 API를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다.
*/
/**
* 백엔드 API를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다.
* (수정됨: 클릭 시 baseData 대신 Staging 변수를 업데이트하도록 변경)
*/
function fetchCategoriesAndHashtags() {
// Fetch Categories
fetch(`${getMainPath()}/blog/categories.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#category-list');
if(list) {
if (list) {
list.innerHTML = '';
data.tags.forEach(tag => {
const el = document.createElement('span');
el.className = 'tag-item';
el.innerText = tag;
// (로직 변경) 클릭 시
el.onclick = function() {
stagedCategory = tag; // 1. 임시 변수(stagedCategory) 업데이트
renderStagedCategory(); // 2. 스테이징 UI만 새로고침 (팝업 안 닫음)
};
list.appendChild(el);
});
}
}
}).catch(err => console.error('Error fetching categories:', err));
// Fetch Hashtags
fetch(`${getMainPath()}/blog/hashtags.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#hashtag-list');
if(list) {
if (list) {
list.innerHTML = '';
data.tags.forEach(tag => {
const rawTag = tag;
const el = document.createElement('span');
el.className = 'tag-item';
el.innerText = `#${tag}`;
el.innerText = `#${rawTag}`;
// (로직 변경) 클릭 시
el.onclick = function() {
// 1. 임시 배열(stagedHashtags)에 추가 (중복 방지 헬퍼 사용)
if (addTagToStaged(rawTag)) {
// 2. 추가 성공 시에만 스테이징 UI 새로고침
renderStagedHashtags();
}
};
list.appendChild(el);
});
}
@@ -333,18 +445,66 @@ function loadEditor() {
*/
function save() {
const titleField = document.getElementById('title_field');
// --- (수정/신규 로직) ---
// 1. baseData의 복사본을 만들어 전송용 임시 객체(dataToSend)를 생성합니다.
// (원본 baseData를 직접 수정하면 팝업 UI가 인코딩된 문자로 깨집니다)
let dataToSend = JSON.parse(JSON.stringify(baseData));
// 2. dataToSend 객체의 모든 텍스트 필드를 encodeURIComponent로 인코딩합니다.
if (titleField) {
baseData.title = encodeURIComponent(titleField.value);
dataToSend.title = encodeURIComponent(titleField.value);
} else {
dataToSend.title = encodeURIComponent(dataToSend.title || '');
}
baseData.content = encodeURIComponent(JSON.stringify(quill.getContents()));
baseData.modifyLat = currentLat;
baseData.modifyLon = currentLon;
dataToSend.content = encodeURIComponent(JSON.stringify(quill.getContents()));
// (누락되었던 필드 추가)
dataToSend.category = encodeURIComponent(dataToSend.category || 'none');
dataToSend.tags = encodeURIComponent(dataToSend.tags || '');
// 3. 좌표 데이터를 임시 객체에 업데이트합니다.
dataToSend.modifyLat = currentLat;
dataToSend.modifyLon = currentLon;
// (신규 게시물일 경우 원본 위치 좌표 설정)
if (dataToSend.firstPostLat === 0.0 || dataToSend.firstPostLat === null) {
dataToSend.firstPostLat = currentLat;
}
if (dataToSend.firstPostLon === 0.0 || dataToSend.firstPostLon === null) {
dataToSend.firstPostLon = currentLon;
}
const uploadUrl = `${getMainPath()}/blog/post.bjx`;
if (confirm("해당 내용으로 저장하시겠습니까?")) {
post(uploadUrl, serverData.enc, JSON.stringify(baseData), serverData.keyword, function(resultData) {
alert("저장되었습니다.");
console.log("Data being sent to server:", dataToSend);
console.log("JSON string being sent:", JSON.stringify(dataToSend));
post(uploadUrl, serverData.enc, JSON.stringify(dataToSend), serverData.keyword, function(resultData) {
// --- (수정된 콜백 로직) ---
try {
// 1. 서버로부터 받은 JSON 문자열을 객체로 파싱합니다.
const response = JSON.parse(resultData);
// 2. 서버 응답이 성공(resultCode === 0)이고,
// 서버가 postId를 (예: response.data.postId) 보내줬는지 확인합니다.
// (참고: 'response.data.postId'는 서버 응답 구조에 따라 변경해야 할 수 있습니다.)
if (response.resultCode === 0 && response.data && response.data.postId) {
// 3. 알림 후, 응답받은 ID를 사용해 뷰어 페이지로 리디렉션합니다.
alert("저장되었습니다. 게시물 보기 페이지로 이동합니다.");
location.href = getMainPath() + "/blog/viewer/" + response.data.postId;
} else {
// 저장은 성공했으나 ID를 받지 못한 경우 (또는 서버가 다른 에러 코드를 보낸 경우)
alert("저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error"));
}
} catch (e) {
// JSON 파싱 실패 등 예외 처리
console.error("Failed to parse save response:", e, resultData);
alert("저장에 성공했으나 서버 응답을 처리할 수 없습니다.");
}
// --- (여기까지 수정된 로직) ---
});
}
}
@@ -353,7 +513,32 @@ function save() {
* 사용자의 현재 위치(위도, 경도)를 가져옵니다.
*/
function getLocation() {
if (navigator.geolocation) {
if (baseData.firstPostLat !== 0.0 || baseData.firstPostLon !== 0.0) {
try {
var requestOptions = {
method: 'GET',
};
fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+baseData.firstPostLat+"&lon="+baseData.firstPostLon+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions)
.then(response => response.json())
.then(function(result) {
const locationField = document.getElementById('location_field');
try {
var inh = `<span class="tag-title">LOCATION</span>`;
inh = inh + `<div class="tag-content-wrapper">`;
try{
inh = inh + `<div class="tag-item">${result.features[0].properties.formatted}</div>`;
}catch(err) {}
inh = inh + `<div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div>`;
inh = inh + `<div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
locationField.innerHTML = inh;
} catch (e) {
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
}
})
.catch(error => console.log('error', error));
}catch (e) { }
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
currentLat = pos.coords.latitude;
currentLon = pos.coords.longitude;
@@ -363,9 +548,18 @@ function getLocation() {
baseData.modifyLon = currentLon;
const locationField = document.getElementById('location_field');
if (locationField) {
locationField.textContent = `Lat: ${currentLat.toFixed(4)}, Lon: ${currentLon.toFixed(4)}`;
// [수정] 제목과 내용 래퍼 구조로 변경
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
}
});
} else {
const locationField = document.getElementById('location_field');
if (locationField) {
// [수정] 제목과 내용 래퍼 구조로 변경
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
}
}
}
@@ -373,10 +567,29 @@ function getLocation() {
* 팝업 레이어를 엽니다.
*/
function openPopup(element) {
const targetId = element.getAttribute('to');
const popup = document.querySelector(targetId);
const overlay = document.querySelector('.dim_layer');
if (popup && overlay) {
// === (신규) Staging 변수 초기화 로직 ===
if (targetId === '#popLayer1') { // 카테고리 팝업
// 1. 실제 데이터(baseData)에서 임시 변수(stagedCategory)로 값을 복사
stagedCategory = baseData.category || 'none';
// 2. 임시 변수 기준으로 스테이징 UI 렌더링
renderStagedCategory();
}
else if (targetId === '#popLayer2') { // 해시태그 팝업
// 1. 실제 데이터(baseData)에서 임시 배열(stagedHashtags)로 값을 복사
// (문자열을 배열로 변환하고, 빈 문자열 필터링)
stagedHashtags = baseData.tags ? baseData.tags.split(',').filter(t => t.trim() !== '') : [];
// 2. 임시 배열 기준으로 스테이징 UI 렌더링
renderStagedHashtags();
}
// ===================================
overlay.style.display = 'block';
popup.style.display = 'block';
}
@@ -461,7 +674,7 @@ function submitLoginForm() {
function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); }
function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } // 수정된 URL
function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); } // 수정된 URL
function gotoLogin() { document.location.replace(`${getMainPath()}/login.bs`); }
function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); }
function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); }
/**
@@ -543,4 +756,172 @@ function unformat(type, data, key) {
default:
return [odd.reverse().join(""), dividerStr, even.reverse().join("")].join("");
}
}
}
/**
* (신규 추가)
* 중복을 방지하며 baseData.tags (문자열)에 새 태그를 안전하게 추가합니다.
*/
function addTagToData(newTag) {
if (!newTag || newTag.trim() === '') return; // 빈 태그 방지
// 현재 태그 문자열을 배열로 변환 (태그가 없으면 빈 배열)
let tags = baseData.tags ? baseData.tags.split(',') : [];
// 새 태그가 이미 존재하는지 확인 (공백 제거 및 대소문자 무시)
const tagExists = tags.some(t => t.trim().toLowerCase() === newTag.trim().toLowerCase());
if (!tagExists) {
tags.push(newTag.trim()); // 새 태그 추가
baseData.tags = tags.join(','); // 다시 쉼표로 구분된 문자열로 저장
}
}
/**
* (신규 추가)
* 편집기 컨트롤 박스의 텍스트를 현재 baseData 기준으로 새로 고칩니다.
*/
function updateControlBoxDisplay() {
const categoryBox = document.querySelector('.controlbox-category');
const hashtagBox = document.querySelector('.controlbox-hashtag');
if (categoryBox) {
// [수정] 편집 모드도 읽기 모드와 동일한 HTML 구조(제목 + 래퍼)로 생성
const categoryContent = (baseData.category && baseData.category !== 'none')
? `<span class="tag-item">${baseData.category}</span>`
: '<i>카테고리 설정</i>';
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
}
if (hashtagBox) {
// [수정] 편집 모드도 읽기 모드와 동일한 HTML 구조(제목 + 래퍼)로 생성
let hashtagContent = '';
if (baseData.tags && baseData.tags.length > 0) {
hashtagContent = baseData.tags.split(',')
.map(t => `<span class="tag-item">#${t.trim()}</span>`)
.join(' '); // 각 태그를 span으로 감싸고 공백으로 연결
} else {
hashtagContent = '<i>해시태그 편집</i>';
}
hashtagBox.innerHTML = `<span class="tag-title">HASHTAGS</span><div class="tag-content-wrapper">${hashtagContent}</div>`;
}
}
/* === (신규 추가) POPUP STAGING 헬퍼 함수들 === */
/** 1. Staging Category 렌더링: 선택된 카테고리(임시 변수)를 팝업 UI에 표시 */
function renderStagedCategory() {
const area = document.getElementById('selected-category-area');
if (area) {
if (stagedCategory && stagedCategory !== 'none') {
// 선택된 아이템에 삭제(X) 버튼을 포함하여 렌더링
area.innerHTML = `<span class="tag-item">${stagedCategory}
<span class="remove-tag" onclick="removeStagedCategory()">X</span>
</span>`;
} else {
area.innerHTML = '<i>No category selected.</i>';
}
}
}
/** 2. Staging Hashtags 렌더링: 선택된 해시태그 목록(임시 배열)을 팝업 UI에 표시 */
function renderStagedHashtags() {
const area = document.getElementById('selected-hashtags-area');
if (area) {
area.innerHTML = ''; // 영역 초기화
if (stagedHashtags.length > 0) {
stagedHashtags.forEach((tag, index) => {
// 각 아이템에 삭제(X) 버튼과 올바른 index를 전달하는 onclick 이벤트 추가
area.innerHTML += `<span class="tag-item">#${tag}
<span class="remove-tag" onclick="removeStagedHashtag(${index})">X</span>
</span>`;
});
} else {
area.innerHTML = '<i>No tags selected.</i>';
}
}
}
/** 3. Staged Category 삭제: (X) 버튼 클릭 시 호출 */
function removeStagedCategory() {
stagedCategory = 'none';
renderStagedCategory(); // UI 새로고침
}
/** 4. Staged Hashtag 삭제: (X) 버튼 클릭 시 호출 */
function removeStagedHashtag(index) {
stagedHashtags.splice(index, 1); // 배열에서 해당 인덱스의 아이템 1개 제거
renderStagedHashtags(); // UI 새로고침
}
/** 5. Staged Hashtag 추가 (중복 방지 헬퍼): 임시 배열에 태그 추가 */
function addTagToStaged(newTag) {
if (!newTag || newTag.trim() === '') return false; // 빈 값 방지
const tagToAdd = newTag.trim().replace(/#/g, ''); // # 제거 및 공백 제거
// 임시 배열에 이미 존재하는지 확인 (대소문자 무시)
const tagExists = stagedHashtags.some(t => t.toLowerCase() === tagToAdd.toLowerCase());
if (!tagExists) {
stagedHashtags.push(tagToAdd); // 임시 배열에 추가
return true;
}
return false; // 중복이면 false 반환
}
function handleVote(buttonElement, voteType) {
// 1. 가장 가까운 .vote-controls 컨테이너를 찾음
const controls = buttonElement.closest('.vote-controls');
// 2. 컨테이너의 data-post-id 속성에서 postId를 가져옴
const postId = controls.dataset.postId; // (data-post-id="...") 값을 읽음
// 3. 모든 버튼 비활성화 (중복 클릭 방지)
controls.querySelectorAll('button').forEach(btn => btn.disabled = true);
// 4. 요청할 URL 생성
let url = `${getMainPath()}/blog/post/${postId}/${voteType === 'like' ? 'like' : 'unlike'}.bjx`;
// 5. CSRF 토큰 및 헤더 준비 (기본 헤더)
let headers = {
'Content-Type': 'application/json'
};
// [수정] CSRF 메타 태그가 존재하는지 (즉, 사용자가 로그인했는지) 확인
const csrfMeta = document.querySelector('meta[name="_csrf"]');
if (csrfMeta) {
const csrfToken = csrfMeta.getAttribute('content');
if (csrfToken) {
// 토큰이 존재할 경우에만 헤더에 추가
headers['X-CSRF-TOKEN'] = csrfToken;
}
}
// 6. Fetch API를 사용하여 POST 요청 전송
// (익명 사용자는 CSRF 헤더 없이 요청하고, 인증 사용자는 헤더와 함께 요청)
fetch(url, {
method: 'POST',
headers: headers
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// 7. 성공 시: UI 업데이트
const likeSpan = controls.querySelector('.like-count');
const unlikeSpan = controls.querySelector('.unlike-count');
if (likeSpan) likeSpan.innerText = data.voteCount;
if (unlikeSpan) unlikeSpan.innerText = data.unlikeCount;
})
.catch(error => {
// 8. 실패 시: 버튼 다시 활성화
console.error('Error handling vote:', error);
alert('투표 중 오류가 발생했습니다. 나중에 다시 시도해주세요.');
controls.querySelectorAll('button').forEach(btn => btn.disabled = false);
});
}
/* ============================================= */
@@ -6,18 +6,18 @@
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(true)});</script>
</th:block>
<body>
<th:block layout:fragment="content" id="content">
<head>
<th:block id="head">
<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>
<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-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block>
</head>
<section class="wrapper style2" >
<th:block sec:authorize="isAuthenticated()">
<div class="container" >
@@ -33,6 +33,14 @@
<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 class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
<div class="write_option btn-example controlbox-category" to="#popLayer1">
</div>
<div class="write_option btn-example controlbox-hashtag" to="#popLayer2" id="hashtag_field">
</div>
<div class="write_option btn-example controlbox-location" id="location_field"></div>
</div>
</div>
</th:block>
</section>
@@ -46,44 +54,52 @@
<div class="container">
<div id="editor"></div>
<div class="write_controllbox">
<div class="write_option btn-example controlbox-category" to="#popLayer1">
</div>
<div style="width: 15px"></div>
<div class="write_option btn-example controlbox-hashtag" to="#popLayer2" id="hashtag_field">
</div>
<div style="width: 15px"></div>
<div class="write_option" id="location_field"></div>
</div>
<button id="save" class="button fit" style="margin-top: 1em;" onclick="save()">저장하기</button>
</div>
</th:block>
</section>
</th:block>
<th:block layout:fragment="popup_layer">
<div id="popLayer1" class="pop_layer category-popup">
<div class="pop_container">
<div class="pop_conts">
<h2>Categories</h2>
<div class="tag-list-label">Selected:</div>
<div id="selected-category-area" class="staging-area">
</div>
<div class="tag-list-label">Pre-loaded Categories:</div>
<div id="category-list" class="tag-list"></div>
<input type="text" id="category-input" placeholder="Add a new category" class="tag-input">
<button id="add-category-btn" class="button">Add</button>
<div class="btn_r">
<a href="#" id="apply-category-btn" class="button" style="margin-right: 0.5em;">Apply</a>
<a href="#" class="btn_layerClose">Close</a>
</div>
</div>
</div>
</div>
<div id="popLayer2" class="pop_layer hashtag-popup">
<div class="pop_container">
<div class="pop_conts">
<h2>Hashtags</h2>
<div class="tag-list-label">Selected:</div>
<div id="selected-hashtags-area" class="staging-area tag-list">
</div>
<div class="tag-list-label">Suggested Tags:</div>
<div id="hashtag-list" class="tag-list"></div>
<input type="text" id="hashtag-input" placeholder="Add a new hashtag" class="tag-input">
<button id="add-hashtag-btn" class="button">Add</button>
<div class="btn_r">
<a href="#" id="apply-hashtag-btn" class="button" style="margin-right: 0.5em;">Apply</a>
<a href="#" class="btn_layerClose">Close</a>
</div>
</div>
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head">
</th:block>
<th:block layout:fragment="content" id="content">
<div id="main_layer">
<th:block sec:authorize="isAnonymous()">
<h1>권한이 없는 뎁쇼?!</h1>
</th:block>
<th:block sec:authorize="isAuthenticated()">
<div class="post_layer" id="posts" th:each="posts, postsStat : ${chunkedPosts}">
<div class="post_layer" th:class="${#strings.append(rowKey, postsStat.index)}" th:each="post, postStat : ${posts}">
<div class="post_item"
th:if="${post.id != null and post.id != ''}"
onclick="goToEditor(this)"
th:attr="data-post-id=${post.id}">
<span id="postTitle" class="post_attr" th:text="${post.title}"></span>
<div id="content" class="post_attr content" th:attr="data=${post.content}"></div>
<span id="writeDate" class="post_attr" th:text="${#dates.format(post.writeTime, 'yyyy.MM.dd HH:mm:ss')}"></span>
<span id="postId" class="post_attr" th:text="${post.id}"></span>
</div>
</div>
</div>
</th:block>
</div>
</th:block>
</html>
@@ -9,22 +9,29 @@
<th:block layout:fragment="head">
</th:block>
</head>
<body>
<th:block layout:fragment="content" id="content">
<section class="wrapper style1">
<div class="container">
<div class="row justify-content-center">
<div class="col-8 col-12-narrower">
<div id="content">
<div id="content_inner">
<article>
<section th:each="post : ${Posts}">
<section th:each="post : ${postsPage.content}">
<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)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
</a>
<div class="inner">
<h3 th:text="${post.title != null and not #strings.isEmpty(post.title)} ? ${post.title} : 'untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')} + ']'"></h3>
<h3 style="display: flex; justify-content: space-between; align-items: center;">
<span th:text="${post.title != null and not #strings.isEmpty(post.title)} ? ${post.title} : 'untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')} + ']'"></span>
<span style="font-size: 0.75em; color: #888; font-weight: normal; white-space: nowrap; margin-left: 1em;">
(읽음: <span th:text="${post.readCount}">0</span>)
</span>
</h3>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p>
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
</div>
<footer sec:authorize="isAuthenticated()" style="text-align: right; margin-top: 1em;">
@@ -33,11 +40,34 @@
</div>
</section>
</article>
</div>
</div>
<nav aria-label="Page navigation" th:if="${postsPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em; font-size: 0.9em;">
<ul class="pagination" style="display: inline-block; padding-left: 0; list-style: none; border-radius: 5px; border: 1px solid #e0e0e0; overflow: hidden;">
<li th:styleappend="${postsPage.isFirst()} ? 'opacity: 0.5; pointer-events: none;' : ''" style="display: inline; float: left;">
<a th:href="${postsPage.isFirst()} ? '#' : @{/blog/posts(page=${postsPage.number - 1})}"
class="button alt small" style="border-radius:0; margin:0; border-right: 1px solid #e0e0e0;">
&laquo; Prev
</a>
</li>
<li th:each="pageNum : ${#numbers.sequence(0, postsPage.totalPages - 1)}"
style="display: inline; float: left; border-right: 1px solid #e0e0e0;">
<a th:href="@{/blog/posts(page=${pageNum})}"
th:text="${pageNum + 1}"
th:class="${pageNum == postsPage.number} ? 'button small' : 'button alt small'"
style="border-radius:0; margin:0;">
</a>
</li>
<li th:styleappend="${postsPage.isLast()} ? 'opacity: 0.5; pointer-events: none;'" style="display: inline; float: left;">
<a th:href="${postsPage.isLast()} ? '#' : @{/blog/posts(page=${postsPage.number + 1})}"
class="button alt small" style="border-radius:0; margin:0;">
Next &raquo;
</a>
</li>
</ul>
</nav>
</div> </div>
</div>
</div>
</section>
</th:block>
</body>
</html>
@@ -1,53 +1,81 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"
>
<head>
<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)});</script>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
</th:block>
</head>
<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)});</script>
</th:block>
<body>
<th:block layout:fragment="content" id="content">
<section class="wrapper style2">
<div class="container" sec:authorize="isAuthenticated()" onclick="loadEditor()" style="cursor: pointer;" title="클릭하여 수정하기">
<header class="major">
<h2 id="title_layer" th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</h2>
<p 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>
<h2 id="title_layer">
<span th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</span>
<span style="font-size: 0.8em; color: #888; font-weight: normal; margin-left: 0.5em;">
(읽음: <span th:text="${srcPost.readCount}">0</span>)
</span>
</h2>
<p>
<span th:if="${srcPost.writer != null}" th:text="${'by ' + srcPost.writer + ' | '}" style="font-weight: 600;"></span>
<span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')}"></span>
</p>
</header>
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
<div class="write_option controlbox-category">
</div>
<div class="write_option controlbox-hashtag" id="hashtag_field">
</div>
<div class="write_option controlbox-location" id="location_field">
</div>
</div>
</div>
<div class="container open-login-popup" sec:authorize="isAnonymous()" to="#loginPopup" style="cursor: pointer;">
<header class="major">
<h2 id="title_layer" th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</h2>
<p 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>
<h2 id="title_layer_anon">
<span th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</span>
<span style="font-size: 0.8em; color: #888; font-weight: normal; margin-left: 0.5em;">
(읽음: <span th:text="${srcPost.readCount}">0</span>)
</span>
</h2>
<p>
<span th:if="${srcPost.writer != null}" th:text="${'by ' + srcPost.writer + ' | '}" style="font-weight: 600;"></span>
<span th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')}"></span>
</p>
</header>
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
<div class="write_option controlbox-category">
</div>
<div class="write_option controlbox-hashtag" id="hashtag_field_anon">
</div>
<div class="write_option controlbox-location" id="location_field_anon">
</div>
</div>
</div>
</section>
<section class="wrapper style1">
<div class="container">
<div id="content">
<div id="content_inner">
<article>
<div id="editor"></div>
<div class="write_controllbox">
<div class="write_option controlbox-category">
</div>
<div style="width: 15px"></div>
<div class="write_option controlbox-hashtag" id="hashtag_field">
</div>
<div style="width: 15px"></div>
<div class="write_option" id="location_field">
</div>
<div class="vote-controls" style="margin-top: 2em; text-align: center; border-top: 1px solid #e0e0e0; padding-top: 2em;" th:data-post-id="${srcPost.id}">
<button class="button" onclick="handleVote(this, 'like')">
👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>)
</button>
<button class="button" onclick="handleVote(this, 'unlike')" style="margin-left: 1em;">
👎 Unlike (<span class="unlike-count" th:text="${srcPost.unlikeCount}">0</span>)
</button>
</div>
<h3 id="write" th:text="${srcPost.firstAddress}"></h3>
@@ -67,5 +95,4 @@
</div>
</section>
</th:block>
</body>
</html>
@@ -10,17 +10,57 @@
<th:block layout:fragment="content" id="content">
<div id="main_layer">
<div class="layer">
<th:block id="where" th:each="location : ${locations}">
<div class="where_item" style="font-family: monospace; white-space: nowrap;">
<span th:text="${location.timeString}"></span> |
<span th:text="${location.mAddressLines}"></span> |
<span th:text="${location.bettween}"></span> |
<span th:text="${#numbers.formatDecimal(location.mLatitude, 1, 3)}"></span> |
<span th:text="${#numbers.formatDecimal(location.mLongitude, 1, 3)}"></span> |
<span th:text="${location.mCountryName}"></span>
<th:block th:each="location : ${locationPage.content}">
<div class="location-item"> <div class="location-header">
<span class="location-time" th:text="${location.displayTime}"></span>
<span class="location-between" th:text="${location.bettween}"></span>
</div>
<div class="location-body">
<p class="location-address" th:text="${location.mAddressLines}"></p>
<p class="location-coords">
<span>위도: <b th:text="${#numbers.formatDecimal(location.mLatitude, 1, 3)}"></b></span>
<span>경도: <b th:text="${#numbers.formatDecimal(location.mLongitude, 1, 3)}"></b></span>
<span class="location-country" th:text="${'(' + location.mCountryName + ')'}"></span>
</p>
</div>
</div>
</th:block>
</div>
<div class="pagination">
<th:block th:with="currentPage=${locationPage.number},
totalPages=${locationPage.totalPages}">
<a class="nav-link" th:if="${currentPage > 0}" th:href="@{/bums/where.bs(page=0)}">&lt;&lt; 처음</a>
<span class="nav-link disabled" th:unless="${currentPage > 0}">&lt;&lt; 처음</span>
<a class="nav-link" th:if="${locationPage.hasPrevious()}" th:href="@{/bums/where.bs(page=${currentPage - 1})}">&lt; 이전</a>
<span class="nav-link disabled" th:unless="${locationPage.hasPrevious()}">&lt; 이전</span>
<th:block th:with="startPage=${T(java.lang.Math).max(0, currentPage - 3)},
endPage=${T(java.lang.Math).min(totalPages - 1, currentPage + 3)}">
<th:block th:each="pageNum : ${#numbers.sequence(startPage, endPage)}">
<a class="page-num"
th:if="${pageNum != currentPage}"
th:href="@{/bums/where.bs(page=${pageNum})}"
th:text="${pageNum + 1}">
</a>
<span class="page-num current-page"
th:if="${pageNum == currentPage}"
th:text="${pageNum + 1}">
</span>
</th:block>
</th:block>
<a class="nav-link" th:if="${locationPage.hasNext()}" th:href="@{/bums/where.bs(page=${currentPage + 1})}">다음 &gt;</a>
<span class="nav-link disabled" th:unless="${locationPage.hasNext()}">다음 &gt;</span>
<a class="nav-link" th:if="${currentPage < totalPages - 1}" th:href="@{/bums/where.bs(page=${totalPages - 1})}">&gt;&gt;</a>
<span class="nav-link disabled" th:unless="${currentPage < totalPages - 1}">&gt;&gt;</span>
</th:block>
</div>
</div>
</th:block>
</html>
@@ -17,16 +17,45 @@
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/>
<script th:inline="javascript">
/*
* [수정됨] 이 객체는 Post.kt 모델의 모든 필드를 포함해야 합니다.
* 여기서 누락된 필드는 편집 후 저장 시 서버에서 null 또는 0으로 초기화됩니다.
*/
var serverData = {
id: [[${srcPost != null and srcPost.id != null} ? ${srcPost.id} : 0]],
originId: [[${srcPost != null and srcPost.originId != null} ? ${srcPost.originId} : 0]],
title: /*[[${srcPost != null and srcPost.title != null} ? ${srcPost.title} : '']]*/,
content: /*[[${srcPost != null and srcPost.content != null} ? ${srcPost.content} : '']]*/,
firstPostLat: [[${srcPost != null and srcPost.firstPostLat != null} ? ${srcPost.firstPostLat} : 0]],
firstPostLon: [[${srcPost != null and srcPost.firstPostLon != null} ? ${srcPost.firstPostLon} : 0]],
writeTime: [[${srcPost != null and srcPost.writeTime != null} ? ${srcPost.writeTime} : 0]],
enc: /*[[${enc != null} ? ${enc} : '']]*/,
keyword: /*[[${keyword != null} ? ${keyword} : '']]*/
// --- Key IDs ---
id: [[${srcPost?.id}]], // (Thymeleaf 3.x의 안전 탐색 연산자 '?' 사용)
originId: [[${srcPost?.originId}]],
// --- Core Content (컨트롤러에서 이미 디코딩됨) ---
title: /*[[${srcPost?.title ?: ''}]]*/,
content: /*[[${srcPost?.content ?: ''}]]*/,
// === (수정) 치명적으로 누락되었던 필드들 ===
category: /*[[${srcPost?.category ?: 'none'}]]*/,
tags: /*[[${srcPost?.tags ?: ''}]]*/,
// --- Timestamps (데이터 보존을 위해 필수) ---
writeTime: [[${srcPost?.writeTime ?: 0}]],
modifyTime: [[${srcPost?.modifyTime ?: 0}]],
// --- Location Data (데이터 보존을 위해 필수) ---
firstPostLat: [[${srcPost?.firstPostLat ?: 0.0}]],
firstPostLon: [[${srcPost?.firstPostLon ?: 0.0}]],
firstAddress: /*[[${srcPost?.firstAddress ?: ''}]]*/,
modifyLat: [[${srcPost?.modifyLat ?: 0.0}]],
modifyLon: [[${srcPost?.modifyLon ?: 0.0}]],
modifyAddress: /*[[${srcPost?.modifyAddress ?: ''}]]*/,
// --- Metadata (데이터 보존을 위해 필수) ---
writer: /*[[${srcPost?.writer ?: ''}]]*/,
posting: [[${srcPost?.posting ?: false}]],
readCount: [[${srcPost?.readCount ?: 0}]],
voteCount: [[${srcPost?.voteCount ?: 0}]],
unlikeCount: [[${srcPost?.unlikeCount ?: 0}]],
// --- Page-specific (모델 데이터 아님) ---
enc: /*[[${enc ?: ''}]]*/,
keyword: /*[[${keyword ?: ''}]]*/
};
</script>
</th:block>