diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index 30e66c3..8b7e6a6 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -94,7 +94,7 @@ class BlogController( data class GibberishRequest(val content: String) /** - * [신규 추가] 게시물을 영구적으로 삭제하는 API입니다. + * 게시물을 영구적으로 삭제하는 API입니다. * 작성자 또는 관리자만 이 작업을 수행할 수 있습니다. */ @DeleteMapping("/blog/post/{postId}") @@ -443,32 +443,52 @@ class BlogController( @GetMapping("/blog/posts") suspend fun postsList( @RequestParam(value = "page", defaultValue = "0") page: Int, - @AuthenticationPrincipal userDetails: UserDetails? // 현재 로그인한 사용자 정보 주입 + @RequestParam(required = false) category: String?, // [추가] 카테고리 필터 + @RequestParam(required = false) tag: String?, // [추가] 태그 필터 + @AuthenticationPrincipal userDetails: UserDetails? ): ResultMV { val vm = ResultMV("content/posts") val pageable = PageRequest.of(page, 8) - val roles = userDetails?.authorities?.map { it.authority } ?: emptyList() - val username = userDetails?.username + // [추가] 뷰에 필터링 정보 전달 + vm.modelMap["currentCategory"] = category + vm.modelMap["currentTag"] = tag val posts: List val total: Long + // [수정] 필터링 조건에 따라 분기 처리 when { - // 1. ADMIN 역할: 모든 글 (비공개, 모든 버전 포함) - roles.contains("ROLE_ADMIN") -> { - posts = postManager.findAllVersionsPaginated(pageable).awaitSingle() - total = postManager.countAllVersions().awaitSingle() + // 1. 카테고리 필터링이 적용된 경우 + !category.isNullOrBlank() -> { + posts = postManager.findPostsByCategory(category, pageable).awaitSingle() + total = postManager.countPostsByCategory(category).awaitSingle() + vm.modelMap["filterTitle"] = "'${category}' 카테고리의 글" } - // 2. WRITE 역할: 공개된 모든 글 + 자신의 비공개 글 - roles.contains("ROLE_WRITE") && username != null -> { - posts = postManager.findLatestUniqueForWriter(username, pageable).awaitSingle() - total = postManager.countLatestUniqueForWriter(username).awaitSingle() + // 2. 태그 필터링이 적용된 경우 + !tag.isNullOrBlank() -> { + posts = postManager.findPostsByTag(tag, pageable).awaitSingle() + total = postManager.countPostsByTag(tag).awaitSingle() + vm.modelMap["filterTitle"] = "'#${tag}' 태그가 포함된 글" } - // 3. 그 외 (익명, READ 역할): 공개된 글만 + // 3. 필터링이 없는 경우 (기존 로직) else -> { - posts = postManager.findLatestUniquePaginated(pageable).awaitSingle() - total = postManager.countLatestUnique().awaitSingle() + val roles = userDetails?.authorities?.map { it.authority } ?: emptyList() + val username = userDetails?.username + when { + roles.contains("ROLE_ADMIN") -> { + posts = postManager.findAllVersionsPaginated(pageable).awaitSingle() + total = postManager.countAllVersions().awaitSingle() + } + roles.contains("ROLE_WRITE") && username != null -> { + posts = postManager.findLatestUniqueForWriter(username, pageable).awaitSingle() + total = postManager.countLatestUniqueForWriter(username).awaitSingle() + } + else -> { + posts = postManager.findLatestUniquePaginated(pageable).awaitSingle() + total = postManager.countLatestUnique().awaitSingle() + } + } } } @@ -636,17 +656,19 @@ class BlogController( @GetMapping("/blog/categories.bjx") @ResponseBody fun getCategories(): Mono { - // TODO: 향후 DB에서 고유 카테고리 목록을 조회하도록 수정해야 합니다. - val sampleCategories = listOf("일상", "기술", "여행", "요리") - return Mono.just(TagResponse(tags = sampleCategories)) + // [수정] 샘플 데이터 대신 DB에서 고유 카테고리 목록을 조회 + return postManager.findAllDistinctCategories() + .collectList() + .map { categories -> TagResponse(tags = categories) } } @GetMapping("/blog/hashtags.bjx") @ResponseBody fun getHashtags(): Mono { - // TODO: 향후 DB에서 고유 해시태그 목록을 조회하도록 수정해야 합니다. - val sampleTags = listOf("Spring", "Kotlin", "React", "MongoDB", "여행") - return Mono.just(TagResponse(tags = sampleTags)) + // [수정] 샘플 데이터 대신 DB에서 고유 해시태그 목록을 조회 + return postManager.findAllDistinctTags() + .collectList() + .map { tags -> TagResponse(tags = tags) } } @@ -670,6 +692,17 @@ class BlogController( val incomingPost = PayloadDecoder.decode(rawPayload, Post::class.java, objectMapper) + // ======================= [수정 시작] ======================= + // DB에 저장하기 전, 클라이언트에서 인코딩된 데이터를 모두 디코딩합니다. + incomingPost.title = URLDecoder.decode(incomingPost.title ?: "", "UTF-8") + incomingPost.content = URLDecoder.decode(incomingPost.content ?: "", "UTF-8") + incomingPost.category = URLDecoder.decode(incomingPost.category ?: "none", "UTF-8") + incomingPost.tags = URLDecoder.decode(incomingPost.tags ?: "", "UTF-8") + incomingPost.firstAddress = URLDecoder.decode(incomingPost.firstAddress ?: "", "UTF-8") + incomingPost.modifyAddress = URLDecoder.decode(incomingPost.modifyAddress ?: "", "UTF-8") + // ======================= [수정 끝] ========================= + + return if (incomingPost.id.isNullOrBlank()) { // 새 글 작성 val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } @@ -682,6 +715,7 @@ class BlogController( incomingPost.writeTime = System.currentTimeMillis() incomingPost.modifyTime = incomingPost.writeTime + val savedPost = postManager.save(incomingPost).awaitSingle() PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) @@ -698,19 +732,29 @@ class BlogController( if (!isAdmin && !isWriter) { return PostSaveResponse(403, "Permission denied to update post", null) } - + incomingPost.writer = user.username // 2. 원본 데이터를 기반으로 PostHistory 객체를 생성합니다. val history = PostHistory( postId = originalPost.id!!, - title = originalPost.title, content = originalPost.content, category = originalPost.category, tags = originalPost.tags, writer = originalPost.writer, writeTime = originalPost.writeTime, posting = originalPost.posting, + firstPostLat = originalPost.firstPostLat, + firstPostLon = originalPost.firstPostLon, + firstAddress = originalPost.firstAddress, + modifyAddress = originalPost.modifyAddress, + modifyTime = originalPost.modifyTime, + modifyLat = originalPost.modifyLat, + modifyLon = originalPost.modifyLon, + readCount = originalPost.readCount, + voteCount = originalPost.voteCount, + unlikeCount = originalPost.unlikeCount, + isBlocked = originalPost.isBlocked, + postType = originalPost.postType, // ... originalPost의 모든 필드를 복사 ... - modifyTime = originalPost.modifyTime ) // 3. 히스토리를 DB에 저장합니다. @@ -723,7 +767,12 @@ class BlogController( posting = incomingPost.posting, // ★ 변경된 공개 상태 적용 category = incomingPost.category, tags = incomingPost.tags, - modifyTime = System.currentTimeMillis() // 수정 시간 갱신 + modifyTime = System.currentTimeMillis(), // 수정 시간 갱신 + writeTime = incomingPost.writeTime, // 사용자가 수정한 작성 시간을 반영합니다. + modifyAddress = incomingPost.modifyAddress, // 수동으로 입력된 주소를 반영합니다. + modifyLat = incomingPost.modifyLat, // 주소가 수동 입력되면 0.0으로 초기화됩니다. + modifyLon = incomingPost.modifyLon, + writer = incomingPost.writer, // ... 기타 수정 가능한 필드들 ... ) diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt index cd17a80..1aa7080 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Telegram.kt @@ -63,11 +63,6 @@ class Telegram { return "hello1212" } - @Autowired - lateinit var rssDataService: RssDataService - - - val keyworkd = arrayListOf("I0Z","dcBEW", "TGyG", "U=Qu", "Bm=s") val keyworkd2 = arrayListOf("x-n", "Y_D", "u", "uoo", "dfhZ", "gSKY") diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt index 01127a7..9008f34 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/ImageMeta.kt @@ -98,7 +98,7 @@ class ImageMetaService( // application.properties의 업로드 경로 주입 /** - * [신규 추가] Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너 + * Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너 */ @Profile("!local") @EventListener(ApplicationReadyEvent::class) @@ -110,7 +110,7 @@ class ImageMetaService( } /** - * [신규 추가] 동기화 작업을 비동기(Coroutine)로 실행하는 런처 (잠금 처리) + * 동기화 작업을 비동기(Coroutine)로 실행하는 런처 (잠금 처리) * BlogController에서도 이 함수를 호출할 수 있습니다. */ fun launchSyncTask() { @@ -136,7 +136,7 @@ class ImageMetaService( } /** - * [신규 추가] 실제 파일 시스템과 DB를 동기화하는 핵심 로직 + * 실제 파일 시스템과 DB를 동기화하는 핵심 로직 */ // ImageMetaService.kt의 runFileSystemSync 함수를 아래 내용으로 교체하세요. private suspend fun runFileSystemSync() { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index 7ac1828..efc3e1f 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -364,6 +364,25 @@ interface PostRepository : ReactiveMongoRepository { ]) fun findRandomPublishedPostByType(postType: String): Mono + // --- [신규 추가] 필터링을 위한 Repository 메소드 --- + fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux + fun countByCategoryAndPostingIsTrue(category: String): Mono + fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux + fun countByTagsRegexAndPostingIsTrue(tag: String): Mono + // [추가] MongoDB Aggregation을 사용해 고유 태그 목록을 효율적으로 조회 + @Aggregation(pipeline = [ + // 1. tags 필드가 null이면 빈 문자열로 만든 후 "," 기준으로 잘라 배열로 변환 + "{ \$project: { tags: { \$split: [ { \$ifNull: [ \"\$tags\", \"\" ] }, \",\" ] } } }", + // 2. 생성된 tags 배열을 개별 문서로 분리 (예: ["a","b"] -> {tags:"a"}, {tags:"b"}) + "{ \$unwind: \"\$tags\" }", + // 3. 각 태그의 앞뒤 공백 제거 + "{ \$project: { tag: { \$trim: { input: \"\$tags\" } } } }", + // 4. 공백이 제거된 태그로 그룹화하여 고유한 값만 추출 + "{ \$group: { _id: \"\$tag\" } }", + // 5. 그룹화 결과 중 빈 값("")은 제외 + "{ \$match: { _id: { \$ne: \"\" } } }" + ]) + fun findDistinctTags(): Flux // 반환 타입을 Document로 변경 } @@ -427,6 +446,38 @@ class PostManager( } } + // --- [신규 추가] 카테고리/태그 관련 서비스 메소드 --- + fun findAllDistinctCategories(): Flux { + // 'category' 필드가 null이 아니고 비어있지 않은 문서들을 대상으로 distinct 연산 수행 + val query = Query.query(Criteria.where("category").ne(null).ne("")) + return reactiveMongoTemplate.findDistinct(query, "category", "Post", String::class.java) + } + + fun findAllDistinctTags(): Flux { + return postRepository.findDistinctTags() + .mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출 + .filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링 + } + // --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 --- + fun findPostsByCategory(category: String, pageable: Pageable): Mono> { + return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList() + } + fun countPostsByCategory(category: String): Mono { + return postRepository.countByCategoryAndPostingIsTrue(category) + } + + fun findPostsByTag(tag: String, pageable: Pageable): Mono> { + // [수정] 한글 및 다국어를 지원하는 정규식으로 변경 + val regex = "(^|,)${Regex.escape(tag)}(,|$)" + return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList() + } + + fun countPostsByTag(tag: String): Mono { + // [수정] 위와 동일하게 정규식 변경 + val regex = "(^|,)${Regex.escape(tag)}(,|$)" + return postRepository.countByTagsRegexAndPostingIsTrue(regex) + } + // [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드 fun findRandomGibberish(): Mono { return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name) @@ -464,13 +515,6 @@ class PostManager( return postRepository.findById(id) } -// /** -// * [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 글 + 자신의 글 페이지네이션 조회) -// */ -// fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono> { -// return postRepository.findLatestUniqueForWriterPaginated(username, pageable) -// .collectList() -// } fun findPostsByWriter(writer: String, pageable: Pageable): Flux { return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable) @@ -484,14 +528,6 @@ class PostManager( } } -// /** -// * [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수 -// */ -// fun countLatestUniqueForWriter(username: String): Mono { -// return postRepository.countLatestUniqueForWriter(username) -// .map { it.totalCount } -// .switchIfEmpty(Mono.just(0L)) -// } fun getPost(id: String): Mono { val query = Query.query(Criteria.where("id").`is`(id)) @@ -505,7 +541,6 @@ class PostManager( /** - * [이름 변경] find20 -> findAllVersionsPaginated * 인증된 사용자를 위한 메서드 (모든 버전 조회) * [FIX]: Change return type to Mono> and remove the blocking call. */ @@ -552,7 +587,6 @@ class PostManager( // } /** - * [신규 추가] * 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 수 있도록 *업데이트된* 문서를 반환합니다. */ fun incrementVote(postId: String): Mono { @@ -564,7 +598,6 @@ class PostManager( } /** - * [신규 추가] * 싫어요 카운트를 1 증가시키고, *업데이트된* 문서를 반환합니다. */ fun incrementUnlike(postId: String): Mono { @@ -600,7 +633,6 @@ class PostManager( /** - * [로직 수정] * 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다. * [FIX]: Return Mono> to match the updated callee. */ @@ -611,10 +643,6 @@ class PostManager( return this.findLatestUniquePaginated(pageRequest) // <-- 4. This now correctly returns the Mono } - fun find20() : List { - return postRepository.findAllByModifyTime(0).takeLast(20).buffer(20).blockLast(Duration.ofSeconds(30)) ?: listOf() - } - fun save(post: Post): Mono { println("saved user before ${post}") // user.hashPassword(bCryptPasswordEncoder) @@ -947,260 +975,6 @@ class LocationLogService : LocationService { } } -interface RssDataInterface { - fun title() : String - fun thumbnailUrl() : String - fun originPage() : String - fun description() : String - fun pubDate() : Long - fun category() : RssDataType - fun getCho() : String? - -} -enum class RssDataType { - NO_DATA, - YOUTUBE, - NewsFeed, - GURU, - Most, - TAGS, - REDDIT, - REDDIT_nsfw, - Dotax, - FmKorae, - DcInside, - RuliWeb, - Clien, - TheQoo, - Arca; - -// fun getResId() = when (this) { -// YOUTUBE -> R.drawable.youtube -// REDDIT, REDDIT_nsfw -> R.drawable.reddit -// Dotax -> R.drawable.daum -// FmKorae -> R.drawable.fmk -// DcInside -> R.drawable.dcinside -// Arca -> R.drawable.arca -// else -> { -// 0 -// } -// } - - fun defaultImgSize() = when (this) { - YOUTUBE -> 200 - REDDIT_nsfw,GURU,Most -> 360 - else -> { 120 } - } - -// fun getDefaultVisibiliy() = when (this) { -// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE -// else -> { View.VISIBLE } -// } -} -@Data -@NoArgsConstructor -@AllArgsConstructor -@Document(collection = "RssData") -class RssData : RssDataInterface { - - @Id - var originPage : String? = null - var title : String? = null - var description : String? = null - var thumbnail : String? = null - var pubDate : Long = 0L - var category : String? = null - - var chosung : String? = null - - - @BsonIgnore - var mRssDataType : RssDataType? = null - override fun title(): String { - return when(category()){ - RssDataType.NewsFeed -> { - if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: "" - } - else -> title ?: "" - }.apply { -// chosung = JamoUtils.split(this).joinToString("") - } - } - - override fun thumbnailUrl(): String { - return thumbnail ?: "" - } - - override fun originPage(): String { - return originPage ?: "" - } - - override fun description(): String { - - return when(category()){ - RssDataType.YOUTUBE -> { - if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: "" - } - RssDataType.NewsFeed -> { - category().name - } - else -> description.plus(" / ").plus(category().name) - } - } - - override fun pubDate(): Long { - return pubDate - } - - override fun category(): RssDataType { - if (mRssDataType == null) - mRssDataType = RssDataType.valueOf(category!!) - return mRssDataType!! - } - - override fun getCho(): String? { - return chosung - } -} - - -val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15" -fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get() -object FeedParseManager { - val parsers = listOf(QVZTb2dpcmw,SkFWTW9zdA) - fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) { - try { - parsers.filter { doc.title().contains(it.getName()) }.first()?.let { - it.parse(doc,service) - } - } catch (e : Exception) { - - e.printStackTrace() - } - } -} -interface SoInterface{ - fun getName() : String - fun parse(doc : org.jsoup.nodes.Document,service: RssDataService) -} -object QVZTb2dpcmw : SoInterface { - override fun getName(): String { - return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray())) - } - override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) { - var lists = arrayListOf() - doc.getElementsByTag("article").forEach { article -> - - val title = article.getElementsByTag("a").get(0).attr("title") - val href = article.getElementsByTag("a").get(0).attr("href") - val img = article.getElementsByTag("img").get(0).attr("data-src") - service.save(RssData().apply { - this.originPage = href - this.title = title - this.description = "Sogirl" - this.thumbnail = img - this.pubDate = Date().time - this.category = RssDataType.GURU.name - - }) { -// CoroutineScope(Dispatchers.IO).launch { -// service.sendMsg("${title}\n${img}\n${href}") -// } - } - } -// lists.map { -// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}") -// } - } -} - -object SkFWTW9zdA : SoInterface { - var dmy = SimpleDateFormat("dd-MM-yyyy") - override fun getName(): String { - return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray())) - } - override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) { - var lists = arrayListOf() - doc.getElementsByClass("card").forEach { card -> - var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else "" - if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb - var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else "" - if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) { - val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href") - val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title") - val date = card.getElementsByTag("span").get(0).text() - service.save(RssData().apply { - lists.add(this) - description = model - thumbnail = thumb - originPage = link - this.title = title - category = RssDataType.Most.name - try { - pubDate = dmy.parse(date).time - }catch (e : Exception) {e.printStackTrace()} - }){ -// CoroutineScope(Dispatchers.IO).launch { -// service.sendMsg("${title}\n${thumb}\n${link}") -// } - } - } - } -// service.sendMsg(lists.map { -// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n" -// }.joinToString(" \n ")) - } -} -@Repository -interface RssDataRepository : ReactiveMongoRepository { - fun findFirstByOriginPageEquals(originPage : String): Mono - fun findAllByOrderByPubDate() : Mono> - fun save(log: RssData): Mono -} - -@Service -class RssDataService { - @Autowired - private lateinit var logService: LogService - - @Autowired - private lateinit var rssDataRepository: RssDataRepository - fun hasItem(originPage : String) { - - } - fun getLocationLog() : List? { - return rssDataRepository.findAllByOrderByPubDate().block() - } - - - fun save(log: RssData, callback : (Boolean)->Unit) { - println("saved msg before ${Gson().toJson(log)}") - log.originPage?.let { - if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) { - rssDataRepository.save(log) - .subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, { - println("saved msg comp") - callback(true) - }) - } else { - println("있어???") - } - } - } - - @Autowired - lateinit var globalEvv : GlobalEnvironment - - suspend fun sendMsg(data : String) { - val client = WebClient.create() - client.get() - .uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}") - .retrieve() - .bodyToMono(String::class.java).block() ?: "FAIL" - } -} - - enum class Visibility { PUBLIC, // 전체 공개 MEMBERS, // 회원 공개 @@ -1367,7 +1141,6 @@ class WebBookmarkService(private val repository: WebBookmarkRepository, } /** - * [신규 추가] * 북마크의 좋아요 카운트를 1 증가시킵니다. * @param bookmarkId 대상 북마크의 ID * @return 업데이트된 WebBookmark 객체 @@ -1380,7 +1153,6 @@ class WebBookmarkService(private val repository: WebBookmarkRepository, } /** - * [신규 추가] * 북마크의 싫어요 카운트를 1 증가시킵니다. * @param bookmarkId 대상 북마크의 ID * @return 업데이트된 WebBookmark 객체 diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt index a5738b8..89d7de6 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/PuzzleData.kt @@ -638,7 +638,7 @@ class GameRankService( } /** - * [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다. + * 특정 플레이어의 모든 게임 랭킹을 조회합니다. */ fun getRanksByPlayer(playerName: String): Flux { return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName) diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index a609a30..3c2ef5d 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -278,6 +278,20 @@ function initEditor(useEditor = false) { getLocation(); // 위치 정보 가져오기 시작 + const manualDateInput = document.getElementById('manual_date'); + const manualLatInput = document.getElementById('manual_lat'); + const manualLonInput = document.getElementById('manual_lon'); + + if (manualDateInput) { + const timeToDisplay = baseData.writeTime > 0 ? baseData.writeTime : new Date().getTime(); + manualDateInput.value = formatTimestampForInput(timeToDisplay); + } + if (manualLatInput && manualLonInput) { + // 수정 좌표가 있으면 그것을, 없으면 최초 좌표를, 둘 다 없으면 빈 칸으로 설정 + manualLatInput.value = baseData.modifyLat || baseData.firstPostLat || ''; + manualLonInput.value = baseData.modifyLon || baseData.firstPostLon || ''; + } + try { // Quill 에디터 옵션 및 모듈 설정 (편집/읽기 모드 전환) var Font = Quill.import('formats/font'); @@ -489,16 +503,28 @@ function setupControlBox(mode) { categoryBox.classList.remove('btn-example'); hashtagBox.classList.remove('btn-example'); - // 카테고리 데이터를 HTML로 렌더링 - const categoryContent = `${baseData.category || '지정되지 않음'}`; - categoryBox.innerHTML = `CATEGORY
${categoryContent}
`; + // [수정] 카테고리 데이터를 링크(``)를 포함한 HTML로 렌더링 + if (baseData.category && baseData.category !== 'none') { + const categoryLink = `${getMainPath()}/blog/posts?category=${encodeURIComponent(baseData.category)}`; + const categoryContent = `${baseData.category}`; + categoryBox.innerHTML = `CATEGORY
${categoryContent}
`; + } else { + categoryBox.innerHTML = `CATEGORY
지정되지 않음
`; + } - // 해시태그 데이터를 HTML로 렌더링 (태그가 여러 개일 수 있으므로 split/map/join 사용) + // [수정] 해시태그 데이터를 링크(``)를 포함한 HTML로 렌더링 let hashtagContent = ''; if (baseData.tags && baseData.tags.length > 0) { hashtagContent = baseData.tags.split(',') - .map(tag => `#${tag.trim()}`) - .join(' '); // 각 태그를 공백으로 구분 + .map(tag => { + const trimmedTag = tag.trim(); + if (trimmedTag) { + const tagLink = `${getMainPath()}/blog/posts?tag=${encodeURIComponent(trimmedTag)}`; + return `#${trimmedTag}`; + } + return ''; + }) + .join(' '); } else { hashtagContent = '없음'; } @@ -595,6 +621,51 @@ async function save() { // 1. 원본 baseData를 복사하여 전송용 객체(dataToSend) 생성 let dataToSend = JSON.parse(JSON.stringify(baseData)); + // 2. [신규] 수동 입력 필드에서 값 읽기 + const manualDateInput = document.getElementById('manual_date'); + const manualLatInput = document.getElementById('manual_lat'); + const manualLonInput = document.getElementById('manual_lon'); + + // 2.1. 수동 날짜 처리 + if (manualDateInput && manualDateInput.value) { + const manualTimestamp = new Date(manualDateInput.value).getTime(); + if (!isNaN(manualTimestamp)) { + dataToSend.writeTime = manualTimestamp; + if (!dataToSend.id) { + dataToSend.modifyTime = manualTimestamp; + } + } + } + + // 2.2. 수동 좌표 또는 자동 GPS 위치 처리 + const manualLat = parseFloat(manualLatInput.value); + const manualLon = parseFloat(manualLonInput.value); + + if (!isNaN(manualLat) && !isNaN(manualLon)) { + // 수동 좌표가 유효한 숫자로 입력된 경우 + dataToSend.modifyLat = manualLat; + dataToSend.modifyLon = manualLon; + // 좌표를 직접 입력했으므로, 주소 정보는 초기화 + dataToSend.modifyAddress = ''; + + if (!dataToSend.id) { // 새 글일 경우 + dataToSend.firstPostLat = manualLat; + dataToSend.firstPostLon = manualLon; + dataToSend.firstAddress = ''; + } + } else { + // 수동 좌표가 없으면, 자동 GPS 위치를 사용 + dataToSend.modifyLat = currentLat; + dataToSend.modifyLon = currentLon; + if (!dataToSend.id) { // 새 글일 경우 + dataToSend.firstPostLat = currentLat; + dataToSend.firstPostLon = currentLon; + dataToSend.firstAddress = ''; + dataToSend.modifyAddress = ''; + } + } + + // 2. 모든 텍스트 필드를 URL 전송을 위해 인코딩합니다. if (titleField) { dataToSend.title = encodeURIComponent(titleField.value); @@ -606,9 +677,14 @@ async function save() { dataToSend.category = encodeURIComponent(dataToSend.category || 'none'); dataToSend.tags = encodeURIComponent(dataToSend.tags || ''); dataToSend.posting = document.getElementById('post-published-switch').checked - // 3. 현재 위치 좌표를 '수정 좌표'로 업데이트 - dataToSend.modifyLat = currentLat; - dataToSend.modifyLon = currentLon; + + dataToSend.firstAddress = encodeURIComponent(dataToSend.firstAddress || ''); + // dataToSend.modifyAddress = encodeURIComponent(dataToSend.modifyAddress || ''); + // + // + // // 3. 현재 위치 좌표를 '수정 좌표'로 업데이트 + // dataToSend.modifyLat = currentLat; + // dataToSend.modifyLon = currentLon; // 4. 새 글일 경우 '최초 좌표'에도 기록 if (dataToSend.firstPostLat === 0.0 || dataToSend.firstPostLat === null) { @@ -646,11 +722,18 @@ async function save() { * 사용자의 현재 GPS 위치를 가져옵니다. (바닐라 Browser API) */ function getLocation() { - // 1. 이미 저장된 좌표가 있으면 해당 좌표로 주소 변환 API 호출 - if (baseData.firstPostLat !== 0.0 || baseData.firstPostLon !== 0.0) { + // 1. 표시할 좌표 결정: 수정 좌표(modifyLat) -> 최초 좌표(firstPostLat) 순으로 확인 + const latToDisplay = (baseData.modifyLat !== 0.0) ? baseData.modifyLat : baseData.firstPostLat; + const lonToDisplay = (baseData.modifyLon !== 0.0) ? baseData.modifyLon : baseData.firstPostLon; + + const locationField = document.getElementById('location_field'); + if (!locationField) return; // locationField 요소가 없으면 함수 종료 + + // 2. 저장된 좌표가 있는 경우 (가장 일반적인 경우) + if (latToDisplay != 0.0 || lonToDisplay != 0.0) { try { var requestOptions = { method: 'GET' }; - fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+baseData.firstPostLat+"&lon="+baseData.firstPostLon+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions) + fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+latToDisplay+"&lon="+lonToDisplay+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions) .then(response => response.json()) .then(function(result) { const locationField = document.getElementById('location_field'); @@ -658,41 +741,48 @@ function getLocation() { var inh = `LOCATION`; inh += `
`; inh += `
${result.features[0].properties.formatted}
`; // 주소 - inh += `
Lat: ${baseData.firstPostLat.toFixed(2)}
`; - inh += `
Lon: ${baseData.firstPostLon.toFixed(2)}
`; + inh += `
Lat: ${latToDisplay.toFixed(2)}
`; + inh += `
Lon: ${lonToDisplay.toFixed(2)}
`; locationField.innerHTML = inh; } catch (e) { // 주소 변환 실패 시 좌표만 표시 locationField.innerHTML = `LOCATION` + - `
Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`; + `
Lat: ${latToDisplay.toFixed(2)}
Lon: ${lonToDisplay.toFixed(2)}
`; } }) .catch(error => console.log('error', error)); }catch (e) { } - } else if (navigator.geolocation) { - // 2. 저장된 좌표가 없으면 (새 글 작성 시) 브라우저에 GPS 요청 - navigator.geolocation.getCurrentPosition(pos => { - currentLat = pos.coords.latitude; - currentLon = pos.coords.longitude; - if (baseData.firstPostLat === 0.0) baseData.firstPostLat = currentLat; - if (baseData.firstPostLon === 0.0) baseData.firstPostLon = currentLon; - baseData.modifyLat = currentLat; - baseData.modifyLon = currentLon; + } + // 3. 저장된 좌표는 없지만, 브라우저가 GPS를 지원하는 경우 (새 글 작성 시) + else if (navigator.geolocation) { + // 현재 위치를 GPS로 가져옵니다. + navigator.geolocation.getCurrentPosition( + (pos) => { // 성공 콜백 + currentLat = pos.coords.latitude; + currentLon = pos.coords.longitude; - // UI에 좌표 표시 - const locationField = document.getElementById('location_field'); - if (locationField) { - locationField.innerHTML = `LOCATION` + - `
Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`; + // 가져온 GPS 좌표를 location-field에 표시 + const inh = `LOCATION +
+
Lat: ${currentLat.toFixed(4)}
+
Lon: ${currentLon.toFixed(4)}
+
`; + locationField.innerHTML = inh; + + // [개선] 가져온 좌표를 수동 입력 필드의 기본값으로도 설정해 줍니다. + const manualLatInput = document.getElementById('manual_lat'); + const manualLonInput = document.getElementById('manual_lon'); + if (manualLatInput) manualLatInput.value = currentLat.toFixed(6); + if (manualLonInput) manualLonInput.value = currentLon.toFixed(6); + }, + () => { // 실패 콜백 + locationField.innerHTML = `LOCATION
GPS를 가져올 수 없습니다.
`; } - }); - } else { - // 3. GPS 지원 안 하는 브라우저 - const locationField = document.getElementById('location_field'); - if (locationField) { - locationField.innerHTML = `LOCATION` + - `
좌표 지원 안함
`; - } + ); + } + // 4. 브라우저가 GPS를 지원하지 않는 경우 + else { + locationField.innerHTML = `LOCATION
좌표 지원 안함
`; } } @@ -2229,7 +2319,7 @@ function handleDeletePost(postId) { } /** - * [신규 추가] 현재 수정 중인 게시물을 삭제하는 함수 + * 현재 수정 중인 게시물을 삭제하는 함수 * @param {string} postId 삭제할 게시물의 ID */ function deleteCurrentPost(buttonElement) { @@ -2262,4 +2352,18 @@ function deleteCurrentPost(buttonElement) { alert('삭제 중 오류가 발생했습니다.'); }); } -} \ No newline at end of file +} + +/** + * 밀리초 타임스탬프를 'YYYY-MM-DDTHH:mm' 형식의 문자열로 변환합니다. + * @param {number} ms - 변환할 타임스탬프 (밀리초) + * @returns {string} datetime-local input에 사용할 수 있는 형식의 문자열 + */ +function formatTimestampForInput(ms) { + if (!ms || ms === 0) return ''; + const date = new Date(ms); + // 사용자의 로컬 시간대에 맞게 표시하기 위해 타임존 오프셋을 계산하여 적용합니다. + const timezoneOffset = date.getTimezoneOffset() * 60000; + const localDate = new Date(date.getTime() - timezoneOffset); + return localDate.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm" +} diff --git a/src/main/resources/templates/content/editor.html b/src/main/resources/templates/content/editor.html index e68427b..5efab12 100644 --- a/src/main/resources/templates/content/editor.html +++ b/src/main/resources/templates/content/editor.html @@ -41,6 +41,24 @@
+
+

수동 설정

+
+
+ + +
+
+ + +
+
+ + +
+
+

* 작성일이나 좌표를 직접 입력하면 자동으로 측정된 GPS 정보 대신 입력된 값이 저장됩니다.

+
diff --git a/src/main/resources/templates/content/home.html b/src/main/resources/templates/content/home.html index a1857f7..8fcb318 100644 --- a/src/main/resources/templates/content/home.html +++ b/src/main/resources/templates/content/home.html @@ -34,21 +34,12 @@
-
- - -
-
+
Post Thumbnail @@ -66,6 +57,22 @@

+ +
+
+

- Advertisement -

+ + +
+
+
diff --git a/src/main/resources/templates/content/posts.html b/src/main/resources/templates/content/posts.html index 39d4a97..50c513f 100644 --- a/src/main/resources/templates/content/posts.html +++ b/src/main/resources/templates/content/posts.html @@ -16,6 +16,10 @@
+
+

필터링된 게시물

+

모든 글 보기

+
@@ -48,12 +52,12 @@
-
+

- Advertisement -