This commit is contained in:
lunaticbum 2025-10-17 18:14:12 +09:00
parent 00bba0bc39
commit 5ac3b05660
11 changed files with 172 additions and 49 deletions

View File

@ -171,50 +171,43 @@ class BlogController(
* [수정됨] 이미지 URL을 새로운 API 경로인 /api/images/ 생성합니다.
*/
private fun processPostForView(post: Post): Post {
// 1. URL 디코딩: 인코딩되어 저장된 모든 텍스트 필드를 디코딩합니다.
// ?.let {} 구문을 사용하여 null-safe하게 처리합니다.
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
post.content = post.content?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
post.tags = post.tags?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
post.category = post.category?.let { URLDecoder.decode(it, "UTF-8") } ?: "none"
post.firstAddress = post.firstAddress?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
post.modifyAddress = post.modifyAddress?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
// [수정] 모든 URLDecoder 호출을 제거합니다. 데이터는 이미 순수 텍스트입니다.
post.title = post.title ?: ""
post.content = post.content ?: ""
post.tags = post.tags ?: ""
post.category = if (post.category.isNullOrBlank()) "none" else post.category
post.firstAddress = post.firstAddress ?: ""
post.modifyAddress = post.modifyAddress ?: ""
// 2. 기본값 설정: 제목이 비어있을 경우, 작성 시간을 기반으로 기본 제목을 생성합니다.
if (post.title.isNullOrBlank()) {
// 제목이 비어있을 경우 기본값 설정
if (post.title!!.isBlank()) {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
}
var firstImgSrc: String? = null
val defaultThumb = "/images/pic01.jpg" // 기본 썸네IL 경로를 상수로 정의
val defaultThumb = "/images/pic01.jpg"
try {
// 3. 콘텐츠 처리 (Delta or HTML)
// Delta(JSON) 형식인지 먼저 시도
// Delta 형식인지 먼저 시도
JsonParser.parseString(post.content)
val (text, firstImg) = extractFromDelta(post.content!!)
post.html = text // Jsoup.parse() 대신 Delta에서 추출한 순수 텍스트를 사용
post.html = text
firstImgSrc = firstImg
} catch (e: Exception) {
// JSON 파싱 실패 시 일반 HTML로 간주
val doc = Jsoup.parse(post.content)
post.html = doc.text() // HTML 태그를 제외한 순수 텍스트만 요약으로 저장
post.html = doc.text()
firstImgSrc = doc.select("img").first()?.attr("src")
}
// 4. 이미지 및 썸네일 경로 설정 (로직 중앙화)
if (!firstImgSrc.isNullOrBlank()) {
val filename = firstImgSrc.substringAfterLast("/")
// 원본 이미지 URL 경로 설정
post.image = "/api/images/$filename"
// 썸네일 생성 및 경로 설정
generateThumbnail(filename, 200) // 너비 200px 썸네일 생성
generateThumbnail(filename, 200)
val thumbFilename = filename.substringBeforeLast(".") + "_thumbnail." + filename.substringAfterLast(".")
post.thumb = "/api/images/$thumbFilename?type=thumbnail"
} else {
// 게시물에 이미지가 없는 경우, 기본 썸네일을 지정합니다.
post.image = null
post.thumb = defaultThumb
}
@ -580,13 +573,22 @@ class BlogController(
logService.log("User ${userDetails.username} not authorized to edit post $postId")
return ResultMV("redirect:/blog/posts")
}
val decodedContent = URLDecoder.decode(rawPost.content, "UTF-8")
logService.log("$postId ${decodedContent}")
if (decodedContent.contains("/blog/post/images/")) {
rawPost.content = decodedContent.replace("/blog/post/images/", "/api/images/")
// content가 변경되었으므로 다시 인코딩해서 저장
rawPost.content = URLEncoder.encode(rawPost.content, "UTF-8")
// ======================= ▼▼▼ 수정된 로직 시작 ▼▼▼ =======================
var processedContent: String
try {
// 1. URL 디코딩을 시도합니다.
processedContent = URLDecoder.decode(rawPost.content, "UTF-8")
} catch (e: Exception) {
// 2. 디코딩에 실패하면 (예: 이미 디코딩된 상태이거나 인코딩되지 않은 데이터), 원본 내용을 그대로 사용합니다.
processedContent = rawPost.content ?: ""
logService.log("URL decoding failed for post $postId, using raw content. Error: ${e.message}")
}
// 3. Post 객체의 content 필드를 안전하게 처리된 내용으로 업데이트하여 뷰에 전달합니다.
// (이후 processPostForView에서 추가 처리됩니다.)
rawPost.content = processedContent
// ======================= ▲▲▲ 수정된 로직 종료 ▲▲▲ =======================
// ====================================================================

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -4182,3 +4182,32 @@ a.btn_layerClose:hover {
.image-preview-item.is-hidden .toggle-visibility-btn {
background: #28a745; /* 숨겨진 아이템을 다시 보이게 하는 버튼은 초록색으로 표시 */
}
.ql-toolbar.ql-snow.sticky {
position: fixed; /* 화면을 기준으로 위치 고정 */
top: 44px; /* 화면 상단에서 44px 떨어진 곳에 위치 (모바일 상단바 높이 고려) */
left: 0;
right: 0;
width: 100%; /* 화면 너비 전체를 차지 */
z-index: 999; /* 다른 요소들보다 위에 표시되도록 z-index 설정 */
background: #f7f7f7; /* 배경색을 지정해야 아래 내용이 비치지 않음 */
box-shadow: 0 2px 5px rgba(0,0,0,0.1); /* 떠 있는 효과를 위한 그림자 */
/* [중요] 툴바가 화면 너비를 꽉 채우므로, container 너비에 맞게 내부 컨텐츠를 제한합니다. */
/* 기존 .container 스타일과 유사하게 설정 */
padding-left: 15px;
padding-right: 15px;
box-sizing: border-box; /* 패딩이 너비에 포함되도록 설정 */
}
/* 2. 툴바가 고정되었을 때, 에디터 본문이 툴바 뒤로 숨지 않도록 상단에 여백(padding)을 추가 */
.ql-container.ql-snow.has-sticky-toolbar {
padding-top: 60px; /* 툴바 높이(약 42px) + 약간의 여유 공간 */
}
/* 모바일 화면에서는 상단바(titleBar)가 없으므로 top: 0 으로 조정 */
@media screen and (min-width: 841px) {
.ql-toolbar.ql-snow.sticky {
top: 0;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -243,6 +243,7 @@ window.addEventListener('DOMContentLoaded', () => {
/* --- Quill 에디터 초기화 관련 함수들 --- */
/* ============================================= */
/**
* [핵심] Quill 에디터를 초기화하는 메인 함수입니다.
* ( 함수는 바닐라 JS와 Quill API로만 작성되어 수정이 필요 없습니다.)
@ -256,8 +257,8 @@ function initEditor(useEditor = false) {
// serverData (includes.html에 정의됨)에서 baseData (JS 내부 변수)로 모든 값을 복사합니다.
if (typeof serverData !== 'undefined') {
baseData.id = serverData.id;
baseData.title = decodeURIComponent(serverData.title || '');
baseData.content = decodeURIComponent(serverData.content || '');
baseData.title = serverData.title || '';
baseData.content = serverData.content || '';
baseData.category = serverData.category;
baseData.tags = serverData.tags;
baseData.firstPostLat = serverData.firstPostLat;
@ -293,15 +294,46 @@ function initEditor(useEditor = false) {
}
try {
const ImageBlot = Quill.import('formats/image');
class StyledImage extends ImageBlot {
static formats(domNode) {
// 기존 속성(alt, height, width) 외에 style 속성을 추가로 허용합니다.
const formats = super.formats(domNode);
if (domNode.hasAttribute('style')) {
formats.style = domNode.getAttribute('style');
}
return formats;
}
format(name, value) {
if (name === 'style') {
if (value) {
this.domNode.setAttribute(name, value);
} else {
this.domNode.removeAttribute(name);
}
} else {
super.format(name, value);
}
}
}
// Quill 에디터 옵션 및 모듈 설정 (편집/읽기 모드 전환)
var Font = Quill.import('formats/font');
Font.whitelist = ['sans-serif', 'serif', 'monospace', 'arial', 'georgia', 'comic-sans-ms', 'courier-new', 'roboto', 'playfair-display'];
Font.whitelist = ['monospace','sans-serif', 'serif', 'arial', 'georgia', 'comic-sans-ms', 'courier-new', 'roboto', 'playfair-display'];
Quill.register(Font, true);
Quill.register({ 'modules/table-better': QuillTableBetter }, true);
// Quill.register({'modules/imageResize': ImageResize}, true);
Quill.register('modules/imageResize', QuillResizeModule);
Quill.register(StyledImage, true);
const quillOptions = {
theme: 'snow',
modules: useEditor ? {
imageResize: {
displaySize: true
},
toolbar: {
container: [
[{ font: Font.whitelist }], [{ 'size': ['small', false, 'large', 'huge'] }],
@ -316,7 +348,9 @@ function initEditor(useEditor = false) {
},
'table-better': { language: 'en_US', toolbarTable: true },
keyboard: { bindings: QuillTableBetter.keyboardBindings }
} : {
} : {imageResize: {
displaySize: true
},
toolbar: false // 읽기 모드(useEditor: false)일 경우 툴바 숨김
},
readOnly: !useEditor // 읽기 전용 모드 설정
@ -324,10 +358,48 @@ function initEditor(useEditor = false) {
quill = new Quill(editorContainer, quillOptions); // Quill 인스턴스 생성
if (useEditor) { // 편집 모드일 때만 스티키 로직을 활성화합니다.
const toolbar = document.querySelector('.ql-toolbar');
const editorTop = editorContainer.offsetTop;
window.addEventListener('scroll', () => {
if (window.scrollY > editorTop) {
toolbar.classList.add('sticky');
editorContainer.classList.add('has-sticky-toolbar');
} else {
toolbar.classList.remove('sticky');
editorContainer.classList.remove('has-sticky-toolbar');
}
});
}
quill.root.addEventListener('paste', (event) => {
// 1. 클립보드에서 텍스트 데이터 가져오기
let pasteText = (event.clipboardData || window.clipboardData).getData('text');
// 간단하게 마크다운인지 확인 (예: #, *, -, > 등의 문자로 시작하는지)
// 좀 더 정교한 확인 로직을 추가할 수 있습니다.
const isMarkdown = /^(#|\*|-|>|`)/.test(pasteText.trim());
if (isMarkdown) {
// 2. 기본 붙여넣기 동작을 막습니다.
event.preventDefault();
event.stopPropagation();
// 3. 마크다운을 HTML로 변환합니다.
const html = marked.parse(pasteText, { gfm: true , breaks: true});
// 4. 현재 커서 위치에 변환된 HTML을 삽입합니다.
const range = quill.getSelection(true);
quill.clipboard.dangerouslyPasteHTML(range.index, html);
}
// 마크다운이 아니면 Quill의 기본 붙여넣기 로직이 실행됩니다.
}, true);
if (baseData.content) {
loadContent(baseData.content); // DB에서 불러온 콘텐츠를 에디터에 로드
}
if (useEditor) {
quill.format('font', Font.whitelist[0], 'silent');
}
// 읽기 모드/편집 모드에 따라 CSS 클래스 및 제목 필드 처리
if (!useEditor) {
editorContainer.classList.add('readonly-mode');

File diff suppressed because one or more lines are too long

View File

@ -128,11 +128,18 @@
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<!-- <script src="https://unpkg.com/quill-image-resize@3.0.9/image-resize.min.js" defer></script>-->
<!-- <script th:src="@{/js/image-resize.min.js}"></script>-->
<script src="https://cdn.jsdelivr.net/gh/scrapooo/quill-resize-module@1.0.2/dist/quill-resize-module.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@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>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js" defer></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/quill-image-resize-module@3.0.0/image-resize.min.js"></script>-->
<script defer>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
<!-- <script defer>initEditor(true);</script>-->
</th:block>
</body>
</html>

View File

@ -42,7 +42,9 @@
<section th:each="post , iterStat : ${Posts}">
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
<span class="image left">
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}"
alt="Post Thumbnail"
th:onerror="|this.onerror=null; this.src='@{/images/bum.png}';|" />
</span>
<div class="inner">

View File

@ -11,7 +11,7 @@
<section class="wrapper style1">
<div class="container">
<div id="license-content-container">
<p>#lun ##Dependency License Report <em>2025-09-24 15:26:44 KST</em></p>
<p>#lun ##Dependency License Report <em>2025-10-10 15:13:12 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.26.1</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.27.0</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.10.1</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.11.0</code></p>
<blockquote>
<ul>
<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 Project URL</strong>: <a href="https://github.com/google/gson">https://github.com/google/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>
@ -1695,10 +1695,11 @@
<p><strong>219</strong> <strong>Group:</strong> <code>com.fasterxml.jackson</code> <strong>Name:</strong> <code>jackson-bom</code> <strong>Version:</strong> <code>2.17.2</code></p>
<p><strong>220</strong> <strong>Group:</strong> <code>com.squareup.okio</code> <strong>Name:</strong> <code>okio</code> <strong>Version:</strong> <code>3.6.0</code></p>
<p><strong>221</strong> <strong>Group:</strong> <code>org.apache.groovy</code> <strong>Name:</strong> <code>groovy-bom</code> <strong>Version:</strong> <code>4.0.23</code></p>
<p><strong>222</strong> <strong>Group:</strong> <code>org.jetbrains.kotlin</code> <strong>Name:</strong> <code>kotlin-stdlib-common</code> <strong>Version:</strong> <code>1.9.25</code></p>
<p><strong>223</strong> <strong>Group:</strong> <code>org.jetbrains.kotlinx</code> <strong>Name:</strong> <code>kotlinx-coroutines-bom</code> <strong>Version:</strong> <code>1.8.1</code></p>
<p><strong>224</strong> <strong>Group:</strong> <code>org.jetbrains.kotlinx</code> <strong>Name:</strong> <code>kotlinx-coroutines-core</code> <strong>Version:</strong> <code>1.8.1</code></p>
<p><strong>225</strong> <strong>Group:</strong> <code>org.springframework.ai</code> <strong>Name:</strong> <code>spring-ai-bom</code> <strong>Version:</strong> <code>1.0.0-M6</code></p>
<p><strong>222</strong> <strong>Group:</strong> <code>org.jetbrains.kotlin</code> <strong>Name:</strong> <code>kotlin-bom</code> <strong>Version:</strong> <code>1.9.25</code></p>
<p><strong>223</strong> <strong>Group:</strong> <code>org.jetbrains.kotlin</code> <strong>Name:</strong> <code>kotlin-stdlib-common</code> <strong>Version:</strong> <code>1.9.25</code></p>
<p><strong>224</strong> <strong>Group:</strong> <code>org.jetbrains.kotlinx</code> <strong>Name:</strong> <code>kotlinx-coroutines-bom</code> <strong>Version:</strong> <code>1.8.1</code></p>
<p><strong>225</strong> <strong>Group:</strong> <code>org.jetbrains.kotlinx</code> <strong>Name:</strong> <code>kotlinx-coroutines-core</code> <strong>Version:</strong> <code>1.8.1</code></p>
<p><strong>226</strong> <strong>Group:</strong> <code>org.springframework.ai</code> <strong>Name:</strong> <code>spring-ai-bom</code> <strong>Version:</strong> <code>1.0.0-M6</code></p>
</div>
</div>
</section>

View File

@ -24,7 +24,9 @@
<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" />
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}"
alt="Post Thumbnail"
th:onerror="|this.onerror=null; this.src='@{/images/bum.png}';|" />
</a>
<div class="inner">
<h3 style="display: flex; justify-content: space-between; align-items: center;">

View File

@ -101,11 +101,18 @@
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js" defer></script>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<!-- <script src="https://unpkg.com/quill-image-resize@3.0.9/image-resize.min.js" defer></script>-->
<!-- <script th:src="@{/js/image-resize.min.js}"></script>-->
<script src="https://cdn.jsdelivr.net/gh/scrapooo/quill-resize-module@1.0.2/dist/quill-resize-module.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@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() {
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js" defer></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/quill-image-resize-module@3.0.0/image-resize.min.js"></script>-->
<!-- <script defer>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>-->
<script defer>document.addEventListener('DOMContentLoaded', function() {
initEditor(false)
fetchComments(serverData.id);
});</script>