This commit is contained in:
lunaticbum 2025-12-26 18:00:05 +09:00
parent e8355b3048
commit 7397d403d4
7 changed files with 218 additions and 42 deletions

View File

@ -127,13 +127,18 @@ class PostViewController(
@GetMapping("/api/feed")
@ResponseBody
suspend fun getFeedMore(@RequestParam cursor: Long): FeedResponse {
return feedService.getGlobalFeed(cursor, 10).awaitSingle()
suspend fun getFeedMore(
@RequestParam cursor: Long,
@RequestParam(required = false) q: String? // 더보기 할 때도 검색어 유지 필요
): FeedResponse {
return feedService.getGlobalFeed(cursor, 10, q).awaitSingle()
}
// --- View Endpoints ---
@GetMapping("/", "/home.bs")
suspend fun home(request: jakarta.servlet.http.HttpServletRequest): ResultMV {
suspend fun home(
@RequestParam(required = false) q: String?,
request: jakarta.servlet.http.HttpServletRequest): ResultMV {
visitorLogService.recordVisit(request).subscribe()
val vm = ResultMV("content/home")
val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner"
@ -156,12 +161,14 @@ class PostViewController(
vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8")
vm.modelMap["gibberishId"] = randomGibberish.id
}
val feedData = feedService.getGlobalFeed(null, 10).awaitSingle()
val feedData = feedService.getGlobalFeed(null, 10, q).awaitSingle()
vm.modelMap["feedItems"] = feedData.items
vm.modelMap["nextCursor"] = feedData.nextCursor // HTML에 hidden으로 숨겨둠
val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
vm.modelMap["Posts"] = postsList.map { processPostForView(it) }
vm.modelMap["searchQuery"] = q // HTML에 hidden으로 숨겨둠
// val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
// vm.modelMap["Posts"] = postsList.map { processPostForView(it) }
vm.modelMap["path"] = "/blog/viewer/"
} catch (ex: Exception) {

View File

@ -4,6 +4,7 @@ import kr.lunaticbum.back.lun.model.AggregationCount
import kr.lunaticbum.back.lun.model.Post
import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
@ -11,6 +12,17 @@ import reactor.core.publisher.Mono
@Repository
interface PostRepository : ReactiveMongoRepository<Post, String> {
@Query("{ " +
" '\$and': [ " +
" { 'posting': true, 'isBlocked': false, 'modifyTime': { '\$lt': ?1 } }, " + // 기본 필터 (공개여부, 커서)
" { '\$or': [ " +
" { 'title': { '\$regex': ?0, '\$options': 'i' } }, " +
" { 'content': { '\$regex': ?0, '\$options': 'i' } }, " +
" { 'tags': { '\$regex': ?0, '\$options': 'i' } } " +
" ] } " +
" ] " +
"}")
fun searchPosts(keyword: String, maxTime: Long, pageable: Pageable): Flux<Post>
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",

View File

@ -3,6 +3,7 @@ package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.WebBookmark
import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.repository.Aggregation
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
@ -11,7 +12,19 @@ import reactor.core.publisher.Mono
@Repository
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
// [검색용] 키워드가 제목, 코멘트, 태그, 설명 중 포함 + 공개된 북마크 + 커서 적용
@Query("{ " +
" '\$and': [ " +
" { 'visibility': { '\$in': ?0 }, 'savedAt': { '\$lt': ?2 } }, " +
" { '\$or': [ " +
" { 'title': { '\$regex': ?1, '\$options': 'i' } }, " +
" { 'userComment': { '\$regex': ?1, '\$options': 'i' } }, " +
" { 'tags': { '\$regex': ?1, '\$options': 'i' } }, " +
" { 'description': { '\$regex': ?1, '\$options': 'i' } } " +
" ] } " +
" ] " +
"}")
fun searchBookmarks(visibilities: List<String>, keyword: String, maxTime: Long, pageable: Pageable): Flux<WebBookmark>
// WebBookmarkRepository 인터페이스 내부에 추가
// savedAt이 특정 시간(?1)보다 작은 것들 중 최신순 조회
fun findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(

View File

@ -18,39 +18,41 @@ class FeedService(
private val postRepository: PostRepository,
private val bookmarkRepository: WebBookmarkRepository
) {
/**
* @param cursorTime 클라이언트가 가지고 있는 마지막 글의 시간 ( 요청시엔 현재시간 or 아주 )
* @param size 번에 불러올 개수 (: 10)
*/
fun getGlobalFeed(cursorTime: Long?, size: Int): Mono<FeedResponse> {
// 커서가 없으면 현재 시간으로 설정 (첫 로딩)
val lastTime = cursorTime ?: System.currentTimeMillis()
// 각 저장소에서 'size' 만큼만 가져옴 (부하 최소화)
// 기존 메서드 시그니처 변경: keyword: String? 추가
fun getGlobalFeed(cursorTime: Long?, size: Int, keyword: String? = null): Mono<FeedResponse> {
val lastTime = cursorTime ?: System.currentTimeMillis()
val pageable = PageRequest.of(0, size)
// 1. Post 조회 (lastTime 이전 글)
val postsFlux = postRepository.findFeedPostsBefore(lastTime, pageable)
.map { post ->
val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST
val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거
// 1. Post 조회 (검색어가 있으면 searchPosts, 없으면 findFeedPostsBefore)
val postsFlux = if (!keyword.isNullOrBlank()) {
postRepository.searchPosts(keyword, lastTime, pageable)
} else {
postRepository.findFeedPostsBefore(lastTime, pageable)
}.map { post ->
val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST
val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거
FeedItemDto(
id = post.id,
type = type,
title = post.title,
content = if (type == ContentType.GIBBERISH) post.content else rawContent,
thumbnail = post.thumb,
createdAt = post.modifyTime,
writer = post.writer,
url = "/blog/viewer/${post.id}"
)
}
FeedItemDto(
id = post.id,
type = type,
title = post.title,
content = if (type == ContentType.GIBBERISH) post.content else rawContent,
thumbnail = post.thumb,
createdAt = post.modifyTime,
writer = post.writer,
url = "/blog/viewer/${post.id}"
)
}
// 2. Bookmark 조회 (lastTime 이전 글)
val bookmarksFlux = bookmarkRepository.findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
listOf("PUBLIC"), lastTime, pageable
).map { bookmark ->
// 2. Bookmark 조회
val bookmarksFlux = if (!keyword.isNullOrBlank()) {
bookmarkRepository.searchBookmarks(listOf("PUBLIC"), keyword, lastTime, pageable)
} else {
bookmarkRepository.findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
listOf("PUBLIC"), lastTime, pageable
)
}.map { bookmark ->
FeedItemDto(
id = bookmark.id,
type = ContentType.BOOKMARK,
@ -63,15 +65,71 @@ class FeedService(
)
}
// 3. 병합 후 다시 정렬하고 size 만큼 자르기
// 3. 병합 및 정렬 (기존 동일)
return Flux.merge(postsFlux, bookmarksFlux)
.sort(Comparator.comparing(FeedItemDto::createdAt).reversed()) // 최신순 정렬
.take(size.toLong()) // 전체 중 상위 size 개만 선택
.sort(Comparator.comparing(FeedItemDto::createdAt).reversed())
.take(size.toLong())
.collectList()
.map { items ->
// 마지막 아이템의 시간을 다음 커서로 설정
val nextCursor = if (items.isNotEmpty()) items.last().createdAt else null
FeedResponse(items, nextCursor)
}
}
/**
* @param cursorTime 클라이언트가 가지고 있는 마지막 글의 시간 ( 요청시엔 현재시간 or 아주 )
* @param size 번에 불러올 개수 (: 10)
*/
// fun getGlobalFeed(cursorTime: Long?, size: Int): Mono<FeedResponse> {
// // 커서가 없으면 현재 시간으로 설정 (첫 로딩)
// val lastTime = cursorTime ?: System.currentTimeMillis()
//
// // 각 저장소에서 'size' 만큼만 가져옴 (부하 최소화)
// val pageable = PageRequest.of(0, size)
//
// // 1. Post 조회 (lastTime 이전 글)
// val postsFlux = postRepository.findFeedPostsBefore(lastTime, pageable)
// .map { post ->
// val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST
// val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거
//
// FeedItemDto(
// id = post.id,
// type = type,
// title = post.title,
// content = if (type == ContentType.GIBBERISH) post.content else rawContent,
// thumbnail = post.thumb,
// createdAt = post.modifyTime,
// writer = post.writer,
// url = "/blog/viewer/${post.id}"
// )
// }
//
// // 2. Bookmark 조회 (lastTime 이전 글)
// val bookmarksFlux = bookmarkRepository.findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
// listOf("PUBLIC"), lastTime, pageable
// ).map { bookmark ->
// FeedItemDto(
// id = bookmark.id,
// type = ContentType.BOOKMARK,
// title = bookmark.title ?: bookmark.url,
// content = bookmark.userComment ?: bookmark.description,
// thumbnail = bookmark.displayImageUrl,
// createdAt = bookmark.savedAt,
// writer = bookmark.userId,
// url = bookmark.url ?: ""
// )
// }
//
// // 3. 병합 후 다시 정렬하고 size 만큼 자르기
// return Flux.merge(postsFlux, bookmarksFlux)
// .sort(Comparator.comparing(FeedItemDto::createdAt).reversed()) // 최신순 정렬
// .take(size.toLong()) // 전체 중 상위 size 개만 선택
// .collectList()
// .map { items ->
// // 마지막 아이템의 시간을 다음 커서로 설정
// val nextCursor = if (items.isNotEmpty()) items.last().createdAt else null
// FeedResponse(items, nextCursor)
// }
// }
}

View File

@ -0,0 +1,12 @@
/*!
* imagesLoaded PACKAGED v5.0.0
* JavaScript is all like "You images are done yet or what?"
* MIT License
*/
!function(t,e){"object"==typeof module&&module.exports?module.exports=e():t.EvEmitter=e()}("undefined"!=typeof window?window:this,(function(){function t(){}let e=t.prototype;return e.on=function(t,e){if(!t||!e)return this;let i=this._events=this._events||{},s=i[t]=i[t]||[];return s.includes(e)||s.push(e),this},e.once=function(t,e){if(!t||!e)return this;this.on(t,e);let i=this._onceEvents=this._onceEvents||{};return(i[t]=i[t]||{})[e]=!0,this},e.off=function(t,e){let i=this._events&&this._events[t];if(!i||!i.length)return this;let s=i.indexOf(e);return-1!=s&&i.splice(s,1),this},e.emitEvent=function(t,e){let i=this._events&&this._events[t];if(!i||!i.length)return this;i=i.slice(0),e=e||[];let s=this._onceEvents&&this._onceEvents[t];for(let n of i){s&&s[n]&&(this.off(t,n),delete s[n]),n.apply(this,e)}return this},e.allOff=function(){return delete this._events,delete this._onceEvents,this},t})),
/*!
* imagesLoaded v5.0.0
* JavaScript is all like "You images are done yet or what?"
* MIT License
*/
function(t,e){"object"==typeof module&&module.exports?module.exports=e(t,require("ev-emitter")):t.imagesLoaded=e(t,t.EvEmitter)}("undefined"!=typeof window?window:this,(function(t,e){let i=t.jQuery,s=t.console;function n(t,e,o){if(!(this instanceof n))return new n(t,e,o);let r=t;var h;("string"==typeof t&&(r=document.querySelectorAll(t)),r)?(this.elements=(h=r,Array.isArray(h)?h:"object"==typeof h&&"number"==typeof h.length?[...h]:[h]),this.options={},"function"==typeof e?o=e:Object.assign(this.options,e),o&&this.on("always",o),this.getImages(),i&&(this.jqDeferred=new i.Deferred),setTimeout(this.check.bind(this))):s.error(`Bad element for imagesLoaded ${r||t}`)}n.prototype=Object.create(e.prototype),n.prototype.getImages=function(){this.images=[],this.elements.forEach(this.addElementImages,this)};const o=[1,9,11];n.prototype.addElementImages=function(t){"IMG"===t.nodeName&&this.addImage(t),!0===this.options.background&&this.addElementBackgroundImages(t);let{nodeType:e}=t;if(!e||!o.includes(e))return;let i=t.querySelectorAll("img");for(let t of i)this.addImage(t);if("string"==typeof this.options.background){let e=t.querySelectorAll(this.options.background);for(let t of e)this.addElementBackgroundImages(t)}};const r=/url\((['"])?(.*?)\1\)/gi;function h(t){this.img=t}function d(t,e){this.url=t,this.element=e,this.img=new Image}return n.prototype.addElementBackgroundImages=function(t){let e=getComputedStyle(t);if(!e)return;let i=r.exec(e.backgroundImage);for(;null!==i;){let s=i&&i[2];s&&this.addBackground(s,t),i=r.exec(e.backgroundImage)}},n.prototype.addImage=function(t){let e=new h(t);this.images.push(e)},n.prototype.addBackground=function(t,e){let i=new d(t,e);this.images.push(i)},n.prototype.check=function(){if(this.progressedCount=0,this.hasAnyBroken=!1,!this.images.length)return void this.complete();let t=(t,e,i)=>{setTimeout((()=>{this.progress(t,e,i)}))};this.images.forEach((function(e){e.once("progress",t),e.check()}))},n.prototype.progress=function(t,e,i){this.progressedCount++,this.hasAnyBroken=this.hasAnyBroken||!t.isLoaded,this.emitEvent("progress",[this,t,e]),this.jqDeferred&&this.jqDeferred.notify&&this.jqDeferred.notify(this,t),this.progressedCount===this.images.length&&this.complete(),this.options.debug&&s&&s.log(`progress: ${i}`,t,e)},n.prototype.complete=function(){let t=this.hasAnyBroken?"fail":"done";if(this.isComplete=!0,this.emitEvent(t,[this]),this.emitEvent("always",[this]),this.jqDeferred){let t=this.hasAnyBroken?"reject":"resolve";this.jqDeferred[t](this)}},h.prototype=Object.create(e.prototype),h.prototype.check=function(){this.getIsImageComplete()?this.confirm(0!==this.img.naturalWidth,"naturalWidth"):(this.proxyImage=new Image,this.img.crossOrigin&&(this.proxyImage.crossOrigin=this.img.crossOrigin),this.proxyImage.addEventListener("load",this),this.proxyImage.addEventListener("error",this),this.img.addEventListener("load",this),this.img.addEventListener("error",this),this.proxyImage.src=this.img.currentSrc||this.img.src)},h.prototype.getIsImageComplete=function(){return this.img.complete&&this.img.naturalWidth},h.prototype.confirm=function(t,e){this.isLoaded=t;let{parentNode:i}=this.img,s="PICTURE"===i.nodeName?i:this.img;this.emitEvent("progress",[this,s,e])},h.prototype.handleEvent=function(t){let e="on"+t.type;this[e]&&this[e](t)},h.prototype.onload=function(){this.confirm(!0,"onload"),this.unbindEvents()},h.prototype.onerror=function(){this.confirm(!1,"onerror"),this.unbindEvents()},h.prototype.unbindEvents=function(){this.proxyImage.removeEventListener("load",this),this.proxyImage.removeEventListener("error",this),this.img.removeEventListener("load",this),this.img.removeEventListener("error",this)},d.prototype=Object.create(h.prototype),d.prototype.check=function(){this.img.addEventListener("load",this),this.img.addEventListener("error",this),this.img.src=this.url,this.getIsImageComplete()&&(this.confirm(0!==this.img.naturalWidth,"naturalWidth"),this.unbindEvents())},d.prototype.unbindEvents=function(){this.img.removeEventListener("load",this),this.img.removeEventListener("error",this)},d.prototype.confirm=function(t,e){this.isLoaded=t,this.emitEvent("progress",[this,this.element,e])},n.makeJQueryPlugin=function(e){(e=e||t.jQuery)&&(i=e,i.fn.imagesLoaded=function(t,e){return new n(this,t,e).jqDeferred.promise(i(this))})},n.makeJQueryPlugin(),n}));

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,37 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
<head>
<script th:src="@{/js/imagesloaded.pkgd.min.js}"></script>
<script th:src="@{/js/masonry.pkgd.min.js}"></script>
<style>
.feed-grid {
/* 그리드 컨테이너 */
width: 100%;
}
.grid-sizer, .feed-item {
/* 반응형 너비 설정 (기존 col-4 col-12-medium 등과 호환되게 조정) */
width: 33.333%;
}
/* 모바일 화면에서는 1단으로 변경 */
@media screen and (max-width: 980px) {
.grid-sizer, .feed-item {
width: 50%;
}
}
@media screen and (max-width: 736px) {
.grid-sizer, .feed-item {
width: 100%;
}
}
.feed-item {
padding: 0 1em 2em 1em; /* 카드 간 간격 */
float: left; /* Masonry 필수 */
}
</style>
</head>
<th:block layout:fragment="content" id="content">
<section id="banner"
th:styleappend="${randomBannerImage != null} ? |background-image: url('${apiBaseUrl}${randomBannerImage}');| : ''">
@ -16,7 +47,20 @@
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
</header>
</section>
<section class="wrapper style1" style="padding: 2em 0 1em 0;">
<div class="container">
<form action="/" method="get" id="search-form" style="display: flex; justify-content: center; gap: 10px;">
<input type="text" name="q" th:value="${searchQuery}" placeholder="관심 있는 내용을 검색해보세요 (예: Spring, 일상...)"
style="width: 50%; min-width: 300px; padding: 0.75em;" />
<button type="submit" class="button icon solid fa-search">검색</button>
</form>
<div th:if="${searchQuery}" style="text-align: center; margin-top: 1em;">
<h3>'<span th:text="${searchQuery}" style="color: #e44c65;"></span>' 검색 결과</h3>
<a th:href="@{/}" class="button small alt">전체 목록 돌아가기</a>
</div>
</div>
</section>
<!-- <section class="wrapper style2">-->
<!-- <div class="container">-->
<!-- <header class="major">-->
@ -35,7 +79,7 @@
<section class="wrapper style1">
<div class="container">
<article id="feed-container">
<article id="feed-container" class="feed-grid">
<section th:each="item, iterStat : ${feedItems}" class="feed-item">
<div th:if="${item.type.name() == 'POST'}" class="box post"
@ -179,9 +223,11 @@
// UI 상태 변경 (로딩 중)
btn.style.display = 'none';
spinner.style.display = 'inline-block';
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q') || '';
// API 호출
fetch(`/api/feed?cursor=${cursor}`)
// API 호출 시 q 파라미터 포함
fetch(`/api/feed?cursor=${cursor}&q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
// 데이터가 없으면 버튼 숨기고 종료
@ -272,5 +318,24 @@
return '';
}
</script>
/* home.html 하단 스크립트 */
<script th:inline="javascript">
var $grid; // Masonry 인스턴스 저장 변수
// 1. 페이지 로드 완료 후 초기화
document.addEventListener('DOMContentLoaded', function() {
var gridElement = document.querySelector('.feed-grid');
// 이미지 로딩이 완료된 후 레이아웃 실행 (겹침 방지)
imagesLoaded(gridElement, function() {
$grid = new Masonry(gridElement, {
itemSelector: '.feed-item',
columnWidth: '.grid-sizer',
percentPosition: true
});
});
});
</script>
</th:block>
</html>