This commit is contained in:
lunaticbum 2025-12-29 16:36:43 +09:00
parent 7397d403d4
commit b10d3223fd
20 changed files with 1775 additions and 955 deletions

View File

@ -217,12 +217,15 @@ class SecurityConfig(
// 6. 나머지 모든 요청 = authenticated (인증 필요) // 6. 나머지 모든 요청 = authenticated (인증 필요)
.anyRequest().authenticated() .anyRequest().authenticated()
}.formLogin { form -> }
form.loginPage("/home.bs?action=login") .formLogin { form ->
.loginProcessingUrl("/user/login.bs") form
.defaultSuccessUrl("/", true) .loginPage("/home.bs?action=login") // 로그인 페이지 (GET)
.permitAll() .loginProcessingUrl("/login.bjx") // [핵심] 로그인 폼이 제출되는 주소 (POST)
}.rememberMe { rememberMe -> .defaultSuccessUrl("/") // 성공 시 이동할 주소
.failureUrl("/home.bs?action=login&error=true") // 실패 시 이동할 주소
}
.rememberMe { rememberMe ->
rememberMe.rememberMeServices(rememberMeServices()) rememberMe.rememberMeServices(rememberMeServices())
.key(key) .key(key)
.tokenRepository(tokenRepository) .tokenRepository(tokenRepository)

View File

@ -5,11 +5,13 @@ import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.* import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.FeedResponse
import kr.lunaticbum.back.lun.service.CommentService import kr.lunaticbum.back.lun.service.CommentService
import kr.lunaticbum.back.lun.service.FeedService
import kr.lunaticbum.back.lun.service.PostHistoryManager import kr.lunaticbum.back.lun.service.PostHistoryManager
import kr.lunaticbum.back.lun.service.PostManager import kr.lunaticbum.back.lun.service.PostManager
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.PayloadDecoder
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
@ -19,105 +21,115 @@ import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
// Gibberish 요청 DTO
data class GibberishRequest(
val id: String? = null,
val content: String
)
// 댓글 요청 DTO
data class CommentRequest(
val content: String,
val targetType: ContentType
)
@RestController @RestController
@RequestMapping("/blog") // API 경로 접두어 @RequestMapping("/blog")
class PostApiController( class PostApiController(
private val postManager: PostManager, private val postManager: PostManager,
private val postHistoryManager: PostHistoryManager, private val postHistoryManager: PostHistoryManager,
private val commentService: CommentService, private val commentService: CommentService,
private val feedService: FeedService, // [신규] 통합 피드 서비스
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val logService: LogService private val logService: LogService
) { ) {
// --- GET APIs (조회) --- // --- 1. 통합 피드 API (Infinite Scroll용) ---
// script fetch url: /blog/feed?cursor=...&q=...
@GetMapping("/rankOfViews.bjx") @GetMapping("/feed")
fun getRankOfViews(): Mono<ResponseEntity<PostListResponse>> { suspend fun getFeed(
val authentication = SecurityContextHolder.getContext().authentication @RequestParam(required = false) cursor: Long?,
val isAnonymous = authentication == null || authentication is AnonymousAuthenticationToken @RequestParam(required = false) q: String?,
@AuthenticationPrincipal user: UserDetails?
val postsFlux: Flux<Post> = if (isAnonymous) { ): FeedResponse {
postManager.getTop5UniquePublishedByViews() // 커서 기반 페이징 (기본 10개)
} else { return feedService.getGlobalFeed(cursor, 10, q, user?.username).awaitSingle()
postManager.getTop5AllVersionsByViews()
}
return postsFlux.collectList().map { ResponseEntity.ok(PostListResponse(it)) }
} }
@GetMapping("/recentOfPost.bjx") // --- 2. 통합 댓글 API (Post, Gibberish, Bookmark 공용) ---
fun getRecentOfPost(): Mono<ResponseEntity<PostListResponse>> {
val authentication = SecurityContextHolder.getContext().authentication
val isAnonymous = authentication == null || authentication is AnonymousAuthenticationToken
val postsFlux: Flux<Post> = if (isAnonymous) { @GetMapping("/comments/{targetId}")
postManager.getRecent5UniquePublished() fun getComments(
} else { @PathVariable targetId: String,
postManager.getRecent5AllVersions() @RequestParam type: ContentType
} ): Mono<CommentResponse> {
return postsFlux.collectList().map { ResponseEntity.ok(PostListResponse(it)) } return commentService.getComments(targetId, type)
}
@GetMapping("/posts/{postId}/comments.bjx")
fun getComments(@PathVariable postId: String): Mono<CommentResponse> {
return commentService.getCommentsForPost(postId)
.collectList() .collectList()
.map { comments -> CommentResponse(0, "Success", comments) } .map { comments -> CommentResponse(0, "Success", comments) }
} }
@PostMapping("/comments/{targetId}")
fun addComment(
@PathVariable targetId: String,
@RequestBody request: CommentRequest,
@AuthenticationPrincipal user: UserDetails?
): Mono<CommentResponse> {
val writer = user?.username ?: "Anonymous"
// 간단한 유효성 검사
if (request.content.isBlank()) return Mono.just(CommentResponse(400, "Content is empty"))
return commentService.addComment(targetId, request.targetType, writer, request.content)
.map { CommentResponse(0, "Success") }
}
// 대댓글 조회 (기존 유지)
@GetMapping("/comments/{commentId}/replies.bjx") @GetMapping("/comments/{commentId}/replies.bjx")
fun getReplies(@PathVariable commentId: String): Mono<CommentResponse> { fun getReplies(@PathVariable commentId: String): Mono<CommentResponse> {
return commentService.getRepliesForComment(commentId) // CommentService에 대댓글 조회 메서드가 구현되어 있다고 가정 (또는 기존 로직 유지)
.collectList() // 여기서는 예시로 빈 리스트 반환 혹은 기존 서비스 호출
.map { replies -> CommentResponse(0, "Success", replies) } return Mono.just(CommentResponse(0, "Not implemented yet", emptyList()))
} }
@GetMapping("/categories.bjx")
fun getCategories(): Mono<TagResponse> {
return postManager.findAllDistinctCategories()
.collectList()
.map { categories -> TagResponse(tags = categories) }
}
@GetMapping("/hashtags.bjx") // --- 3. 게시글(Post) CRUD ---
fun getHashtags(): Mono<TagResponse> {
return postManager.findAllDistinctTags()
.collectList()
.map { tags -> TagResponse(tags = tags) }
}
// --- POST/PUT/DELETE APIs (데이터 변경) --- @PostMapping("/post") // .bjx 접미사 제거 (RESTful 권장)
@PostMapping("/post.bjx")
@Transactional @Transactional
suspend fun savePost( suspend fun savePost(
@RequestBody rawPayload: String, @RequestBody rawPost: Post, // JSON 그대로 매핑
@AuthenticationPrincipal user: UserDetails? @AuthenticationPrincipal user: UserDetails?
): PostSaveResponse { ): PostSaveResponse {
if (user == null) { if (user == null) {
return PostSaveResponse(401, "Authentication required", null) return PostSaveResponse(401, "Authentication required", null)
} }
val incomingPost = PayloadDecoder.decode(rawPayload, Post::class.java, objectMapper) // [핵심] 저장 전 인코딩 수행 (Editor는 Raw JSON을 보내고, 여기서 인코딩해서 저장)
// View에서 safeDecode로 풀어서 보여주게 됨.
val encodedTitle = URLEncoder.encode(rawPost.title ?: "", "UTF-8")
val encodedContent = URLEncoder.encode(rawPost.content ?: "", "UTF-8")
val encodedCategory = URLEncoder.encode(rawPost.category ?: "none", "UTF-8")
val encodedTags = URLEncoder.encode(rawPost.tags ?: "", "UTF-8")
val encodedFirstAddress = URLEncoder.encode(rawPost.firstAddress ?: "", "UTF-8")
val encodedModifyAddress = URLEncoder.encode(rawPost.modifyAddress ?: "", "UTF-8")
// Decode contents val incomingPost = rawPost.copy(
incomingPost.title = URLDecoder.decode(incomingPost.title ?: "", "UTF-8") title = encodedTitle,
incomingPost.content = URLDecoder.decode(incomingPost.content ?: "", "UTF-8") content = encodedContent,
incomingPost.category = URLDecoder.decode(incomingPost.category ?: "none", "UTF-8") category = encodedCategory,
incomingPost.tags = URLDecoder.decode(incomingPost.tags ?: "", "UTF-8") tags = encodedTags,
incomingPost.firstAddress = URLDecoder.decode(incomingPost.firstAddress ?: "", "UTF-8") firstAddress = encodedFirstAddress,
incomingPost.modifyAddress = URLDecoder.decode(incomingPost.modifyAddress ?: "", "UTF-8") modifyAddress = encodedModifyAddress
)
return if (incomingPost.id.isNullOrBlank()) { return if (incomingPost.id.isNullOrBlank()) {
// New Post // --- Create ---
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" } val canWrite = user.authorities.any { it.authority == "ROLE_WRITE" }
if (!isAdmin && !canWrite) { if (!isAdmin && !canWrite) {
return PostSaveResponse(403, "Permission denied to create post", null) return PostSaveResponse(403, "Permission denied", null)
} }
incomingPost.writer = user.username incomingPost.writer = user.username
@ -128,18 +140,17 @@ class PostApiController(
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!)) PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
} else { } else {
// Edit Post // --- Update ---
val originalPost = postManager.findById(incomingPost.id!!).awaitSingleOrNull() val originalPost = postManager.findById(incomingPost.id!!).awaitSingleOrNull()
?: return PostSaveResponse(404, "Original post not found", null) ?: return PostSaveResponse(404, "Original post not found", null)
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
val isWriter = user.username == originalPost.writer val isWriter = user.username == originalPost.writer
if (!isAdmin && !isWriter) { if (!isAdmin && !isWriter) {
return PostSaveResponse(403, "Permission denied to update post", null) return PostSaveResponse(403, "Permission denied", null)
} }
incomingPost.writer = user.username
// Save History // 히스토리 저장
val history = PostHistory( val history = PostHistory(
postId = originalPost.id!!, postId = originalPost.id!!,
content = originalPost.content, content = originalPost.content,
@ -163,7 +174,7 @@ class PostApiController(
) )
postHistoryManager.save(history).awaitSingle() postHistoryManager.save(history).awaitSingle()
// Update Post // 업데이트 객체 생성 (작성자는 원본 유지 또는 갱신)
val updatedPost = originalPost.copy( val updatedPost = originalPost.copy(
title = incomingPost.title, title = incomingPost.title,
content = incomingPost.content, content = incomingPost.content,
@ -171,11 +182,10 @@ class PostApiController(
category = incomingPost.category, category = incomingPost.category,
tags = incomingPost.tags, tags = incomingPost.tags,
modifyTime = System.currentTimeMillis(), modifyTime = System.currentTimeMillis(),
writeTime = incomingPost.writeTime,
modifyAddress = incomingPost.modifyAddress, modifyAddress = incomingPost.modifyAddress,
modifyLat = incomingPost.modifyLat, modifyLat = incomingPost.modifyLat,
modifyLon = incomingPost.modifyLon, modifyLon = incomingPost.modifyLon,
writer = incomingPost.writer, // writer는 변경하지 않음 (필요시 incomingPost.writer 사용)
) )
val savedPost = postManager.save(updatedPost).awaitSingle() val savedPost = postManager.save(updatedPost).awaitSingle()
@ -188,100 +198,121 @@ class PostApiController(
@PathVariable postId: String, @PathVariable postId: String,
@AuthenticationPrincipal user: UserDetails? @AuthenticationPrincipal user: UserDetails?
): ResponseEntity<Map<String, String>> { ): ResponseEntity<Map<String, String>> {
if (user == null) { if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "인증이 필요합니다."))
}
val post = postManager.findById(postId).awaitSingleOrNull() val post = postManager.findById(postId).awaitSingleOrNull()
?: return ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf("message" to "삭제할 게시물을 찾을 수 없습니다.")) ?: return ResponseEntity.notFound().build()
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" } val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
val isWriter = user.username == post.writer val isWriter = user.username == post.writer
if (!isAdmin && !isWriter) { if (!isAdmin && !isWriter) return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(mapOf("message" to "이 게시물을 삭제할 권한이 없습니다."))
}
return try {
postManager.deletePost(postId).awaitFirstOrNull() postManager.deletePost(postId).awaitFirstOrNull()
ResponseEntity.ok(mapOf("message" to "게시물이 성공적으로 삭제되었습니다.")) return ResponseEntity.ok(mapOf("message" to "Deleted"))
} catch (e: Exception) {
logService.log("게시물 삭제 중 오류 발생: ID=$postId, 오류=${e.message}")
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("message" to "삭제 중 서버 오류가 발생했습니다."))
}
} }
@PostMapping("/post/{postId}/block")
@PreAuthorize("hasRole('ADMIN')")
fun blockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
return postManager.blockPost(postId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
@PostMapping("/post/{postId}/unblock") // --- 4. Gibberish (짧은 글) CRUD ---
@PreAuthorize("hasRole('ADMIN')")
fun unblockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
return postManager.unblockPost(postId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
@PostMapping("/posts/{postId}/comments.bjx") @PostMapping("/gibberish")
fun addComment( suspend fun saveGibberish(
@PathVariable postId: String,
@RequestBody rawPayload: String,
@AuthenticationPrincipal user: UserDetails?
): Mono<CommentResponse> {
val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper)
comment.postId = postId
comment.writer = user?.username ?: "Anonymous"
comment.writeTime = System.currentTimeMillis()
return commentService.addComment(comment)
.map { CommentResponse(0, "Success") }
}
@PostMapping("/post/{postId}/like.bjx")
fun likePost(@PathVariable postId: String): Mono<VoteResponse> {
return postManager.incrementVote(postId).map { post ->
VoteResponse(post.voteCount, post.unlikeCount)
}
}
@PostMapping("/post/{postId}/unlike.bjx")
fun unlikePost(@PathVariable postId: String): Mono<VoteResponse> {
return postManager.incrementUnlike(postId).map { post ->
VoteResponse(post.voteCount, post.unlikeCount)
}
}
// --- Special APIs ---
@PostMapping("/gibberish") // 경로가 /gibberish 인데 @RequestMapping("/blog") 아래에 있어서 실제로는 /blog/gibberish 가 됨.
// 기존 코드에서는 /gibberish로 되어 있었으므로, 여기서는 RequestMapping을 오버라이드 해야 할 수도 있습니다.
// 하지만 일관성을 위해 /blog/gibberish로 사용하거나, 아래와 같이 절대 경로로 지정합니다.
fun saveGibberish(
@RequestBody request: GibberishRequest, @RequestBody request: GibberishRequest,
@AuthenticationPrincipal user: UserDetails? @AuthenticationPrincipal user: UserDetails?
): Mono<ResponseEntity<Any>> { ): ResponseEntity<Any> {
if (user == null) { if (user == null) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "로그인이 필요합니다."))) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "Login required"))
} }
if (request.content.isBlank() || request.content.length > 100) { if (request.content.isBlank() || request.content.length > 100) {
return Mono.just(ResponseEntity.badRequest().body(mapOf("message" to "내용은 1자 이상 100자 이하로 작성해야 합니다."))) return ResponseEntity.badRequest().body(mapOf("message" to "Content length must be 1-100"))
} }
// 인코딩 처리
val encodedContent = URLEncoder.encode(request.content, "UTF-8")
return if (!request.id.isNullOrBlank()) {
// --- Update ---
val post = postManager.findById(request.id).awaitSingleOrNull()
?: return ResponseEntity.notFound().build()
if (post.writer != user.username) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(mapOf("message" to "Not authorized"))
}
post.content = encodedContent
post.modifyTime = System.currentTimeMillis()
postManager.save(post).awaitSingle()
ResponseEntity.ok().build()
} else {
// --- Create ---
val newPost = Post( val newPost = Post(
title = URLEncoder.encode(request.content.take(20), "UTF-8"), title = URLEncoder.encode(request.content.take(20), "UTF-8"), // 제목은 내용 앞부분
content = URLEncoder.encode(request.content, "UTF-8"), content = encodedContent,
writer = user.username, writer = user.username,
writeTime = System.currentTimeMillis(), writeTime = System.currentTimeMillis(),
modifyTime = System.currentTimeMillis(), modifyTime = System.currentTimeMillis(),
posting = true, posting = true,
postType = PostType.GIBBERISH.name postType = PostType.GIBBERISH.name
) )
val saved = postManager.save(newPost).awaitSingle()
ResponseEntity.status(HttpStatus.CREATED).body(saved)
}
}
return postManager.save(newPost)
.map { savedPost -> ResponseEntity.status(HttpStatus.CREATED).body(savedPost as Any) } // --- 5. 기타 조회 API (랭킹, 태그 등) ---
@GetMapping("/rankOfViews.bjx")
fun getRankOfViews(): Mono<ResponseEntity<PostListResponse>> {
val auth = SecurityContextHolder.getContext().authentication
val isAnon = auth == null || auth is AnonymousAuthenticationToken
val flux = if (isAnon) postManager.getTop5UniquePublishedByViews() else postManager.getTop5AllVersionsByViews()
return flux.collectList().map { ResponseEntity.ok(PostListResponse(it)) }
}
@GetMapping("/recentOfPost.bjx")
fun getRecentOfPost(): Mono<ResponseEntity<PostListResponse>> {
val auth = SecurityContextHolder.getContext().authentication
val isAnon = auth == null || auth is AnonymousAuthenticationToken
val flux = if (isAnon) postManager.getRecent5UniquePublished() else postManager.getRecent5AllVersions()
return flux.collectList().map { ResponseEntity.ok(PostListResponse(it)) }
}
@GetMapping("/categories.bjx")
fun getCategories(): Mono<TagResponse> {
return postManager.findAllDistinctCategories()
.collectList()
.map { TagResponse(tags = it) }
}
@GetMapping("/hashtags.bjx")
fun getHashtags(): Mono<TagResponse> {
return postManager.findAllDistinctTags()
.collectList()
.map { TagResponse(tags = it) }
}
// --- 6. 좋아요/싫어요 및 관리자 기능 ---
@PostMapping("/post/{postId}/like.bjx")
fun likePost(@PathVariable postId: String): Mono<VoteResponse> {
return postManager.incrementVote(postId).map { VoteResponse(it.voteCount, it.unlikeCount) }
}
@PostMapping("/post/{postId}/unlike.bjx")
fun unlikePost(@PathVariable postId: String): Mono<VoteResponse> {
return postManager.incrementUnlike(postId).map { VoteResponse(it.voteCount, it.unlikeCount) }
}
@PostMapping("/post/{postId}/block")
@PreAuthorize("hasRole('ADMIN')")
fun blockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
return postManager.blockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build())
}
@PostMapping("/post/{postId}/unblock")
@PreAuthorize("hasRole('ADMIN')")
fun unblockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
return postManager.unblockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build())
} }
} }

View File

@ -5,6 +5,7 @@ import kotlinx.coroutines.reactive.awaitSingle
import kr.lunaticbum.back.lun.model.BookmarkImage import kr.lunaticbum.back.lun.model.BookmarkImage
import kr.lunaticbum.back.lun.model.Comment import kr.lunaticbum.back.lun.model.Comment
import kr.lunaticbum.back.lun.model.CommentResponse import kr.lunaticbum.back.lun.model.CommentResponse
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.ImageMetaService import kr.lunaticbum.back.lun.model.ImageMetaService
import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.model.VoteResponse import kr.lunaticbum.back.lun.model.VoteResponse
@ -83,7 +84,8 @@ class BookmarkController(
@GetMapping("/{bookmarkId}/comments") @GetMapping("/{bookmarkId}/comments")
@ResponseBody @ResponseBody
fun getComments(@PathVariable bookmarkId: String): Mono<CommentResponse> { fun getComments(@PathVariable bookmarkId: String): Mono<CommentResponse> {
return commentService.getCommentsForPost(bookmarkId) // [수정] getComments(targetId, type) 호출
return commentService.getComments(bookmarkId, ContentType.BOOKMARK)
.collectList() .collectList()
.map { comments -> CommentResponse(0, "Success", comments) } .map { comments -> CommentResponse(0, "Success", comments) }
} }
@ -95,12 +97,19 @@ class BookmarkController(
@RequestBody rawPayload: String, @RequestBody rawPayload: String,
@AuthenticationPrincipal user: UserDetails? @AuthenticationPrincipal user: UserDetails?
): Mono<CommentResponse> { ): Mono<CommentResponse> {
// [수정] Comment 객체 생성 시 변경된 필드(targetId, targetType) 사용
val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper) val comment = PayloadDecoder.decode(rawPayload, Comment::class.java, objectMapper)
comment.postId = bookmarkId
comment.writer = user?.username ?: "Anonymous"
comment.writeTime = System.currentTimeMillis()
return commentService.addComment(comment) // 기존 postId 필드가 삭제되었으므로 targetId/targetType 설정
// PayloadDecoder가 targetId 등을 채워주지 못할 경우를 대비해 수동 설정
val newComment = comment.copy(
targetId = bookmarkId,
targetType = ContentType.BOOKMARK,
writer = user?.username ?: "Anonymous",
writeTime = System.currentTimeMillis()
)
return commentService.addComment(newComment)
.map { CommentResponse(0, "Success") } .map { CommentResponse(0, "Success") }
} }
} }

View File

@ -5,10 +5,11 @@ import com.google.gson.Gson
import com.google.gson.JsonParser import com.google.gson.JsonParser
import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponse
import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactive.awaitSingleOrNull
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.* import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.model.dto.FeedResponse import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.FeedResponse
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
import kr.lunaticbum.back.lun.service.FeedService import kr.lunaticbum.back.lun.service.FeedService
import kr.lunaticbum.back.lun.service.PostManager import kr.lunaticbum.back.lun.service.PostManager
import kr.lunaticbum.back.lun.services.ImageService import kr.lunaticbum.back.lun.services.ImageService
@ -36,26 +37,47 @@ class PostViewController(
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val logService: LogService, private val logService: LogService,
private val feedService: FeedService, private val feedService: FeedService,
private val bookmarkRepository: WebBookmarkRepository, // [추가] 북마크 조회를 위해 필요
private val imageService: ImageService // [주입 추가] private val imageService: ImageService // [주입 추가]
) { ) {
@Value("\${image.upload.path}") @Value("\${image.upload.path}")
private val uploadPath: String? = null private val uploadPath: String? = null
private fun safeDecode(value: String?): String {
if (value.isNullOrBlank()) return ""
return try {
// URL 인코딩된 문자열이면 디코딩, 아니거나 에러나면 원본 반환
URLDecoder.decode(value, "UTF-8")
} catch (e: Exception) {
value
}
}
// --- Helper Methods (View 전용) --- // --- Helper Methods (View 전용) ---
private fun processPostForView(post: Post): Post { private fun processPostForView(post: Post): Post {
post.title = post.title ?: "" // [수정] 모든 텍스트 필드에 safeDecode 적용
post.content = post.content ?: "" post.title = safeDecode(post.title)
post.tags = post.tags ?: ""
post.category = if (post.category.isNullOrBlank()) "none" else post.category // Gibberish는 내용도 인코딩되어 있으므로 필수 적용
post.firstAddress = post.firstAddress ?: "" post.content = safeDecode(post.content)
post.modifyAddress = post.modifyAddress ?: ""
post.tags = safeDecode(post.tags)
post.category = safeDecode(post.category).ifBlank { "none" }
post.firstAddress = safeDecode(post.firstAddress)
post.modifyAddress = safeDecode(post.modifyAddress)
if (post.title!!.isBlank()) { if (post.title!!.isBlank()) {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm") val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]" post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
} }
var firstImgSrc: String? = null 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" val defaultThumb = "/images/pic01.jpg"
try { try {
@ -137,44 +159,38 @@ class PostViewController(
@GetMapping("/", "/home.bs") @GetMapping("/", "/home.bs")
suspend fun home( suspend fun home(
@RequestParam(required = false) q: String?, request: jakarta.servlet.http.HttpServletRequest,
request: jakarta.servlet.http.HttpServletRequest): ResultMV { @RequestParam(required = false) q: String?, // 검색어
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
visitorLogService.recordVisit(request).subscribe() visitorLogService.recordVisit(request).subscribe()
val vm = ResultMV("content/home") val vm = ResultMV("content/home")
// 배너 로직 (기존 유지)
val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner" val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg?type=banner"
val randomImage = imageMetaService.getRandomBannerImage().awaitSingleOrNull()
val bannerPath = randomImage?.path?.let {
if (it.contains("/blog/post/images/")) it.replace("/blog/post/images/", "/api/images/") + "?type=banner"
else it + "?type=banner"
} ?: defaultBannerImage
vm.modelMap["randomBannerImage"] = bannerPath
try { // [변경] FeedService를 통해 통합 피드 데이터 조회
var bannerImagePath: String? = null val username = userDetails?.username
val randomImage: ImageMeta? = imageMetaService.getRandomBannerImage().awaitSingleOrNull() // 파라미터: (cursor=null, size=10, keyword=q, username)
val feedData = feedService.getGlobalFeed(null, 10, q, username).awaitSingle()
if (randomImage != null && !randomImage.path.isNullOrBlank()) { vm.modelMap["feedItems"] = feedData.items
if (randomImage.path.contains("/blog/post/images/")) { vm.modelMap["nextCursor"] = feedData.nextCursor
bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/") +"?type=banner" vm.modelMap["searchQuery"] = q
} else {
bannerImagePath = randomImage.path +"?type=banner"
}
}
vm.modelMap["randomBannerImage"] = if (!bannerImagePath.isNullOrBlank()) bannerImagePath else defaultBannerImage
// Gibberish 작성 폼용 (랜덤 문구는 이제 필요 없으면 제거 가능)
val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull() val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull()
if (randomGibberish != null) { if (randomGibberish != null) {
vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8") vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8")
vm.modelMap["gibberishId"] = randomGibberish.id vm.modelMap["gibberishId"] = randomGibberish.id
} }
val feedData = feedService.getGlobalFeed(null, 10, q).awaitSingle()
vm.modelMap["feedItems"] = feedData.items
vm.modelMap["nextCursor"] = feedData.nextCursor // HTML에 hidden으로 숨겨둠
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) {
ex.printStackTrace()
logService.log("Error loading home page: ${ex.message}")
}
return vm return vm
} }
@ -230,29 +246,116 @@ class PostViewController(
return vm return vm
} }
@GetMapping("/blog/viewer/{postId}") // --- [핵심 수정] 뷰어 (북마크 포함) ---
// [핵심 수정] 뷰어 메서드
@GetMapping("/blog/viewer/{id}")
suspend fun postViewer( suspend fun postViewer(
@PathVariable postId: String, @PathVariable("id") id: String,
@AuthenticationPrincipal userDetails: UserDetails? @AuthenticationPrincipal userDetails: UserDetails?
): ResultMV { ): ResultMV {
val vm = ResultMV("content/viewer") val vm = ResultMV("content/viewer")
try { var viewerDto: PostViewerDto? = null
val post = postManager.getPost(postId).awaitSingleOrNull()
?: return ResultMV("redirect:/blog/posts")
// 1. Post 조회
try {
val post = postManager.getPost(id).awaitSingleOrNull()
if (post != null) {
val processed = processPostForView(post)
val isWriter = userDetails?.username == post.writer val isWriter = userDetails?.username == post.writer
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
if (!post.posting && !isWriter && !isAdmin) { if (!post.posting && !isWriter && !isAdmin) return ResultMV("redirect:/")
logService.log("Access denied for user ${userDetails?.username} to post ${post.id}")
return ResultMV("redirect:/blog/posts") viewerDto = post.writer?.let {
PostViewerDto(
id = post.id!!,
type = if(post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST,
title = processed.title ?: "",
content = processed.content ?: "" ,
writer = it,
writeTime = post.writeTime,
modifyTime = post.modifyTime,
tags = post.tags?.split(",")?.map{it.trim()}?.filter{it.isNotBlank()} ?: emptyList(),
category = processed.category,
originId = null, // 필요시 post.originId 매핑
// --- 템플릿 필드명 매핑 (Dto = Post) ---
posting = post.posting,
readCount = post.readCount, // views -> readCount
voteCount = post.voteCount, // likes -> voteCount (없으면 0)
unlikeCount = post.unlikeCount,
// 위치 정보 이름 변환 (Dto = Post)
firstPostLat = post.firstPostLat,
firstPostLon = post.firstPostLon, // Lng -> Lon
firstAddress = post.firstAddress,
modifyLat = post.modifyLat, // modifyPostLat -> modifyLat
modifyLon = post.modifyLon, // modifyPostLng -> modifyLon
modifyAddress = post.modifyAddress,
images = emptyList(),
thumb = post.thumb,
isOwner = isWriter,
isAdmin = isAdmin
)
} }
val processedPost = processPostForView(post)
vm.modelMap["srcPost"] = processedPost
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(processedPost)
} catch (e: Exception) {
return ResultMV("redirect:/")
} }
} catch (e: Exception) { }
// 2. Bookmark 조회
if (viewerDto == null) {
val bookmark = bookmarkRepository.findById(id).awaitSingleOrNull()
if (bookmark != null) {
val isWriter = userDetails?.username == bookmark.userId
if (bookmark.visibility != "PUBLIC" && !isWriter) return ResultMV("redirect:/")
val allImages = (listOfNotNull(bookmark.displayImageUrl) + (bookmark.images.map { it.url } ?: emptyList())).distinct()
viewerDto = (bookmark.title ?: bookmark.url)?.let {
PostViewerDto(
id = bookmark.id!!,
type = ContentType.BOOKMARK,
title = it,
content = bookmark.userComment ?: bookmark.description ?: "",
writer = bookmark.userId,
writeTime = bookmark.savedAt,
modifyTime = bookmark.savedAt,
tags = bookmark.tags ?: emptyList(),
category = "Bookmark",
originId = null,
// --- 템플릿 필드명 매핑 (북마크 기본값) ---
posting = (bookmark.visibility == "PUBLIC"),
readCount = 0,
voteCount = bookmark.voteCount,
unlikeCount = bookmark.unlikeCount,
firstPostLat = 0.0,
firstPostLon = 0.0,
firstAddress = null,
modifyLat = 0.0,
modifyLon = 0.0,
modifyAddress = null,
images = allImages,
originalUrl = bookmark.url,
thumb = bookmark.displayImageUrl,
isOwner = isWriter,
isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
)
}
}
}
if (viewerDto == null) return ResultMV("redirect:/")
vm.modelMap["srcPost"] = viewerDto
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(viewerDto)
vm.modelMap["targetType"] = viewerDto.type
vm.modelMap["isOwner"] = viewerDto.isOwner
vm.modelMap["isAdmin"] = viewerDto.isAdmin
vm.modelMap["originalUrl"] = viewerDto.originalUrl
// vm.modelMap["apiBaseUrl"] = apiBaseUrl
return vm return vm
} }

View File

@ -9,4 +9,4 @@ data class PostIdData(val postId: String)
data class VoteResponse(val voteCount: Long, val unlikeCount: Long) data class VoteResponse(val voteCount: Long, val unlikeCount: Long)
data class ImageUploadResponse(val resultCode: Int, val resultMsg: String, val fileName: String? = null) data class ImageUploadResponse(val resultCode: Int, val resultMsg: String, val fileName: String? = null)
data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List<String>) data class TagResponse(val resultCode: Int = 0, val resultMsg: String = "OK", val tags: List<String>)
data class GibberishRequest(val content: String) data class GibberishRequest(val id: String? = null,val content: String)

View File

@ -0,0 +1,23 @@
package kr.lunaticbum.back.lun.model
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
@Document(collection = "comments")
data class Comment(
@Id val id: String? = null,
// [핵심 변경] postId -> targetId, targetType 추가
val targetId: String, // 댓글이 달린 원본 글/북마크의 ID
val targetType: ContentType, // POST, GIBBERISH, BOOKMARK
val writer: String, // 작성자
val content: String, // 내용
val writeTime: Long = System.currentTimeMillis(),
// 대댓글 기능을 위한 부모 댓글 ID (옵션)
val parentId: String? = null,
// 삭제 여부 (Soft Delete)
val isDeleted: Boolean = false
)

View File

@ -0,0 +1,77 @@
package kr.lunaticbum.back.lun.model
enum class ContentType { POST, GIBBERISH, BOOKMARK }
//interface FeedItem {
// val id: String?
// val type: ContentType
// val title: String?
// val content: String? // 요약 내용 또는 본문
// val thumbnail: String? // 대표 이미지
// val createdAt: Long // 정렬 기준 시간
// val writer: String?
// val url: String // 클릭 시 이동할 경로
//}
// 피드 아이템 통합 DTO
data class FeedItemDto(
val id: String?, // 원본 게시물/북마크 ID
val type: ContentType, // 콘텐츠 타입 (POST, GIBBERISH, BOOKMARK)
val title: String?, // 제목 (Gibberish는 없을 수 있음)
val content: String?, // 내용 요약 또는 전체 (HTML 제거된 텍스트 권장)
val thumbnail: String?, // 썸네일 이미지 URL
val createdAt: Long, // 작성일/저장일 (정렬 기준)
val writer: String?, // 작성자
val url: String, // 클릭 시 이동할 주소 (내부 글보기 또는 외부 링크)
// 필요하다면 추가할 필드들
val voteCount: Long = 0, // 좋아요 수
val commentCount: Int = 0, // 댓글 수 (나중에 추가 가능)
val category: String? = null,
val tags: List<String> = emptyList(),
val isOwner: Boolean = false
)
// [최종 수정] 템플릿 오류 방지를 위한 '만능' DTO
// [최종] 템플릿(includes.html) 호환성 100% 보장 DTO
data class PostViewerDto(
val id: String,
val type: ContentType, // POST, BOOKMARK, GIBBERISH
val title: String,
val content: String,
val writer: String,
val writeTime: Long,
val modifyTime: Long = 0, // 템플릿 요구
val tags: List<String>,
val category: String? = null,
val originId: String? = null,
// --- 템플릿이 요구하는 통계/상태 필드명 ---
val posting: Boolean = true, // 공개 여부
val readCount: Long = 0, // views -> readCount
val voteCount: Long = 0, // likes -> voteCount
val unlikeCount: Long = 0, // 싫어요
// --- 템플릿이 요구하는 위치/주소 필드명 (정확히 일치해야 함) ---
val firstPostLat: Double? = 0.0,
val firstPostLon: Double? = 0.0, // Lng -> Lon
val firstAddress: String? = null,
val modifyLat: Double? = 0.0, // modifyPostLat -> modifyLat
val modifyLon: Double? = 0.0, // modifyPostLng -> modifyLon
val modifyAddress: String? = null,
// --- 뷰어 전용 추가 필드 ---
val images: List<String> = emptyList(),
val originalUrl: String? = null,
val thumb: String? = null,
val isOwner: Boolean = false,
val isAdmin: Boolean = false
)
data class FeedResponse(
val items: List<FeedItemDto>,
val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
)

View File

@ -124,17 +124,6 @@ data class Post(
var postType: String = PostType.STANDARD.name var postType: String = PostType.STANDARD.name
) )
@Document(collection = "Comment")
data class Comment (
@BsonId
var id: String? = null,
var postId: String? = null , // 댓글이 달린 포스트의 id
var parentId: String? = null ,// 대댓글이면 상위 댓글의 id, 최상위 댓글이면 null
var writer: String? = null,
var content: String? = null,
var writeTime: Long? = null,
var mentions: List<String>? = null // 언급된 유저 아이디(선택)
)
@Data @Data
@NoArgsConstructor @NoArgsConstructor

View File

@ -1,36 +0,0 @@
package kr.lunaticbum.back.lun.model.dto
enum class ContentType { POST, GIBBERISH, BOOKMARK }
//interface FeedItem {
// val id: String?
// val type: ContentType
// val title: String?
// val content: String? // 요약 내용 또는 본문
// val thumbnail: String? // 대표 이미지
// val createdAt: Long // 정렬 기준 시간
// val writer: String?
// val url: String // 클릭 시 이동할 경로
//}
// 피드 아이템 통합 DTO
data class FeedItemDto(
val id: String?, // 원본 게시물/북마크 ID
val type: ContentType, // 콘텐츠 타입 (POST, GIBBERISH, BOOKMARK)
val title: String?, // 제목 (Gibberish는 없을 수 있음)
val content: String?, // 내용 요약 또는 전체 (HTML 제거된 텍스트 권장)
val thumbnail: String?, // 썸네일 이미지 URL
val createdAt: Long, // 작성일/저장일 (정렬 기준)
val writer: String?, // 작성자
val url: String, // 클릭 시 이동할 주소 (내부 글보기 또는 외부 링크)
// 필요하다면 추가할 필드들
val voteCount: Long = 0, // 좋아요 수
val commentCount: Int = 0, // 댓글 수 (나중에 추가 가능)
val tags: List<String>? = null // 태그 목록
)
data class FeedResponse(
val items: List<FeedItemDto>,
val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
)

View File

@ -1,15 +1,19 @@
package kr.lunaticbum.back.lun.repository package kr.lunaticbum.back.lun.repository
import kr.lunaticbum.back.lun.model.Comment import kr.lunaticbum.back.lun.model.Comment
import kr.lunaticbum.back.lun.model.ContentType
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
@Repository
interface CommentRepository : ReactiveMongoRepository<Comment, String> { interface CommentRepository : ReactiveMongoRepository<Comment, String> {
fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux<Comment> // 최상위 댓글 // 특정 타겟(글/북마크)의 댓글 조회
fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux<Comment> fun findByTargetIdAndTargetTypeOrderByWriteTimeAsc(targetId: String, targetType: ContentType): Flux<Comment>
fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux<Comment> // [신규 추가]
}
// 댓글 수 카운트
fun countByTargetIdAndTargetType(targetId: String, targetType: ContentType): Mono<Long>
// [추가] 작성자별 댓글 조회 (UserController 내 정보 페이지용)
fun findByWriter(writer: String, pageable: Pageable): Flux<Comment>
}

View File

@ -12,27 +12,15 @@ import reactor.core.publisher.Mono
@Repository @Repository
interface PostRepository : ReactiveMongoRepository<Post, String> { interface PostRepository : ReactiveMongoRepository<Post, String> {
@Query("{ " + // [핵심] 커서 페이징을 위한 쿼리
" '\$and': [ " + // 작성일(writeTime)이 기준값보다 작고(과거), 공개(posting=true)된 글만 조회
" { 'posting': true, 'isBlocked': false, 'modifyTime': { '\$lt': ?1 } }, " + // 기본 필터 (공개여부, 커서) // 포스트(POST)와 짧은글(GIBBERISH) 모두 posting=true라면 가져옵니다.
" { '\$or': [ " + @Query("{ 'writeTime': { \$lt: ?0 }, 'posting': true }")
" { 'title': { '\$regex': ?0, '\$options': 'i' } }, " + fun findFeedPostsBefore(time: Long, pageable: Pageable): Flux<Post>
" { '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 } }", @Query("{ \$or: [ { 'title': { \$regex: ?0, \$options: 'i' } }, { 'content': { \$regex: ?0, \$options: 'i' } }, { 'tags': { \$regex: ?0, \$options: 'i' } } ], 'writeTime': { \$lt: ?1 }, 'posting': true }")
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", fun searchPosts(keyword: String, time: Long, pageable: Pageable): Flux<Post>
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
// [핵심] GIBBERISH 포함, 공개글, 차단안됨, 그리고 입력받은 시간(?0)보다 과거의 글만 조회
"{ \$match: { posting: true, isBlocked: false, modifyTime: { \$lt: ?0 } } }",
"{ \$sort: { \"modifyTime\": -1 } }"
])
fun findFeedPostsBefore(timestamp: Long, pageable: Pageable): Flux<Post>
fun findAllByModifyTime(time : Long? = 0): Flux<Post> fun findAllByModifyTime(time : Long? = 0): Flux<Post>

View File

@ -1,38 +1,52 @@
package kr.lunaticbum.back.lun.service package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.Comment import kr.lunaticbum.back.lun.model.Comment
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.repository.CommentRepository import kr.lunaticbum.back.lun.repository.CommentRepository
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.net.URLDecoder
@Service @Service
class CommentService(private val commentRepository: CommentRepository) { class CommentService(
private val commentRepository: CommentRepository
/** ) {
* [수정] 댓글의 content를 URL 디코딩하는 로직을 추가합니다. // 특정 글/북마크의 댓글 조회
*/ fun getComments(targetId: String, type: ContentType): Flux<Comment> {
private fun decodeCommentContent(comment: Comment): Comment { return commentRepository.findByTargetIdAndTargetTypeOrderByWriteTimeAsc(targetId, type)
comment.content = comment.content?.let { URLDecoder.decode(it, "UTF-8") } .filter { !it.isDeleted }
return comment
} }
fun getRepliesForComment(parentId: String): Flux<Comment> { // [호환성 유지] 기존 코드(getRepliesForComment 등)가 있다면 여기에 구현
return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 fun getRepliesForComment(commentId: String): Flux<Comment> {
// 대댓글 기능이 아직 구현되지 않았다면 빈 Flux 반환
return Flux.empty()
} }
// 댓글 작성 (개별 인자)
fun addComment(targetId: String, type: ContentType, writer: String, content: String): Mono<Comment> {
val comment = Comment(
targetId = targetId,
targetType = type,
writer = writer,
content = content
)
return commentRepository.save(comment)
}
// 댓글 작성 (객체 인자) - BookmarkController 등에서 사용
fun addComment(comment: Comment): Mono<Comment> { fun addComment(comment: Comment): Mono<Comment> {
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리 return commentRepository.save(comment)
return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
} }
fun getCommentsForPost(postId: String): Flux<Comment> { // 댓글 수 조회
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 fun getCommentCount(targetId: String, type: ContentType): Mono<Long> {
return commentRepository.countByTargetIdAndTargetType(targetId, type)
} }
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가 // [추가] 작성자별 댓글 조회 (UserController용)
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> {
return commentRepository.findByWriter(writer, pageable)
} }
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
} }

View File

@ -1,26 +1,76 @@
package kr.lunaticbum.back.lun.service package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.dto.ContentType import com.fasterxml.jackson.databind.ObjectMapper
import kr.lunaticbum.back.lun.model.dto.FeedItemDto import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.dto.FeedResponse import kr.lunaticbum.back.lun.model.FeedItemDto
import kr.lunaticbum.back.lun.model.FeedResponse
import kr.lunaticbum.back.lun.repository.PostRepository import kr.lunaticbum.back.lun.repository.PostRepository
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.net.URLDecoder
// service/FeedService.kt // service/FeedService.kt
@Service @Service
class FeedService( class FeedService(
private val postRepository: PostRepository, private val postRepository: PostRepository,
private val bookmarkRepository: WebBookmarkRepository private val bookmarkRepository: WebBookmarkRepository,
private val objectMapper: ObjectMapper // [추가] JSON 파싱을 위해 주입
) { ) {
// [신규] 안전 디코딩 헬퍼 함수
private fun safeDecode(value: String?): String {
if (value.isNullOrBlank()) return ""
return try {
URLDecoder.decode(value, "UTF-8")
} catch (e: Exception) {
value
}
}
// [헬퍼 함수] 콤마로 구분된 태그 문자열을 리스트로 변환
private fun parseTags(tagsStr: String?): List<String> {
return safeDecode(tagsStr)
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
}
// [신규] Quill JSON Delta 또는 HTML에서 순수 텍스트만 추출하는 헬퍼 함수
private fun extractPlainText(content: String?): String {
if (content.isNullOrBlank()) return ""
return try {
// 1. JSON (Quill Delta) 형식 시도
if (content.trim().startsWith("{") || content.trim().startsWith("[")) {
val root = objectMapper.readTree(content)
val sb = StringBuilder()
// ops 배열을 순회하며 text 추출
val ops = if (root.has("ops")) root.get("ops") else if (root.isArray) root else null
if (ops != null && ops.isArray) {
for (op in ops) {
if (op.has("insert")) {
val insert = op.get("insert")
if (insert.isTextual) {
sb.append(insert.asText())
}
}
}
return sb.toString()
}
}
// 2. JSON이 아니거나 실패 시 HTML 태그 제거 (Legacy 데이터)
content.replace(Regex("<.*?>"), "")
} catch (e: Exception) {
// 파싱 오류 시 원본에서 태그만 제거해서 반환
content.replace(Regex("<.*?>"), "")
}
}
// 기존 메서드 시그니처 변경: keyword: String? 추가 // 기존 메서드 시그니처 변경: keyword: String? 추가
fun getGlobalFeed(cursorTime: Long?, size: Int, keyword: String? = null): Mono<FeedResponse> { fun getGlobalFeed(cursorTime: Long?, size: Int, keyword: String? = null, currentUsername: String? = null): Mono<FeedResponse> {
val lastTime = cursorTime ?: System.currentTimeMillis() val lastTime = cursorTime ?: System.currentTimeMillis()
val pageable = PageRequest.of(0, size) val pageable = PageRequest.of(0, size)
@ -31,17 +81,31 @@ class FeedService(
postRepository.findFeedPostsBefore(lastTime, pageable) postRepository.findFeedPostsBefore(lastTime, pageable)
}.map { post -> }.map { post ->
val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST
val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거
// [수정] 본문 처리 로직 개선
val decodedRaw = safeDecode(post.content)
// GIBBERISH는 그대로, POST는 JSON을 텍스트로 변환하여 미리보기 생성
val displayContent = if (type == ContentType.GIBBERISH) {
decodedRaw
} else {
extractPlainText(decodedRaw)
}
val decodedTitle = safeDecode(post.title)
val isMyPost = currentUsername != null && post.writer == currentUsername
FeedItemDto( FeedItemDto(
id = post.id, id = post.id,
type = type, type = type,
title = post.title, title = decodedTitle, // [적용] 디코딩된 제목
content = if (type == ContentType.GIBBERISH) post.content else rawContent, content = displayContent,
thumbnail = post.thumb, thumbnail = post.thumb,
createdAt = post.modifyTime, createdAt = post.writeTime,
writer = post.writer, writer = post.writer,
url = "/blog/viewer/${post.id}" url = "/blog/viewer/${post.id}",
// [신규] 카테고리 및 태그 매핑
category = if (type == ContentType.POST) safeDecode(post.category) else null,
tags = parseTags(post.tags),
isOwner = isMyPost
) )
} }

View File

@ -0,0 +1,57 @@
package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.Post
import kr.lunaticbum.back.lun.model.WebBookmark
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import java.util.Comparator
// DTO 정의
data class TagCount(val tag: String, val count: Int)
@Service
class GlobalTagService(
private val mongoTemplate: ReactiveMongoTemplate
) {
// 포스트와 북마크 양쪽에서 태그를 긁어와 합침
fun getAllTagsWithCount(): Flux<TagCount> {
// [수정 1] 함수 호출 시 인자를 1개만 전달하도록 변경 ("tags" 제거)
val postTags = aggregateTags(Post::class.java)
val bookmarkTags = aggregateTags(WebBookmark::class.java)
return Flux.merge(postTags, bookmarkTags)
.groupBy { it.tag }
.flatMap { group -> // [수정 3] 타입 추론을 위해 람다 내부 명확화
group.reduce { t1, t2 -> TagCount(t1.tag, t1.count + t2.count) }
}
.sort(Comparator.comparingInt(TagCount::count).reversed())
}
// 컬렉션에서 태그를 분리하고 카운트하는 공통 로직
private fun <T> aggregateTags(entityClass: Class<T>): Flux<TagCount> {
return mongoTemplate.findAll(entityClass)
.flatMapIterable { entity ->
// [수정 2] 엔티티 타입별로 태그 추출 로직을 명확히 분리하여 `split` 오류 해결
val tagsList: List<String> = when (entity) {
is Post -> {
// Post.tags는 String? 타입 -> 콤마로 분리
entity.tags?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList()
}
is WebBookmark -> {
// [수정] WebBookmark.tags는 List<String>? 타입 -> 바로 사용
entity.tags ?: emptyList()
}
else -> emptyList()
}
tagsList
}
.groupBy { it }
.flatMap { group ->
// [수정 3] Key null safety 처리
group.count().map { count ->
TagCount(group.key() ?: "unknown", count.toInt())
}
}
}
}

View File

@ -0,0 +1,125 @@
package kr.lunaticbum.back.lun.utils
import kr.lunaticbum.back.lun.model.ContentType
import kr.lunaticbum.back.lun.model.FeedItemDto
import org.springframework.stereotype.Component
import java.text.SimpleDateFormat
import java.util.*
@Component("thymeleafUtils") // [중요] 이 이름으로 빈이 등록되어야 HTML에서 호출 가능
class ThymeleafUtils {
fun renderFeedItem(item: FeedItemDto, apiBaseUrl: String): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd")
val dateStr = dateFormat.format(Date(item.createdAt))
val sb = StringBuilder()
// --- 공통: 태그/카테고리 HTML 생성 ---
val metaHtml = StringBuilder()
if (item.type == ContentType.POST || item.type == ContentType.BOOKMARK) {
metaHtml.append("<div class=\"meta-tags\">")
// 카테고리
if (!item.category.isNullOrBlank() && item.category != "none") {
val catUrl = if (item.type == ContentType.POST) "/blog/posts?category=${item.category}" else "javascript:void(0)"
val catStyle = if (item.type == ContentType.BOOKMARK) "background:#3498db;" else ""
metaHtml.append("<a href=\"$catUrl\" class=\"category-badge\" style=\"$catStyle\" onclick=\"event.stopPropagation()\">${item.category}</a>")
}
// 태그
item.tags.forEach { tag ->
metaHtml.append("<a href=\"/blog/posts?tag=$tag\" class=\"tag-link\" onclick=\"event.stopPropagation()\">#$tag</a>")
}
metaHtml.append("</div>")
}
// --- 수정/삭제 버튼 (본인 글일 경우) ---
val editBtnHtml = if (item.isOwner && item.type == ContentType.GIBBERISH) {
"""
<div style="position: absolute; top: 10px; right: 10px; z-index: 10;">
<button type="button" class="button small" style="padding:0 0.5em;height:2em;line-height:2em;background:#fbc02d;color:#333;"
onclick="editGibberish('${item.id}', '${escapeJs(item.content ?: "")}')"><i class="icon solid fa-pen"></i></button>
<button type="button" class="button small" style="padding:0 0.5em;height:2em;line-height:2em;background:#e44c65;color:white;"
onclick="deletePost('${item.id}')"><i class="icon solid fa-trash"></i></button>
</div>
""".trimIndent()
} else ""
// --- 타입별 HTML 생성 ---
when (item.type) {
ContentType.POST -> {
val thumbSrc = if (!item.thumbnail.isNullOrBlank()) "$apiBaseUrl${item.thumbnail}" else "/images/pic01.jpg"
// 이미지 에러 핸들러 (JS)
val imgErr = "this.onerror=null; this.src='/images/pic01.jpg'; setTimeout(function(){ if(window.\$grid) window.\$grid.layout(); }, 200);"
sb.append("""
<div class="box post" onclick="location.href='${item.url}'" style="cursor:pointer; position:relative;">
$editBtnHtml
<span class="image left">
<img src="$thumbSrc" onerror="$imgErr" alt="thumbnail" />
</span>
<div class="inner">
<h3>${item.title ?: "Untitled"}</h3>
<p style="font-size:0.9em;color:#555;">$dateStr by ${item.writer}</p>
<p>${truncate(item.content, 100)}</p>
$metaHtml
</div>
</div>
""".trimIndent())
}
ContentType.GIBBERISH -> {
sb.append("""
<div class="box post" onclick="location.href='${item.url}'" style="cursor:pointer; background-color:#fff9c4; border-left:5px solid #fbc02d; position:relative;">
$editBtnHtml
<div class="inner">
<blockquote><i class="icon fa-quote-left" style="color:#fbc02d;"></i>
<span style="font-weight:bold;color:#333;">${item.content}</span></blockquote>
<p style="text-align:right;font-size:0.8em;color:#777;">$dateStr</p>
</div>
</div>
""".trimIndent())
}
ContentType.BOOKMARK -> {
val thumbHtml = if (!item.thumbnail.isNullOrBlank()) {
val imgErr = "this.onerror=null; this.src='/images/pic01.jpg'; setTimeout(function(){ if(window.\$grid) window.\$grid.layout(); }, 200);"
"""<div style="margin-bottom:10px;"><img src="$apiBaseUrl${item.thumbnail}" style="width:100%; height:150px; object-fit:cover; border-radius:4px;" onerror="$imgErr"></div>"""
} else ""
// [수정] 뷰어 URL 생성
val viewerUrl = "/blog/viewer/${item.id}"
sb.append("""
<div class="box post" onclick="location.href='$viewerUrl'" style="border:1px dashed #3498db; position:relative; cursor:pointer;">
$editBtnHtml
<div class="inner">
$thumbHtml
<h4>
<a href="${item.url}" target="_blank" style="color:#3498db;" onclick="event.stopPropagation()">
<i class="icon solid fa-bookmark"></i> ${item.title} <i class="icon solid fa-external-link-alt" style="font-size:0.7em;"></i>
</a>
</h4>
<p style="font-size:0.9em;">${truncate(item.content, 80)}</p>
$metaHtml
</div>
</div>
""".trimIndent())
}
}
return sb.toString()
}
// 문자열 자르기 헬퍼
private fun truncate(str: String?, len: Int): String {
if (str == null) return ""
return if (str.length > len) str.substring(0, len) + "..." else str
}
// JS 문자열 이스케이프
private fun escapeJs(str: String): String {
return str.replace("'", "\\'").replace("\"", "\\\"").replace("\n", " ")
}
}

View File

@ -1,145 +1,212 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html xmlns:th="http://www.thymeleaf.org"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}" layout:decorate="~{layout/default_layout}">
>
<th:block layout:fragment="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" />
<style>
/* 에디터 전용 스타일 */
.editor-container { background: #fff; padding: 2em; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
.input-group { margin-bottom: 1.5em; }
.input-group label { display: block; font-weight: bold; margin-bottom: 0.5em; color: #555; }
.input-group input[type="text"], .input-group select { width: 100%; padding: 0.75em; border: 1px solid #ddd; border-radius: 4px; background: #f9f9f9; }
.input-group input:focus, .input-group select:focus { border-color: #e44c65; background: #fff; outline: none; }
#editor { height: 500px; background: #fff; font-size: 1.1em; }
.ql-toolbar { background: #f4f4f4; border-color: #ddd !important; border-top-left-radius: 4px; border-top-right-radius: 4px; }
.ql-container { border-color: #ddd !important; border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; }
/* 버튼 영역 */
.action-buttons { margin-top: 2em; text-align: right; }
.action-buttons button { margin-left: 0.5em; }
</style>
</th:block> </th:block>
<body>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<section class="wrapper style1">
<section class="wrapper style2" >
<th:block sec:authorize="isAuthenticated()">
<div class="container"> <div class="container">
<header class="major"> <header class="major">
<h2 th:text="${pageTitle}">글 작성/수정</h2> <h2 th:text="${pageTitle}">글 작성</h2>
<h3>
<label for="title_field">
<input id="title_field" th:value="${srcPost.title}" style="width:100%; max-width:100%; box-sizing:border-box; border:none; outline:none; text-align:center;">
</label>
</h3>
<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> </header>
<div style="text-align: right; margin-bottom: 2em; display: flex; align-items: center; justify-content: flex-end;">
<span style="font-weight: bold; color: #555; margin-right: 10px;">게시물 공개</span> <div class="editor-container">
<input type="checkbox" id="post-published-switch" name="posting" th:checked="${srcPost.posting}" class="custom-checkbox" /> <div class="input-group">
<label for="post-published-switch" class="custom-label"></label> <label for="post-title">제목</label>
<input type="text" id="post-title" th:value="${srcPost.title}" placeholder="제목을 입력하세요" />
</div> </div>
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
<div class="write_option btn-example controlbox-category" to="#popLayer1"> <div class="row gtr-uniform">
</div> <div class="col-6 col-12-xsmall">
<div class="write_option btn-example controlbox-hashtag" to="#popLayer2" id="hashtag_field"> <div class="input-group">
</div> <label for="post-category">카테고리</label>
<div class="write_option btn-example controlbox-location" id="location_field"></div> <input type="text" id="post-category" list="category-list" th:value="${srcPost.category == 'none' ? '' : srcPost.category}" placeholder="예: IT, 일상, 여행" />
</div> <datalist id="category-list">
<div class="manual-input-section" style="margin-bottom: 2em; padding: 1.5em; border: 1px solid #ddd; border-radius: 4px; background-color: #f9f9f9;"> <option value="IT/개발"></option>
<h4 style="margin-bottom: 1em; font-size: 1.1em; color: #333;"><i class="icon solid fa-cog" style="margin-right: 0.5em;"></i>수동 설정</h4> <option value="일상"></option>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5em;"> <option value="생각"></option>
<div> <option value="리뷰"></option>
<label for="manual_date" style="font-weight: bold; margin-bottom: 0.5em; display: block;">작성일</label> </datalist>
<input type="datetime-local" id="manual_date" style="width:100%; box-sizing: border-box;">
</div>
<div>
<label for="manual_lat" style="font-weight: bold; margin-bottom: 0.5em; display: block;">위도 (Latitude)</label>
<input type="number" id="manual_lat" step="any" placeholder="예: 37.5665" style="width:100%; box-sizing: border-box;">
</div>
<div>
<label for="manual_lon" style="font-weight: bold; margin-bottom: 0.5em; display: block;">경도 (Longitude)</label>
<input type="number" id="manual_lon" step="any" placeholder="예: 126.9780" style="width:100%; box-sizing: border-box;">
</div> </div>
</div> </div>
<p style="font-size: 0.8em; color: #777; margin-top: 1em;">* 작성일이나 좌표를 직접 입력하면 자동으로 측정된 GPS 정보 대신 입력된 값이 저장됩니다.</p> <div class="col-6 col-12-xsmall">
<div class="input-group">
<label for="post-tags">태그 (쉼표로 구분)</label>
<input type="text" id="post-tags" th:value="${srcPost.tags}" placeholder="예: Spring, Kotlin, 회고" />
</div>
</div>
</div>
<div class="input-group">
<label>내용</label>
<div id="editor"></div> </div>
<div class="row gtr-uniform" style="margin-top: 1em;">
<div class="col-4 col-12-small">
<input type="checkbox" id="post-posting" name="posting" th:checked="${srcPost.posting}">
<label for="post-posting">공개 게시</label>
</div>
</div>
<div class="action-buttons">
<button class="button alt" onclick="history.back()">취소</button>
<button class="button" onclick="savePost()">저장하기</button>
</div>
</div> </div>
</div> </div>
</th:block>
</section> </section>
<section class="wrapper style1"> <script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<th:block sec:authorize="isAnonymous()">
<div class="container"><h1>권한이 없는 뎁쇼?!</h1></div>
</th:block>
<th:block sec:authorize="isAuthenticated()">
<div class="container">
<div id="editor"></div>
<div style="display: flex; gap: 1em; margin-top: 1em;">
<button id="save" class="button fit primary" onclick="save()">저장하기</button>
<button id="delete" class="button fit alt"
th:attr="data-post-id=${srcPost.id}"
onclick="deleteCurrentPost(this)"
th:if="${srcPost.id != null}">삭제하기</button>
</div>
</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>
</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> <script src="https://cdn.jsdelivr.net/gh/scrapooo/quill-resize-module@1.0.2/dist/quill-resize-module.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" /> <script th:inline="javascript">
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js" defer></script> /*<![CDATA[*/
<!-- <script src="https://cdn.jsdelivr.net/npm/quill-image-resize-module@3.0.0/image-resize.min.js"></script>--> const postData = JSON.parse(/*[[${srcPostJson}]]*/ '{}');
<script defer>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script> const apiBaseUrl = /*[[${apiBaseUrl}]]*/ '';
<!-- <script defer>initEditor(true);</script>--> const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
let quill;
document.addEventListener('DOMContentLoaded', function() {
// 1. Quill 초기화
Quill.register("modules/resize", window.QuillResizeModule);
Quill.register({'modules/table-better': QuillTableBetter}, true);
quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
['link', 'image', 'video', 'blockquote', 'code-block'],
[{ 'align': [] }],
['clean']
],
resize: { locale: {} },
'table-better': { language: 'en_US' }
}
});
// 2. 기존 데이터 로드 (JSON Delta or HTML)
if (postData.content) {
try {
const delta = JSON.parse(postData.content);
quill.setContents(delta);
} catch (e) {
// JSON 파싱 실패 시 HTML로 간주 (레거시 데이터 지원)
quill.clipboard.dangerouslyPasteHTML(postData.html || postData.content);
}
}
// 3. 이미지 핸들러 교체 (서버 업로드)
quill.getModule('toolbar').addHandler('image', selectLocalImage);
});
// 이미지 선택 및 업로드 로직
function selectLocalImage() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = () => {
const file = input.files[0];
if (/^image\//.test(file.type)) {
saveToServer(file);
} else {
alert('이미지 파일만 업로드 가능합니다.');
}
};
}
function saveToServer(file) {
const fd = new FormData();
fd.append('file', file); // 컨트롤러 파라미터명 확인 필요 (보통 file or upload)
fetch('/api/images/upload', { // 이미지 업로드 엔드포인트
method: 'POST',
headers: { [csrfHeader]: csrfToken },
body: fd
})
.then(res => res.json())
.then(data => {
// data.url 또는 data.path 등 서버 응답에 맞춰 수정
const url = apiBaseUrl + (data.url || data.path);
const range = quill.getSelection();
quill.insertEmbed(range.index, 'image', url);
})
.catch(err => {
console.error(err);
alert('이미지 업로드 실패');
});
}
// 게시글 저장
function savePost() {
const title = document.getElementById('post-title').value;
const category = document.getElementById('post-category').value;
const tags = document.getElementById('post-tags').value;
const isPosting = document.getElementById('post-posting').checked;
// Quill 컨텐츠 (Delta JSON 형태로 저장 권장)
const content = JSON.stringify(quill.getContents());
if (!title.trim()) { alert('제목을 입력해주세요.'); return; }
const payload = {
id: postData.id, // 수정 시 ID 포함
title: title,
content: content, // [중요] JSON 문자열 전송 (컨트롤러에서 인코딩 처리 권장)
category: category,
tags: tags,
posting: isPosting,
postType: postData.postType || 'POST' // 기본값 POST
};
fetch('/blog/post', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[csrfHeader]: csrfToken
},
body: JSON.stringify(payload)
})
.then(res => {
if (res.ok) {
// 저장 성공 시 해당 글 뷰어로 이동
res.json().then(saved => window.location.href = `/blog/viewer/${saved.id}`);
} else {
res.json().then(e => alert('저장 실패: ' + (e.message || '오류')));
}
})
.catch(err => alert('서버 통신 오류'));
}
/*]]>*/
</script>
</th:block> </th:block>
</body>
</html> </html>

View File

@ -1,341 +1,544 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html xmlns:th="http://www.thymeleaf.org"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}"> layout:decorate="~{layout/default_layout}">
<head>
<script th:src="@{/js/imagesloaded.pkgd.min.js}"></script> <th:block layout:fragment="head">
<script th:src="@{/js/masonry.pkgd.min.js}"></script>
<style> <style>
.feed-grid { /* --- 기본 스타일 --- */
/* 그리드 컨테이너 */ .grid-sizer, .feed-item { width: 48%; }
width: 100%; @media screen and (max-width: 980px) { .grid-sizer, .feed-item { width: 100%; } }
.feed-item { margin-bottom: 20px; float: left; }
/* 광고 컨테이너 */
.ad-container { width: 100%; margin-bottom: 20px; text-align: center; min-height: 280px; background: #f4f4f4; display: flex; justify-content: center; align-items: center; }
.ad-container.hidden { display: none; }
/* 태그/카테고리 배지 */
.meta-tags { margin-top: 10px; font-size: 0.8em; }
.category-badge { display: inline-block; background: #e44c65; color: white; padding: 2px 8px; border-radius: 4px; margin-right: 5px; text-decoration: none; font-weight: bold; }
.tag-link { color: #666; margin-right: 5px; text-decoration: none; }
.tag-link:hover { color: #e44c65; }
/* --- 멘트 옆 댓글 아이콘 --- */
.comment-reply-icon {
display: inline-block;
font-size: 0.8em;
color: rgba(255, 255, 255, 0.6);
margin-left: 15px;
vertical-align: middle;
cursor: pointer;
border-bottom: none;
transition: all 0.2s ease;
} }
.grid-sizer, .feed-item { .comment-reply-icon:hover {
/* 반응형 너비 설정 (기존 col-4 col-12-medium 등과 호환되게 조정) */ color: #e44c65;
width: 33.333%; transform: scale(1.2);
text-shadow: 0 0 10px rgba(228, 76, 101, 0.5);
} }
/* 모바일 화면에서는 1단으로 변경 */ /* --- 플로팅 버튼 (FAB) --- */
@media screen and (max-width: 980px) { .fab-container {
.grid-sizer, .feed-item { position: fixed;
width: 50%; bottom: 30px;
} right: 30px;
} z-index: 999;
@media screen and (max-width: 736px) { display: flex;
.grid-sizer, .feed-item { flex-direction: column-reverse; /* 아래에서 위로 쌓임 */
width: 100%; align-items: center;
} /* gap은 JS/CSS로 동적 제어 (닫혔을 때 공간 제거 위해 gap 대신 margin 사용) */
} }
.feed-item { .fab-main {
padding: 0 1em 2em 1em; /* 카드 간 간격 */ width: 60px; height: 60px;
float: left; /* Masonry 필수 */ background: #e44c65; color: white;
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
border: none;
font-size: 1.5em;
cursor: pointer;
transition: transform 0.3s ease, background 0.3s;
display: flex; justify-content: center; align-items: center;
min-width: 0 !important;
position: relative;
z-index: 1001; /* 항상 최상위 */
} }
.fab-main:hover { background: #d03d56; transform: scale(1.1); }
.fab-main.active { transform: rotate(45deg); background: #333; }
.fab-item {
width: 50px; height: 50px;
background: white; color: #555;
border-radius: 50%;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
border: none;
cursor: pointer;
display: flex; justify-content: center; align-items: center;
font-size: 1.2em;
/* 숨김 상태 초기값 (공간 차지 X) */
height: 0; margin: 0; opacity: 0; visibility: hidden;
transform: translateY(20px) scale(0);
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55);
min-width: 0 !important;
}
/* 툴팁 */
.fab-item::after {
content: attr(data-tooltip);
position: absolute; right: 60px;
background: rgba(0,0,0,0.7); color: white;
padding: 5px 10px; border-radius: 4px;
font-size: 0.8em; white-space: nowrap;
opacity: 0; transition: opacity 0.3s;
pointer-events: none;
}
/* 활성화 시 아이템 등장 */
.fab-container.active .fab-item {
height: 50px; margin-bottom: 15px;
opacity: 1; visibility: visible;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.fab-container.active .fab-item:hover::after { opacity: 1; }
.fab-item:hover { color: #e44c65; }
/* --- 헛소리 입력 모달 --- */
.modal-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.6);
z-index: 2000;
display: none;
justify-content: center; align-items: center;
backdrop-filter: blur(3px);
}
.modal-overlay.active { display: flex; animation: fadeIn 0.3s; }
.modal-box {
background: white; padding: 2em; border-radius: 8px;
width: 90%; max-width: 500px;
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
animation: slideUp 0.3s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* 검색 오버레이 */
.search-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(255, 255, 255, 0.95);
z-index: 2000;
display: flex; justify-content: center; align-items: center;
opacity: 0; visibility: hidden; transition: all 0.3s ease;
}
.search-overlay.active { opacity: 1; visibility: visible; }
.close-search { position: absolute; top: 20px; right: 30px; font-size: 2em; cursor: pointer; color: #333; }
</style> </style>
</head> </th:block>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<section id="banner" <section id="banner" th:style="'background-image: url(' + ${randomBannerImage} + '); background-size: cover; background-position: center;'">
th:styleappend="${randomBannerImage != null} ? |background-image: url('${apiBaseUrl}${randomBannerImage}');| : ''"> <div class="inner">
<header th:if="${gibberish != null}"> <header th:if="${gibberish != null}">
<h2>Bum's Gibberish: <em>[[${gibberish}]]</em></h2> <h2>
<a th:href="@{/blog/viewer/{id}(id=${gibberishId})}" class="button">코멘트 남기기<br>[Leave a Comment]</a> Bum's Gibberish: <em>[[${gibberish}]]</em>
<a th:href="@{/blog/viewer/{id}(id=${gibberishId})}" class="comment-reply-icon" title="댓글 남기기">
<i class="icon solid fa-comment-dots"></i>
</a>
</h2>
</header> </header>
<header th:if="${gibberish == null}"> <header th:if="${gibberish == null}">
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2> <h2>BUM'sPace</h2>
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a> <p>Welcome to my random thoughts and archives.</p>
</header> </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> </div>
</section> </section>
<!-- <section class="wrapper style2">-->
<!-- <div class="container">-->
<!-- <header class="major">-->
<!-- <h2>A gigantic heading you can use for whatever</h2>-->
<!-- <p>With a much smaller subtitle hanging out just below it</p>-->
<!-- </header>-->
<!-- </div>-->
<!-- </section>-->
<!-- <section id="cta2" class="wrapper style3">-->
<!-- <div class="container">-->
<!-- <header>-->
<!-- <h2>Are you ready to continue your quest?</h2>-->
<!-- </header>-->
<!-- </div>-->
<!-- </section>-->
<section class="wrapper style1"> <section class="wrapper style1">
<div class="container"> <div class="container">
<article id="feed-container" class="feed-grid"> <div class="row">
<section th:each="item, iterStat : ${feedItems}" class="feed-item"> <div class="col-12">
<div id="feed-container" class="feed-grid">
<div class="grid-sizer"></div>
<div th:if="${item.type.name() == 'POST'}" class="box post" <th:block th:each="item, stat : ${feedItems}">
th:onclick="|location.href='@{${item.url}}'|" style="cursor: pointer;"> <div th:if="${stat.index % 5 == 0 and stat.index > 0}" class="feed-item ad-container">
<span class="image left">
<img th:if="${item.thumbnail}" th:src="${apiBaseUrl + item.thumbnail}"
alt="Thumbnail" th:onerror="|this.onerror=null; this.src='@{/images/pic01.jpg}';|" />
<img th:unless="${item.thumbnail}" th:src="@{/images/pic01.jpg}" alt="Default Thumbnail" />
</span>
<div class="inner">
<h3 th:text="${item.title}">제목</h3>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;"
th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd HH:mm')} + ' by ' + ${item.writer}"></p>
<p th:text="${#strings.abbreviate(item.content, 150)}">내용 요약</p>
</div>
</div>
<div th:if="${item.type.name() == 'GIBBERISH'}" class="box post gibberish-card"
th:onclick="|location.href='@{${item.url}}'|"
style="cursor: pointer; background-color: #fff9c4; border-left: 5px solid #fbc02d;">
<div class="inner">
<blockquote>
<i class="icon fa-quote-left" style="color:#fbc02d; margin-right:10px;"></i>
<span th:text="${item.content}" style="font-size: 1.1em; font-weight: bold; color: #333;"></span>
<i class="icon fa-quote-right" style="color:#fbc02d; margin-left:10px;"></i>
</blockquote>
<p style="text-align: right; font-size: 0.8em; color: #777; margin-top: 10px;"
th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd HH:mm')}"></p>
</div>
</div>
<div th:if="${item.type.name() == 'BOOKMARK'}" class="box post bookmark-card"
style="border: 1px dashed #3498db;">
<div class="inner" style="display: flex; align-items: center;">
<div style="flex-shrink: 0; margin-right: 20px;" th:if="${item.thumbnail}">
<img th:src="${apiBaseUrl + item.thumbnail}"
style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;" />
</div>
<div style="flex-grow: 1;">
<h4>
<a th:href="${item.url}" target="_blank" style="text-decoration: none; color: #3498db;">
<i class="icon solid fa-bookmark"></i> <span th:text="${item.title}">북마크 제목</span>
<i class="icon solid fa-external-link-alt" style="font-size: 0.7em;"></i>
</a>
</h4>
<p th:if="${item.content}" th:text="${item.content}" style="font-size: 0.9em; color: #666; margin-bottom: 0.5em;"></p>
<p style="font-size: 0.8em; color: #999;">
Saved on <span th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd')}"></span>
</p>
</div>
</div>
</div>
<div th:if="${iterStat.count % 3 == 0}" class="box ad-container" style="padding: 1em; text-align: center; margin-bottom: 2em; background: #f4f4f4;">
<span style="font-size: 0.8em; color: #aaa;">- Advertisement -</span>
<ins class="adsbygoogle" <ins class="adsbygoogle"
style="display:block" style="display:block; width:100%; height:100%;"
data-ad-client="ca-pub-9504446465764716" data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005" data-ad-slot="YOUR_AD_SLOT_ID"
data-ad-format="auto" data-ad-format="auto"
data-full-width-responsive="true"></ins> data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div> </div>
</section>
</article>
<div id="load-more-container" style="text-align: center; margin-top: 2em; margin-bottom: 2em;"> <section class="feed-item" th:utext="${@thymeleafUtils.renderFeedItem(item, apiBaseUrl ?: '')}"></section>
<button id="btn-load-more" class="button alt" </th:block>
th:if="${nextCursor != null}" </div>
th:data-cursor="${nextCursor}"
onclick="loadMoreFeed()"> <div class="col-12" style="text-align:center; margin-top:30px;">
More Stories <i class="icon solid fa-chevron-down"></i> <button id="btn-load-more" class="button big" th:if="${nextCursor != null}"
th:data-cursor="${nextCursor}" onclick="loadMoreFeed()">
Load More
</button> </button>
<div id="loading-spinner" style="display:none;"> <div id="loading-spinner" style="display:none; font-size:2em; color:#e44c65;">
<i class="icon solid fa-spinner fa-spin fa-2x"></i> <i class="icon solid fa-circle-notch fa-spin"></i>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="wrapper style1">
<div class="container">
<div class="row gtr-200">
<section class="col-4 col-12-narrower">
<div class="box highlight">
<i class="icon solid major fa-paper-plane"></i>
<h3>This Is Important</h3>
<p>Duis neque nisi, dapibus sed mattis et quis, nibh. Sed et dapibus nisl amet mattis, sed a rutrum accumsan sed. Suspendisse eu.</p>
</div>
</section>
<section class="col-4 col-12-narrower">
<div class="box highlight" sec:authorize="isAuthenticated()" onclick=gotoWrite()> <div class="fab-container" sec:authorize="isAuthenticated()" id="fabContainer">
<i class="icon solid major fa-pencil-alt"></i> <button class="fab-main" onclick="toggleFab()">
<h3>글쓰기[Writing]</h3> <i class="icon solid fa-plus"></i>
<p>오직 주인장 만의 권한 임요. 그냥 내가 쓰기 편하게 여기 놔둔 메뉴임. 님들은 못씀요.<br>[Only the owner has the authority. This is just a menu that I put here for my convenience. You can't use it.]</p> </button>
<button class="fab-item" onclick="openSearch()" data-tooltip="검색">
<i class="icon solid fa-search"></i>
</button>
<button class="fab-item" onclick="openGibberishModal()" data-tooltip="오늘의 헛소리">
<i class="icon solid fa-comment-dots"></i>
</button>
<button class="fab-item" onclick="location.href='/blog/edit'" data-tooltip="새 글 작성">
<i class="icon solid fa-pen-nib"></i>
</button>
</div> </div>
<div class="box highlight open-login-popup" sec:authorize="isAnonymous()" to="#loginPopup" style="cursor: pointer;">
<i class="icon solid major fa-pencil-alt"></i> <div class="fab-container" sec:authorize="isAnonymous()" id="fabContainerPublic">
<h3>글쓰기[Writing]</h3> <button class="fab-main" onclick="openSearch()" style="background: #333;">
<p>오직 주인장 만의 권한 임요. 그냥 내가 쓰기 편하게 여기 놔둔 메뉴임. 님들은 못씀요.<br>[Only the owner has the authority. This is just a menu that I put here for my convenience. You can't use it.]</p> <i class="icon solid fa-search"></i>
</button>
</div> </div>
</section>
<section class="col-4 col-12-narrower"> <div id="gibberishModal" class="modal-overlay" onclick="closeGibberishModal(event)">
<div class="box highlight"> <div class="modal-box" onclick="event.stopPropagation()">
<i class="icon solid major fa-wrench"></i> <h3 style="margin-bottom: 1em; color: #e44c65;"><i class="icon solid fa-quote-left"></i> 오늘의 헛소리</h3>
<h3>Probably Important</h3> <textarea id="modal-gibberish-content" rows="4" placeholder="무슨 생각을 하고 계신가요?" style="resize: none; margin-bottom: 10px;"></textarea>
<p>Duis neque nisi, dapibus sed mattis et quis, nibh. Sed et dapibus nisl amet mattis, sed a rutrum accumsan sed. Suspendisse eu.</p> <div style="text-align: right; font-size: 0.8em; color: #888; margin-bottom: 1em;">
<span id="modal-char-count">0/100</span>
</div> </div>
</section> <div style="text-align: right;">
<button class="button small alt" onclick="closeGibberishModal()">취소</button>
<button class="button small primary" onclick="submitGibberishFromModal()">등록하기</button>
</div> </div>
</div> </div>
</section> </div>
<section class="wrapper style1" sec:authorize="isAuthenticated()">
<div class="container"> <div id="search-overlay" class="search-overlay">
<div id="gibberish-form" class="box post"> <div class="close-search" onclick="toggleSearchOverlay()">&times;</div>
<h4>오늘의 Gibberish 남기기 (100자 이내)</h4> <div style="text-align:center; width: 80%; max-width: 600px;">
<textarea id="gibberish-content" rows="3" maxlength="100" placeholder="문득 떠오른 생각을 적어보세요..."></textarea> <h2 style="margin-bottom: 1em; color: #333;">Search</h2>
<button class="button" style="margin-top: 1em;" onclick="submitGibberish()">등록</button> <form action="/home.bs" method="get">
<input type="text" id="overlay-search-input" name="q" placeholder="검색어를 입력하세요..." style="font-size: 1.5em; padding: 15px; border: none; border-bottom: 2px solid #e44c65; background: transparent; width: 100%; outline: none;" />
</form>
</div> </div>
</div> </div>
</section>
<section id="cta2" class="wrapper style3"> <script th:src="@{/js/imagesloaded.pkgd.min.js}"></script>
<div class="container"> <script th:src="@{/js/masonry.pkgd.min.js}"></script>
<header>
<!-- <h2>Are you ready to continue your quest?</h2>-->
</header>
</div>
</section>
<script th:inline="javascript"> <script th:inline="javascript">
const apiBaseUrl = /*[[${apiBaseUrl}]]*/ ''; // Thymeleaf 변수 바인딩 const apiBaseUrl = /*[[${apiBaseUrl ?: ''}]]*/ '';
var $grid;
var isFetching = false; // [수정] 중복 요청 방지 플래그
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
// 레이아웃 재정렬 최적화 (Debounce)
let layoutTimeout;
function debouncedLayout() {
clearTimeout(layoutTimeout);
layoutTimeout = setTimeout(() => {
if ($grid) $grid.layout();
}, 100);
}
document.addEventListener('DOMContentLoaded', function() {
var gridElement = document.querySelector('.feed-grid');
// 1. Masonry 초기화
imagesLoaded(gridElement, function() {
$grid = new Masonry(gridElement, {
itemSelector: '.feed-item',
columnWidth: '.grid-sizer',
percentPosition: true,
transitionDuration: '0.2s'
});
});
// 2. 광고 숨김 감지
document.querySelectorAll('.adsbygoogle').forEach(ins => {
new MutationObserver(mutations => {
if (ins.getAttribute('data-ad-status') === 'unfilled') {
const container = ins.closest('.ad-container');
if (container && container.style.display !== 'none') {
container.style.display = 'none';
debouncedLayout();
}
}
}).observe(ins, { attributes: true });
});
// 3. 모달 글자수 체크
const modalTextarea = document.getElementById('modal-gibberish-content');
if(modalTextarea) {
modalTextarea.addEventListener('input', function() {
const len = this.value.length;
document.getElementById('modal-char-count').textContent = len + '/100';
if(len > 100) {
this.value = this.value.substring(0, 100);
document.getElementById('modal-char-count').textContent = '100/100';
}
});
}
});
// --- 기능 함수들 ---
function loadMoreFeed() { function loadMoreFeed() {
if (isFetching) return; // 이미 로딩 중이면 무시
isFetching = true;
const btn = document.getElementById('btn-load-more'); const btn = document.getElementById('btn-load-more');
const spinner = document.getElementById('loading-spinner');
const container = document.getElementById('feed-container');
// 현재 커서 값 가져오기
const cursor = btn.getAttribute('data-cursor'); const cursor = btn.getAttribute('data-cursor');
if (!cursor) return;
// UI 상태 변경 (로딩 중)
btn.style.display = 'none';
spinner.style.display = 'inline-block';
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('q') || ''; const query = urlParams.get('q') || '';
// API 호출 시 q 파라미터 포함 btn.style.display = 'none';
document.getElementById('loading-spinner').style.display = 'inline-block';
fetch(`/api/feed?cursor=${cursor}&q=${encodeURIComponent(query)}`) fetch(`/api/feed?cursor=${cursor}&q=${encodeURIComponent(query)}`)
.then(response => response.json()) .then(res => {
if (!res.ok) throw new Error("서버 응답 오류");
return res.json();
})
.then(data => { .then(data => {
// 데이터가 없으면 버튼 숨기고 종료 // [핵심 1] 커서부터 먼저 갱신 (렌더링 에러가 나도 페이징은 넘어가도록)
if (!data.items || data.items.length === 0) {
spinner.style.display = 'none';
return;
}
// 받아온 데이터를 HTML로 변환하여 추가
data.items.forEach(item => {
const html = createFeedItemHtml(item);
// section 태그로 감싸서 추가
const section = document.createElement('section');
section.className = 'feed-item';
section.innerHTML = html;
container.appendChild(section);
});
// 다음 커서 업데이트
if (data.nextCursor) { if (data.nextCursor) {
btn.setAttribute('data-cursor', data.nextCursor); btn.setAttribute('data-cursor', data.nextCursor);
btn.style.display = 'inline-block';
} else { } else {
// 더 이상 불러올 게 없으면 버튼 제거 btn.removeAttribute('data-cursor'); // 더 이상 데이터 없음
btn.remove(); }
if (!data.items || data.items.length === 0) {
document.getElementById('loading-spinner').style.display = 'none';
return; // 버튼 숨긴 채로 종료
}
var newElements = [];
const container = document.getElementById('feed-container');
data.items.forEach(item => {
try {
const tempDiv = document.createElement('div');
// [핵심 2] HTML 생성 시 에러 발생 방지
const html = createFeedItemHtml(item);
if (html) {
tempDiv.innerHTML = html.trim();
const section = tempDiv.firstChild;
if (section) {
section.className = 'feed-item';
container.appendChild(section);
newElements.push(section);
}
}
} catch (renderErr) {
console.error("아이템 렌더링 실패 (하나 건너뜀):", renderErr, item);
}
});
// Masonry 레이아웃 처리
if ($grid && newElements.length > 0) {
$grid.appended(newElements);
imagesLoaded(newElements, function() {
$grid.layout();
});
} else {
// Masonry가 없거나 이미지가 없는 경우 대비
setTimeout(() => debouncedLayout(), 500);
}
// 버튼 다시 표시 (다음 페이지가 있을 때만)
if (data.nextCursor) {
btn.style.display = 'inline-block';
} }
}) })
.catch(err => { .catch(err => {
console.error('Feed load error:', err); console.error("더보기 로드 실패:", err);
alert('추가 콘텐츠를 불러오는 중 오류가 발생했습니다.'); alert("데이터를 불러오는 중 문제가 발생했습니다.");
btn.style.display = 'inline-block'; btn.style.display = 'inline-block'; // 에러 나면 버튼 다시 복구
}) })
.finally(() => { .finally(() => {
spinner.style.display = 'none'; document.getElementById('loading-spinner').style.display = 'none';
isFetching = false; // 로딩 락 해제
}); });
} }
// JSON 데이터를 HTML 문자열로 변환하는 헬퍼 함수 function toggleFab() {
function createFeedItemHtml(item) { const container = document.getElementById('fabContainer');
const dateStr = new Date(item.createdAt).toISOString().split('T')[0]; const mainBtn = container.querySelector('.fab-main');
container.classList.toggle('active');
mainBtn.classList.toggle('active');
}
if (item.type === 'POST') { function openSearch() {
const thumbSrc = item.thumbnail ? (apiBaseUrl + item.thumbnail) : '/images/pic01.jpg'; const container = document.getElementById('fabContainer');
return ` if(container && container.classList.contains('active')) {
<div class="box post" onclick="location.href='${item.url}'" style="cursor: pointer;"> toggleFab();
<span class="image left"><img src="${thumbSrc}" onerror="this.onerror=null; this.src='/images/pic01.jpg';" /></span> }
<div class="inner"> toggleSearchOverlay();
<h3>${item.title || 'Untitled'}</h3> }
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;">${dateStr} by ${item.writer || 'Bum'}</p>
<p>${item.content ? item.content.substring(0, 150) + '...' : ''}</p> function openGibberishModal() {
</div> toggleFab();
document.getElementById('gibberishModal').classList.add('active');
const textarea = document.getElementById('modal-gibberish-content');
textarea.value = '';
document.getElementById('modal-char-count').textContent = '0/100';
setTimeout(() => textarea.focus(), 100);
}
function closeGibberishModal(e) {
if (e && e.target !== e.currentTarget) return;
document.getElementById('gibberishModal').classList.remove('active');
}
function submitGibberishFromModal() {
const content = document.getElementById('modal-gibberish-content').value;
if (!content.trim()) { alert("내용을 입력해주세요."); return; }
fetch('/blog/gibberish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[csrfHeader]: csrfToken
},
body: JSON.stringify({ id: null, content: content })
})
.then(res => {
if (res.ok) window.location.reload();
else res.json().then(d => alert(d.message || "오류 발생"));
})
.catch(e => alert("서버 통신 오류"));
}
function deletePost(id) {
if (!confirm("정말 삭제하시겠습니까?")) return;
fetch('/blog/post/' + id, {
method: 'DELETE',
headers: { [csrfHeader]: csrfToken }
})
.then(res => {
if (res.ok) window.location.reload();
else alert("삭제 실패");
});
}
function toggleSearchOverlay() {
const overlay = document.getElementById('search-overlay');
overlay.classList.toggle('active');
if(overlay.classList.contains('active')) {
document.body.style.overflow = 'hidden';
setTimeout(() => document.getElementById('overlay-search-input').focus(), 100);
} else {
document.body.style.overflow = '';
}
}
document.addEventListener('keydown', e => { if(e.key==="Escape") document.getElementById('search-overlay').classList.remove('active'); });
// [중요] HTML 생성 함수 (Null 체크 강화)
function createFeedItemHtml(item) {
if (!item) return '';
// 1. 날짜 처리 안전장치
let dateStr = '';
try {
dateStr = new Date(item.createdAt).toISOString().split('T')[0];
} catch(e) { dateStr = 'Recently'; }
const imgErrorHandler = "this.onerror=null; this.style.display='none'; setTimeout(function(){ if(window.$grid) window.$grid.layout(); }, 200);";
let metaHtml = '';
if (item.type === 'POST' || item.type === 'BOOKMARK') {
metaHtml += '<div class="meta-tags">';
// category null 체크
if (item.category && item.category !== 'none') {
const safeCat = item.category.replace(/"/g, '&quot;');
const catUrl = item.type === 'POST' ? `/blog/posts?category=${encodeURIComponent(item.category)}` : 'javascript:void(0)';
const catStyle = item.type === 'BOOKMARK' ? 'background:#3498db;' : '';
metaHtml += `<a href="${catUrl}" class="category-badge" style="${catStyle}" onclick="event.stopPropagation()">${safeCat}</a>`;
}
// tags null 체크
if (item.tags && Array.isArray(item.tags) && item.tags.length > 0) {
item.tags.forEach(tag => {
if(tag) metaHtml += `<a href="/blog/posts?tag=${encodeURIComponent(tag)}" class="tag-link" onclick="event.stopPropagation()">#${tag}</a>`;
});
}
metaHtml += '</div>';
}
let editBtnHtml = '';
if (item.isOwner && item.type === 'GIBBERISH') {
// 따옴표 이스케이프 처리
const safeContent = (item.content || '').replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/\n/g, ' ');
editBtnHtml = `
<div style="position: absolute; top: 10px; right: 10px; z-index: 10;">
<button type="button" class="button small" style="padding:0 0.5em;height:2em;line-height:2em;background:#e44c65;color:white;"
onclick="deletePost('${item.id}')"><i class="icon solid fa-trash"></i></button>
</div>`; </div>`;
} }
else if (item.type === 'GIBBERISH') {
// 썸네일 경로 처리 (null일 경우 대비)
const thumbUrl = item.thumbnail ? (item.thumbnail.startsWith('http') ? item.thumbnail : (apiBaseUrl + item.thumbnail)) : '/images/pic01.jpg';
if (item.type === 'POST') {
return ` return `
<div class="box post gibberish-card" onclick="location.href='${item.url}'" <div class="box post" onclick="location.href='${item.url}'" style="cursor:pointer; position:relative;">
style="cursor: pointer; background-color: #fff9c4; border-left: 5px solid #fbc02d;"> ${editBtnHtml}
<span class="image left">
<img src="${thumbUrl}" onerror="${imgErrorHandler}" />
</span>
<div class="inner"> <div class="inner">
<blockquote> <h3>${item.title || 'Untitled'}</h3>
<i class="icon fa-quote-left" style="color:#fbc02d; margin-right:10px;"></i> <p style="font-size:0.9em;color:#555;">${dateStr} by ${item.writer || 'Anonymous'}</p>
<span style="font-size: 1.1em; font-weight: bold; color: #333;">${item.content}</span> <p>${item.content ? item.content.substring(0, 100) + '...' : ''}</p>
</blockquote> ${metaHtml}
</div>
</div>`;
} else if (item.type === 'GIBBERISH') {
return `
<div class="box post" onclick="location.href='${item.url}'" style="cursor:pointer; background-color:#fff9c4; border-left:5px solid #fbc02d; position:relative;">
${editBtnHtml}
<div class="inner">
<blockquote><i class="icon fa-quote-left" style="color:#fbc02d;"></i>
<span style="font-weight:bold;color:#333;">${item.content || ''}</span></blockquote>
<p style="text-align:right;font-size:0.8em;color:#777;">${dateStr}</p> <p style="text-align:right;font-size:0.8em;color:#777;">${dateStr}</p>
</div> </div>
</div>`; </div>`;
} } else if (item.type === 'BOOKMARK') {
else if (item.type === 'BOOKMARK') { const viewerUrl = `/blog/viewer/${item.id}`;
const thumbImg = item.thumbnail ? const thumbImg = item.thumbnail ?
`<div style="flex-shrink: 0; margin-right: 20px;"><img src="${apiBaseUrl + item.thumbnail}" style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;" /></div>` : ''; `<div style="margin-bottom:10px;"><img src="${thumbUrl}" style="width:100%; height:150px; object-fit:cover; border-radius:4px;" onerror="${imgErrorHandler}"></div>` : '';
return ` return `
<div class="box post bookmark-card" style="border: 1px dashed #3498db;"> <div class="box post" onclick="location.href='${viewerUrl}'" style="border:1px dashed #3498db; position:relative; cursor:pointer;">
<div class="inner" style="display: flex; align-items: center;"> ${editBtnHtml}
<div class="inner">
${thumbImg} ${thumbImg}
<div style="flex-grow: 1;">
<h4> <h4>
<a href="${item.url}" target="_blank" style="text-decoration: none; color: #3498db;"> <a href="${item.url}" target="_blank" style="color:#3498db;" onclick="event.stopPropagation()">
<i class="icon solid fa-bookmark"></i> ${item.title} <i class="icon solid fa-bookmark"></i> ${item.title || 'Bookmark'} <i class="icon solid fa-external-link-alt" style="font-size:0.7em;"></i>
<i class="icon solid fa-external-link-alt" style="font-size: 0.7em;"></i>
</a> </a>
</h4> </h4>
<p style="font-size: 0.9em; color: #666; margin-bottom: 0.5em;">${item.content || ''}</p> <p style="font-size:0.9em;">${item.content || ''}</p>
<p style="font-size: 0.8em; color: #999;">Saved on ${dateStr}</p> ${metaHtml}
</div>
</div> </div>
</div>`; </div>`;
} }
return ''; return '';
} }
</script> </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> </th:block>
</html> </html>

View File

@ -3,148 +3,128 @@
xmlns:th="http://www.thymeleaf.org" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}" layout:decorate="~{layout/default_layout}">
>
<head> <head>
<th:block layout:fragment="head"> <style>
</th:block> /* 홈 화면과 동일한 Masonry 스타일 적용 */
.feed-grid { width: 100%; }
.grid-sizer, .feed-item { width: 33.333%; }
.feed-item { padding: 0 10px 20px 10px; float: left; }
@media screen and (max-width: 980px) { .grid-sizer, .feed-item { width: 50%; } }
@media screen and (max-width: 736px) { .grid-sizer, .feed-item { width: 100%; } }
/* 페이지네이션 스타일 보정 */
.pagination-wrapper { text-align: center; margin-top: 3em; clear: both; }
.pagination { display: inline-flex; list-style: none; padding: 0; border: 1px solid #e0e0e0; border-radius: 4px; overflow: hidden; }
.pagination li a { display: block; padding: 0.5em 1em; text-decoration: none; border-right: 1px solid #e0e0e0; color: #555; }
.pagination li:last-child a { border-right: none; }
.pagination li a.active { background-color: #e44c65; color: white; }
.pagination li a:hover:not(.active) { background-color: #f4f4f4; }
</style>
</head> </head>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<section class="wrapper style1"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div class="row justify-content-center">
<div class="col-12">
<div id="content_inner"> <div id="content_inner">
<article>
<header class="major" th:if="${filterTitle}"> <header class="major" th:if="${filterTitle}" style="text-align: center; margin-bottom: 2em;">
<h2 th:text="${filterTitle}">필터링된 게시물</h2> <h2 th:text="${filterTitle}" style="font-size: 1.5em; color: #e44c65;">필터링된 게시물</h2>
<p><a th:href="@{/blog/posts}">모든 글 보기</a></p> <p><a th:href="@{/}" class="button small">홈으로 돌아가</a></p>
</header> </header>
<th:block th:each="post, iterStat : ${postsPage.content}"> <header class="major" th:unless="${filterTitle}" style="text-align: center; margin-bottom: 2em;">
<section th:id="'post-section-' + ${post.id}"> <h2>전체 글 목록</h2> </header>
<div class="box post" th:id="${post.id}">
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left"> <div class="feed-grid">
<div class="grid-sizer"></div>
<section th:each="post : ${postsPage.content}" class="feed-item" th:id="'post-section-' + ${post.id}">
<div class="box post" th:onclick="|location.href='@{/blog/viewer/{id}(id=${post.id})}'|" 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}" <img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}"
alt="Post Thumbnail" alt="Thumb" th:onerror="|this.onerror=null; this.src='@{/images/pic01.jpg}';|" />
th:onerror="|this.onerror=null; this.src='@{/images/bum.png}';|" /> </span>
</a>
<div class="inner"> <div class="inner">
<h3 style="display: flex; justify-content: space-between; align-items: center;"> <h3 style="display: flex; justify-content: space-between; align-items: center;">
<span> <span>
<span th:if="${!post.posting}" style="background-color: #888; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;"> <span th:if="${!post.posting}" style="background-color: #888; color: white; font-size: 0.6em; padding: 2px 5px; border-radius: 3px; vertical-align: middle; margin-right: 5px;">비공개</span>
비공개 <span th:text="${post.title}">제목</span>
</span>
<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>
<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> </span>
</h3> </h3>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p> <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> <p th:text="${#strings.abbreviate(post.html, 80)}">내용요약</p>
</div> </div>
<footer sec:authorize="isAuthenticated()" <footer sec:authorize="isAuthenticated()"
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}" th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
style="text-align: right; margin-top: 1em;"> style="padding: 1em; border-top: 1px solid #eee; text-align: right;" onclick="event.stopPropagation();">
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small">수정</a> <a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small icon solid fa-pen"></a>
<button class="button small alt" th:onclick="handleDeletePostInList([[${post.id}]], event)">삭제</button> <button class="button small alt icon solid fa-trash" th:onclick="handleDeletePostInList([[${post.id}]], event)"></button>
</footer> </footer>
</div> </div>
</section> </section>
<th:block th:if="${iterStat.count % 3 == 0}">
<section>
<div class="box ad-container" style="padding: 2em; text-align: center;">
<p style="margin-bottom: 1em; color: #888;">- Advertisement -</p>
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div> </div>
</section>
</th:block>
</th:block>
</article>
<nav aria-label="Page navigation" th:if="${postsPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em; font-size: 0.9em;"> <div class="pagination-wrapper" th:if="${postsPage.totalPages > 1}">
<ul class="pagination" style="display: inline-block; padding-left: 0; list-style: none; border-radius: 5px; border: 1px solid #e0e0e0; overflow: hidden;"> <ul class="pagination">
<li th:styleappend="${postsPage.isFirst()} ? 'opacity: 0.5; pointer-events: none;' : ''" style="display: inline; float: left;"> <li th:classappend="${postsPage.isFirst()} ? 'disabled'">
<a th:href="${postsPage.isFirst()} ? '#' : @{/blog/posts(page=${postsPage.number - 1}, category=${currentCategory}, tag=${currentTag})}" <a th:href="${postsPage.isFirst()} ? '#' : @{/blog/posts(page=${postsPage.number - 1}, category=${currentCategory}, tag=${currentTag})}">&laquo; Prev</a>
class="button alt small" style="border-radius:0; margin:0; border-right: 1px solid #e0e0e0;">
&laquo; Prev
</a>
</li> </li>
<li th:each="pageNum : ${#numbers.sequence(0, postsPage.totalPages - 1)}"
style="display: inline; float: left; border-right: 1px solid #e0e0e0;"> <li th:each="pageNum : ${#numbers.sequence(0, postsPage.totalPages - 1)}">
<a th:href="@{/blog/posts(page=${pageNum}, category=${currentCategory}, tag=${currentTag})}" <a th:href="@{/blog/posts(page=${pageNum}, category=${currentCategory}, tag=${currentTag})}"
th:text="${pageNum + 1}" th:text="${pageNum + 1}"
th:class="${pageNum == postsPage.number} ? 'button small' : 'button alt small'" th:classappend="${pageNum == postsPage.number} ? 'active'">1</a>
style="border-radius:0; margin:0;">
</a>
</li> </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}, category=${currentCategory}, tag=${currentTag})}" <li th:classappend="${postsPage.isLast()} ? 'disabled'">
class="button alt small" style="border-radius:0; margin:0;"> <a th:href="${postsPage.isLast()} ? '#' : @{/blog/posts(page=${postsPage.number + 1}, category=${currentCategory}, tag=${currentTag})}">Next &raquo;</a>
Next &raquo;
</a>
</li> </li>
</ul> </ul>
</nav>
</div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<script th:src="@{/js/imagesloaded.pkgd.min.js}"></script>
<script th:src="@{/js/masonry.pkgd.min.js}"></script>
<script th:inline="javascript"> <script th:inline="javascript">
// CSRF 토큰을 meta 태그에서 읽어옴 (POST, DELETE 요청 시 필요) // Masonry 초기화
document.addEventListener('DOMContentLoaded', function() {
var gridElement = document.querySelector('.feed-grid');
imagesLoaded(gridElement, function() {
new Masonry(gridElement, {
itemSelector: '.feed-item',
columnWidth: '.grid-sizer',
percentPosition: true
});
});
});
// 삭제 기능
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content'); const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
/**
* 게시물 목록에서 게시물을 삭제하는 함수
* @param {string} postId - 삭제할 게시물의 ID
* @param {Event} event - 클릭 이벤트 객체
*/
function handleDeletePostInList(postId, event) { function handleDeletePostInList(postId, event) {
// 이벤트 버블링을 막아 게시물 상세보기로 이동하는 것을 방지합니다. event.stopPropagation(); // 카드 클릭 이벤트(뷰어 이동) 방지
event.stopPropagation(); if (!confirm('정말 삭제하시겠습니까?')) return;
const cleanPostId = postId.replace(/^"|"$/g, ''); fetch(`/blog/post/${postId}`, {
if (confirm('이 게시물을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
fetch(`/blog/post/${cleanPostId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: { [csrfHeader]: csrfToken }
[csrfHeader]: csrfToken
}
}) })
.then(response => { .then(res => {
if (response.ok) { if (res.ok) {
return response.json(); alert('삭제되었습니다.');
// Masonry 레이아웃 재조정이 복잡하므로 간단히 새로고침
window.location.reload();
} else { } else {
// 서버에서 에러 메시지를 보냈을 경우를 대비 alert('삭제 실패');
return response.json().then(err => { throw new Error(err.message || '삭제에 실패했습니다.') });
} }
})
.then(data => {
alert(data.message || '게시물이 성공적으로 삭제되었습니다.');
// UI에서 해당 게시물 섹션을 제거하여 즉시 반영
const postSection = document.getElementById(`post-section-${cleanPostId}`);
if (postSection) {
postSection.remove();
}
})
.catch(error => {
console.error('Error:', error);
alert('삭제 처리 중 오류가 발생했습니다: ' + error.message);
}); });
} }
}
</script> </script>
</th:block> </th:block>
</html> </html>

View File

@ -5,63 +5,42 @@
layout:decorate="~{layout/default_layout}"> layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head"> <th:block layout:fragment="head">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css" rel="stylesheet">
<style>
/* 뷰어 전용 스타일: 테두리 제거 및 폰트 통일 */
.ql-container.ql-snow { border: none !important; font-family: inherit; }
.ql-editor { padding: 0; font-size: 1.1em; line-height: 1.8; overflow-y: visible; }
.ql-editor pre.ql-syntax { background-color: #282c34; color: #abb2bf; border-radius: 5px; padding: 1em; }
/* 툴바 숨김 (혹시 생성되더라도) */
.ql-toolbar { display: none !important; }
/* 인용구(Gibberish) 스타일 */
.gibberish-box { background: #fff9c4; border-left: 5px solid #fbc02d; padding: 2em; margin-bottom: 2em; font-size: 1.2em; border-radius: 4px; }
/* 북마크 갤러리 스타일 */
.bookmark-gallery .image.fit { margin-bottom: 0; }
.bookmark-gallery img { transition: transform 0.3s ease; }
.bookmark-gallery img:hover { transform: scale(1.02); }
</style>
</th:block> </th:block>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<section class="wrapper style2"> <section class="wrapper style2">
<div class="container" sec:authorize="isAuthenticated()"> <div class="container">
<header class="major" <header class="major">
th:with="isAdmin=${#authorization.expression('hasRole(''ADMIN'')')}, isWriter=${#authentication.name == srcPost.writer}" <h2>
th:attr=" <span th:text="${srcPost.title}">제목</span>
onclick=${(isAdmin or isWriter) ? 'loadEditor()' : ''}, <i th:if="${srcPost.type == 'BOOKMARK'}" class="icon solid fa-bookmark" style="color: #3498db; font-size: 0.7em; vertical-align: middle;"></i>
style=${(isAdmin or isWriter) ? 'cursor: pointer;' : ''},
title=${(isAdmin or isWriter) ? '클릭하여 수정하기' : ''}
">
<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> </h2>
<p> <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')}"></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> by <span th:text="${srcPost.writer}"></span>
</p> </p>
<div th:if="${originalUrl}" style="margin-top: 1em;">
<a th:href="${originalUrl}" target="_blank" class="button icon solid fa-external-link-alt">원본 사이트 방문하기</a>
</div>
</header> </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" sec:authorize="isAnonymous()">
<header class="major open-login-popup" to="#loginPopup" style="cursor: pointer;">
<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> </div>
</section> </section>
@ -69,52 +48,193 @@
<div class="container"> <div class="container">
<div id="content_inner"> <div id="content_inner">
<article> <article>
<div id="editor"></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}"> <div th:if="${srcPost.type.name() == 'BOOKMARK' and srcPost.images != null and !srcPost.images.isEmpty()}"
<button class="button" onclick="handleVote(this, 'like')"> class="bookmark-gallery" style="margin-bottom: 3em;">
👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>)
</button> <div th:if="${srcPost.images.size() == 1}" class="image fit" style="border-radius: 4px; overflow: hidden; box-shadow: 0 4px 10px rgba(0,0,0,0.1);">
<button class="button" onclick="handleVote(this, 'unlike')"> <a th:href="${originalUrl}" target="_blank">
👎 Unlike (<span class="unlike-count" th:text="${srcPost.unlikeCount}">0</span>) <img th:src="${#strings.startsWith(srcPost.images[0], 'http')} ? ${srcPost.images[0]} : |${apiBaseUrl ?: ''}${srcPost.images[0]}|"
</button> alt="Bookmark Media" onerror="this.style.display='none'" />
</a>
</div> </div>
<h3 id="write" th:text="${srcPost.firstAddress}"></h3> <div th:if="${srcPost.images.size() > 1}" class="row gtr-50 gtr-uniform">
<h3 id="modify" th:text="${srcPost.modifyAddress}"></h3> <div th:each="img : ${srcPost.images}" class="col-6 col-12-mobile">
<span class="image fit" style="border-radius: 4px; overflow: hidden; box-shadow: 0 2px 5px rgba(0,0,0,0.1); cursor: pointer;">
<a th:href="${originalUrl}" target="_blank">
<img th:src="${#strings.startsWith(img, 'http')} ? ${img} : |${apiBaseUrl ?: ''}${img}|"
alt="Bookmark Media"
style="height: 250px; object-fit: cover; width: 100%;"
onerror="this.parentElement.parentElement.style.display='none'" />
</a>
</span>
</div>
</div>
<p style="text-align: center; font-size: 0.8em; color: #999; margin-top: 1em;">
<i class="icon solid fa-images"></i> 이미지를 클릭하면 원본 사이트로 이동합니다.
</p>
</div>
<div th:if="${srcPost.type.name() == 'GIBBERISH'}" class="gibberish-box">
<i class="icon fa-quote-left" style="color:#fbc02d; margin-right:10px;"></i>
<span th:text="${srcPost.content}">내용</span>
</div>
<div th:unless="${srcPost.type.name() == 'GIBBERISH'}" id="viewer-wrapper">
<div id="quill-viewer"></div>
</div>
<div class="meta-tags" th:if="${srcPost.tags != null and srcPost.tags != ''}" style="margin-top: 3em; padding-top: 1em; border-top: 1px solid #eee;">
<i class="icon solid fa-tags" style="color: #e44c65; margin-right: 5px;"></i>
<span th:each="tag : ${#strings.arraySplit(srcPost.tags, ',')}">
<a th:href="@{/blog/posts(tag=${tag.trim()})}" class="tag-link" th:text="'#' + ${tag.trim()}" style="margin-right: 10px; color: #666; text-decoration: none;">#Tag</a>
</span>
</div>
<div th:if="${isOwner or isAdmin}" style="margin-top: 2em; text-align: right;">
<a th:if="${srcPost.type.name() != 'BOOKMARK'}" th:href="@{/blog/edit/{id}(id=${srcPost.id})}" class="button small">수정</a>
<button class="button small alt" th:onclick="|deletePost('${srcPost.id}')|">삭제</button>
</div>
</article> </article>
<section class="comment-section"> <section class="comment-section" style="margin-top: 5em; padding-top: 3em; border-top: 2px solid #f4f4f4;">
<h2>Comments</h2> <h3><i class="icon solid fa-comments"></i> Comments</h3>
<div id="comment-form-container">
<div id="reply-status-bar" style="display: none;"> <div id="comments-list" style="margin-bottom: 2em;">
<span id="reply-status-text"></span> <div style="text-align: center; color: #ccc;"><i class="icon solid fa-spinner fa-spin"></i> Loading comments...</div>
<button id="btn-cancel-reply" onclick="cancelReply()">X 취소</button>
</div> </div>
<textarea id="comment-input" placeholder="댓글을 입력하세요..."></textarea>
<button id="submit-comment" class="button">등록</button> <div id="comment-form" sec:authorize="isAuthenticated()" style="background: #f9f9f9; padding: 1.5em; border-radius: 4px;">
<textarea id="comment-input" rows="3" placeholder="댓글을 남겨보세요..." style="background: #fff; resize: vertical;"></textarea>
<div style="text-align: right; margin-top: 10px;">
<button class="button small" onclick="submitComment()">등록</button>
</div> </div>
<div id="comments-list"> </div>
<div sec:authorize="isAnonymous()" style="text-align: center; padding: 2em; background: #f9f9f9; border-radius: 4px;">
<p>댓글을 남기려면 <a th:href="@{/home.bs?action=login}" style="font-weight: bold;">로그인</a>이 필요합니다.</p>
</div> </div>
</section> </section>
</div> </div>
</div> </div>
</section> </section>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js" defer></script> <script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" /> <script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<!-- <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 th:inline="javascript">
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js" defer></script> const targetId = /*[[${srcPost.id}]]*/ '';
<!-- <script src="https://cdn.jsdelivr.net/npm/quill-image-resize-module@3.0.0/image-resize.min.js"></script>--> const targetType = /*[[${targetType}]]*/ 'POST';
<!-- <script defer>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>--> const rawContent = /*[[${srcPost.content}]]*/ '';
<script defer>document.addEventListener('DOMContentLoaded', function() { const type = /*[[${srcPost.type}]]*/ 'POST';
initEditor(false) const apiBaseUrl = /*[[${apiBaseUrl}]]*/ '';
fetchComments(serverData.id);
});</script> // CSRF Token (삭제/등록 시 필요)
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
document.addEventListener('DOMContentLoaded', function() {
if (type !== 'GIBBERISH') {
initQuillViewer();
}
fetchComments();
});
function initQuillViewer() {
// 1. 형식만 등록 (UI 모듈 활성화 X -> 에러 방지)
Quill.register({'modules/table-better': QuillTableBetter}, true);
const quill = new Quill('#quill-viewer', {
theme: 'snow',
readOnly: true,
modules: {
syntax: true, // 코드 하이라이팅
toolbar: false // 툴바 제거
}
});
// 2. 데이터 주입
try {
const delta = JSON.parse(rawContent);
quill.setContents(delta);
} catch (e) {
// JSON 파싱 실패 시 HTML로 렌더링
quill.clipboard.dangerouslyPasteHTML(rawContent);
}
// 3. 테이블 스타일 강제 보정
setTimeout(() => {
document.querySelectorAll('#quill-viewer table').forEach(t => {
t.style.width = '100%';
t.style.borderCollapse = 'collapse';
});
}, 100);
}
// --- 댓글 기능 ---
function fetchComments() {
fetch(`/blog/comments/${targetId}?type=${targetType}`).then(r=>r.json()).then(data=>{
const list = document.getElementById('comments-list');
list.innerHTML = '';
if(!data.comments || data.comments.length === 0) {
list.innerHTML = '<p style="color:#999; font-style:italic; text-align:center;">아직 댓글이 없습니다. 첫 번째 댓글을 남겨주세요!</p>';
return;
}
data.comments.forEach(c => {
const div = document.createElement('div');
div.className = 'comment-item';
div.style.padding = '15px 0';
div.style.borderBottom = '1px solid #eee';
div.innerHTML = `
<div style="display:flex; justify-content:space-between; margin-bottom: 5px;">
<strong><i class="icon solid fa-user-circle" style="color:#ddd;"></i> ${c.writer}</strong>
<span style="color:#999; font-size:0.8em;">${new Date(c.writeTime).toLocaleDateString()} ${new Date(c.writeTime).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}</span>
</div>
<div style="white-space: pre-wrap; line-height: 1.5;">${c.content}</div>
`;
list.appendChild(div);
});
});
}
function submitComment() {
const content = document.getElementById('comment-input').value;
if(!content.trim()) return alert("내용을 입력해주세요.");
fetch(`/blog/comments/${targetId}`, {
method: 'POST',
headers: {
'Content-Type':'application/json',
[csrfHeader]: csrfToken
},
body: JSON.stringify({ content: content, targetType: targetType })
}).then(r=>{
if(r.ok) {
document.getElementById('comment-input').value='';
fetchComments();
} else {
alert("댓글 등록 실패");
}
});
}
// --- 삭제 기능 ---
function deletePost(id) {
if(!confirm("정말 삭제하시겠습니까?")) return;
fetch('/blog/post/' + id, {
method: 'DELETE',
headers: { [csrfHeader]: csrfToken }
}).then(res => {
if(res.ok) { alert("삭제되었습니다."); location.href = "/"; }
else alert("삭제 실패");
});
}
</script>
</th:block> </th:block>
</html> </html>

View File

@ -14,7 +14,6 @@
<nav id="nav"> <nav id="nav">
<ul> <ul>
<li id="menu_home" ><a th:href="@{/}">Home</a></li> <li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</a></li>
<li id="menu_stock"> <li id="menu_stock">
<a href="#">Stock</a> <a href="#">Stock</a>
<ul> <ul>
@ -23,8 +22,8 @@
<li><a href="/stock/market">시장 지표</a></li> <li><a href="/stock/market">시장 지표</a></li>
</ul> </ul>
</li> </li>
<li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li> <!-- <li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li>-->
<li id="menu_drop"> <li id="menu_playz">
<a href="#">Game</a> <a href="#">Game</a>
<ul> <ul>
<li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li> <li id="menu_nonogram"><a href="puzzle/play">Nonogram</a></li>