...
This commit is contained in:
parent
7397d403d4
commit
b10d3223fd
@ -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)
|
||||||
|
|||||||
@ -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 "이 게시물을 삭제할 권한이 없습니다."))
|
|
||||||
|
postManager.deletePost(postId).awaitFirstOrNull()
|
||||||
|
return ResponseEntity.ok(mapOf("message" to "Deleted"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- 4. Gibberish (짧은 글) CRUD ---
|
||||||
|
|
||||||
|
@PostMapping("/gibberish")
|
||||||
|
suspend fun saveGibberish(
|
||||||
|
@RequestBody request: GibberishRequest,
|
||||||
|
@AuthenticationPrincipal user: UserDetails?
|
||||||
|
): ResponseEntity<Any> {
|
||||||
|
if (user == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "Login required"))
|
||||||
|
}
|
||||||
|
if (request.content.isBlank() || request.content.length > 100) {
|
||||||
|
return ResponseEntity.badRequest().body(mapOf("message" to "Content length must be 1-100"))
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
// 인코딩 처리
|
||||||
postManager.deletePost(postId).awaitFirstOrNull()
|
val encodedContent = URLEncoder.encode(request.content, "UTF-8")
|
||||||
ResponseEntity.ok(mapOf("message" to "게시물이 성공적으로 삭제되었습니다."))
|
|
||||||
} catch (e: Exception) {
|
return if (!request.id.isNullOrBlank()) {
|
||||||
logService.log("게시물 삭제 중 오류 발생: ID=$postId, 오류=${e.message}")
|
// --- Update ---
|
||||||
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(mapOf("message" to "삭제 중 서버 오류가 발생했습니다."))
|
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(
|
||||||
|
title = URLEncoder.encode(request.content.take(20), "UTF-8"), // 제목은 내용 앞부분
|
||||||
|
content = encodedContent,
|
||||||
|
writer = user.username,
|
||||||
|
writeTime = System.currentTimeMillis(),
|
||||||
|
modifyTime = System.currentTimeMillis(),
|
||||||
|
posting = true,
|
||||||
|
postType = PostType.GIBBERISH.name
|
||||||
|
)
|
||||||
|
val saved = postManager.save(newPost).awaitSingle()
|
||||||
|
ResponseEntity.status(HttpStatus.CREATED).body(saved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- 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")
|
@PostMapping("/post/{postId}/block")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
fun blockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
|
fun blockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
|
||||||
return postManager.blockPost(postId)
|
return postManager.blockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build())
|
||||||
.map { ResponseEntity.ok(it) }
|
|
||||||
.defaultIfEmpty(ResponseEntity.notFound().build())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/post/{postId}/unblock")
|
@PostMapping("/post/{postId}/unblock")
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
fun unblockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
|
fun unblockPost(@PathVariable postId: String): Mono<ResponseEntity<Post>> {
|
||||||
return postManager.unblockPost(postId)
|
return postManager.unblockPost(postId).map { ResponseEntity.ok(it) }.defaultIfEmpty(ResponseEntity.notFound().build())
|
||||||
.map { ResponseEntity.ok(it) }
|
|
||||||
.defaultIfEmpty(ResponseEntity.notFound().build())
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/posts/{postId}/comments.bjx")
|
|
||||||
fun addComment(
|
|
||||||
@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,
|
|
||||||
@AuthenticationPrincipal user: UserDetails?
|
|
||||||
): Mono<ResponseEntity<Any>> {
|
|
||||||
if (user == null) {
|
|
||||||
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(mapOf("message" to "로그인이 필요합니다.")))
|
|
||||||
}
|
|
||||||
if (request.content.isBlank() || request.content.length > 100) {
|
|
||||||
return Mono.just(ResponseEntity.badRequest().body(mapOf("message" to "내용은 1자 이상 100자 이하로 작성해야 합니다.")))
|
|
||||||
}
|
|
||||||
|
|
||||||
val newPost = Post(
|
|
||||||
title = URLEncoder.encode(request.content.take(20), "UTF-8"),
|
|
||||||
content = URLEncoder.encode(request.content, "UTF-8"),
|
|
||||||
writer = user.username,
|
|
||||||
writeTime = System.currentTimeMillis(),
|
|
||||||
modifyTime = System.currentTimeMillis(),
|
|
||||||
posting = true,
|
|
||||||
postType = PostType.GIBBERISH.name
|
|
||||||
)
|
|
||||||
|
|
||||||
return postManager.save(newPost)
|
|
||||||
.map { savedPost -> ResponseEntity.status(HttpStatus.CREATED).body(savedPost as Any) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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
|
|
||||||
|
|
||||||
val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull()
|
// Gibberish 작성 폼용 (랜덤 문구는 이제 필요 없으면 제거 가능)
|
||||||
if (randomGibberish != null) {
|
val randomGibberish = postManager.findRandomGibberish().awaitSingleOrNull()
|
||||||
vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8")
|
if (randomGibberish != null) {
|
||||||
vm.modelMap["gibberishId"] = randomGibberish.id
|
vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8")
|
||||||
}
|
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")
|
||||||
|
var viewerDto: PostViewerDto? = null
|
||||||
|
|
||||||
|
// 1. Post 조회
|
||||||
try {
|
try {
|
||||||
val post = postManager.getPost(postId).awaitSingleOrNull()
|
val post = postManager.getPost(id).awaitSingleOrNull()
|
||||||
?: return ResultMV("redirect:/blog/posts")
|
if (post != null) {
|
||||||
|
val processed = processPostForView(post)
|
||||||
|
val isWriter = userDetails?.username == post.writer
|
||||||
|
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
|
||||||
|
|
||||||
val isWriter = userDetails?.username == post.writer
|
if (!post.posting && !isWriter && !isAdmin) return ResultMV("redirect:/")
|
||||||
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
|
|
||||||
|
|
||||||
if (!post.posting && !isWriter && !isAdmin) {
|
viewerDto = post.writer?.let {
|
||||||
logService.log("Access denied for user ${userDetails?.username} to post ${post.id}")
|
PostViewerDto(
|
||||||
return ResultMV("redirect:/blog/posts")
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val processedPost = processPostForView(post)
|
|
||||||
vm.modelMap["srcPost"] = processedPost
|
|
||||||
vm.modelMap["srcPostJson"] = objectMapper.writeValueAsString(processedPost)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return ResultMV("redirect:/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
23
src/main/kotlin/kr/lunaticbum/back/lun/model/Comment.kt
Normal file
23
src/main/kotlin/kr/lunaticbum/back/lun/model/Comment.kt
Normal 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
|
||||||
|
)
|
||||||
77
src/main/kotlin/kr/lunaticbum/back/lun/model/FeedItem.kt
Normal file
77
src/main/kotlin/kr/lunaticbum/back/lun/model/FeedItem.kt
Normal 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? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
|||||||
@ -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? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
|
|
||||||
)
|
|
||||||
@ -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>
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
|
}
|
||||||
}
|
|
||||||
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/main/kotlin/kr/lunaticbum/back/lun/utils/ThymeleafUtils.kt
Normal file
125
src/main/kotlin/kr/lunaticbum/back/lun/utils/ThymeleafUtils.kt
Normal 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", " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|
||||||
</th:block>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<th:block layout:fragment="content" id="content">
|
|
||||||
|
|
||||||
|
|
||||||
<section class="wrapper style2" >
|
|
||||||
<th:block sec:authorize="isAuthenticated()">
|
|
||||||
<div class="container" >
|
|
||||||
<header class="major">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<input type="checkbox" id="post-published-switch" name="posting" th:checked="${srcPost.posting}" class="custom-checkbox" />
|
|
||||||
<label for="post-published-switch" class="custom-label"></label>
|
|
||||||
</div>
|
|
||||||
<div class="write_controllbox" style="margin-top: -1em; margin-bottom: 2em;">
|
|
||||||
<div class="write_option btn-example controlbox-category" to="#popLayer1">
|
|
||||||
</div>
|
|
||||||
<div class="write_option btn-example controlbox-hashtag" to="#popLayer2" id="hashtag_field">
|
|
||||||
</div>
|
|
||||||
<div class="write_option btn-example controlbox-location" id="location_field"></div>
|
|
||||||
</div>
|
|
||||||
<div class="manual-input-section" style="margin-bottom: 2em; padding: 1.5em; border: 1px solid #ddd; border-radius: 4px; background-color: #f9f9f9;">
|
|
||||||
<h4 style="margin-bottom: 1em; font-size: 1.1em; color: #333;"><i class="icon solid fa-cog" style="margin-right: 0.5em;"></i>수동 설정</h4>
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5em;">
|
|
||||||
<div>
|
|
||||||
<label for="manual_date" style="font-weight: bold; margin-bottom: 0.5em; display: block;">작성일</label>
|
|
||||||
<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>
|
|
||||||
<p style="font-size: 0.8em; color: #777; margin-top: 1em;">* 작성일이나 좌표를 직접 입력하면 자동으로 측정된 GPS 정보 대신 입력된 값이 저장됩니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</th:block>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="wrapper style1">
|
|
||||||
<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" />
|
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
|
||||||
<!-- <script src="https://unpkg.com/quill-image-resize@3.0.9/image-resize.min.js" defer></script>-->
|
|
||||||
<!-- <script th:src="@{/js/image-resize.min.js}"></script>-->
|
|
||||||
<script src="https://cdn.jsdelivr.net/gh/scrapooo/quill-resize-module@1.0.2/dist/quill-resize-module.js"></script>
|
|
||||||
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js" defer></script>
|
<style>
|
||||||
<!-- <script src="https://cdn.jsdelivr.net/npm/quill-image-resize-module@3.0.0/image-resize.min.js"></script>-->
|
/* 에디터 전용 스타일 */
|
||||||
<script defer>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
|
.editor-container { background: #fff; padding: 2em; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
|
||||||
<!-- <script defer>initEditor(true);</script>-->
|
.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 layout:fragment="content" id="content">
|
||||||
|
<section class="wrapper style1">
|
||||||
|
<div class="container">
|
||||||
|
<header class="major">
|
||||||
|
<h2 th:text="${pageTitle}">글 작성</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="post-title">제목</label>
|
||||||
|
<input type="text" id="post-title" th:value="${srcPost.title}" placeholder="제목을 입력하세요" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row gtr-uniform">
|
||||||
|
<div class="col-6 col-12-xsmall">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="post-category">카테고리</label>
|
||||||
|
<input type="text" id="post-category" list="category-list" th:value="${srcPost.category == 'none' ? '' : srcPost.category}" placeholder="예: IT, 일상, 여행" />
|
||||||
|
<datalist id="category-list">
|
||||||
|
<option value="IT/개발"></option>
|
||||||
|
<option value="일상"></option>
|
||||||
|
<option value="생각"></option>
|
||||||
|
<option value="리뷰"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.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>
|
||||||
|
|
||||||
|
<script th:inline="javascript">
|
||||||
|
/*<![CDATA[*/
|
||||||
|
const postData = JSON.parse(/*[[${srcPostJson}]]*/ '{}');
|
||||||
|
const apiBaseUrl = /*[[${apiBaseUrl}]]*/ '';
|
||||||
|
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>
|
||||||
@ -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>
|
<th:block layout:fragment="head">
|
||||||
<script th:src="@{/js/imagesloaded.pkgd.min.js}"></script>
|
|
||||||
<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">
|
|
||||||
<section id="banner"
|
|
||||||
th:styleappend="${randomBannerImage != null} ? |background-image: url('${apiBaseUrl}${randomBannerImage}');| : ''">
|
|
||||||
<header th:if="${gibberish != null}">
|
|
||||||
<h2>Bum's Gibberish: <em>[[${gibberish}]]</em></h2>
|
|
||||||
<a th:href="@{/blog/viewer/{id}(id=${gibberishId})}" class="button">코멘트 남기기<br>[Leave a Comment]</a>
|
|
||||||
</header>
|
|
||||||
<header th:if="${gibberish == null}">
|
|
||||||
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
|
|
||||||
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
|
|
||||||
</header>
|
|
||||||
</section>
|
|
||||||
<section class="wrapper style1" style="padding: 2em 0 1em 0;">
|
|
||||||
<div class="container">
|
|
||||||
<form action="/" method="get" id="search-form" style="display: flex; justify-content: center; gap: 10px;">
|
|
||||||
<input type="text" name="q" th:value="${searchQuery}" placeholder="관심 있는 내용을 검색해보세요 (예: Spring, 일상...)"
|
|
||||||
style="width: 50%; min-width: 300px; padding: 0.75em;" />
|
|
||||||
<button type="submit" class="button icon solid fa-search">검색</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div th:if="${searchQuery}" style="text-align: center; margin-top: 1em;">
|
<th:block layout:fragment="content" id="content">
|
||||||
<h3>'<span th:text="${searchQuery}" style="color: #e44c65;"></span>' 검색 결과</h3>
|
<section id="banner" th:style="'background-image: url(' + ${randomBannerImage} + '); background-size: cover; background-position: center;'">
|
||||||
<a th:href="@{/}" class="button small alt">전체 목록 돌아가기</a>
|
<div class="inner">
|
||||||
</div>
|
<header th:if="${gibberish != null}">
|
||||||
|
<h2>
|
||||||
|
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 th:if="${gibberish == null}">
|
||||||
|
<h2>BUM'sPace</h2>
|
||||||
|
<p>Welcome to my random thoughts and archives.</p>
|
||||||
|
</header>
|
||||||
</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">
|
<ins class="adsbygoogle"
|
||||||
<img th:if="${item.thumbnail}" th:src="${apiBaseUrl + item.thumbnail}"
|
style="display:block; width:100%; height:100%;"
|
||||||
alt="Thumbnail" th:onerror="|this.onerror=null; this.src='@{/images/pic01.jpg}';|" />
|
data-ad-client="ca-pub-9504446465764716"
|
||||||
<img th:unless="${item.thumbnail}" th:src="@{/images/pic01.jpg}" alt="Default Thumbnail" />
|
data-ad-slot="YOUR_AD_SLOT_ID"
|
||||||
</span>
|
data-ad-format="auto"
|
||||||
<div class="inner">
|
data-full-width-responsive="true"></ins>
|
||||||
<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>
|
||||||
|
|
||||||
|
<section class="feed-item" th:utext="${@thymeleafUtils.renderFeedItem(item, apiBaseUrl ?: '')}"></section>
|
||||||
|
</th:block>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12" style="text-align:center; margin-top:30px;">
|
||||||
|
<button id="btn-load-more" class="button big" th:if="${nextCursor != null}"
|
||||||
|
th:data-cursor="${nextCursor}" onclick="loadMoreFeed()">
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
<div id="loading-spinner" style="display:none; font-size:2em; color:#e44c65;">
|
||||||
|
<i class="icon solid fa-circle-notch fa-spin"></i>
|
||||||
</div>
|
</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"
|
|
||||||
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>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<div id="load-more-container" style="text-align: center; margin-top: 2em; margin-bottom: 2em;">
|
|
||||||
<button id="btn-load-more" class="button alt"
|
|
||||||
th:if="${nextCursor != null}"
|
|
||||||
th:data-cursor="${nextCursor}"
|
|
||||||
onclick="loadMoreFeed()">
|
|
||||||
More Stories <i class="icon solid fa-chevron-down"></i>
|
|
||||||
</button>
|
|
||||||
<div id="loading-spinner" style="display:none;">
|
|
||||||
<i class="icon solid fa-spinner fa-spin fa-2x"></i>
|
|
||||||
</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>
|
||||||
</div>
|
|
||||||
<div class="box highlight open-login-popup" sec:authorize="isAnonymous()" to="#loginPopup" style="cursor: pointer;">
|
<button class="fab-item" onclick="openSearch()" data-tooltip="검색">
|
||||||
<i class="icon solid major fa-pencil-alt"></i>
|
<i class="icon solid fa-search"></i>
|
||||||
<h3>글쓰기[Writing]</h3>
|
</button>
|
||||||
<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 class="fab-item" onclick="openGibberishModal()" data-tooltip="오늘의 헛소리">
|
||||||
</div>
|
<i class="icon solid fa-comment-dots"></i>
|
||||||
</section>
|
</button>
|
||||||
<section class="col-4 col-12-narrower">
|
<button class="fab-item" onclick="location.href='/blog/edit'" data-tooltip="새 글 작성">
|
||||||
<div class="box highlight">
|
<i class="icon solid fa-pen-nib"></i>
|
||||||
<i class="icon solid major fa-wrench"></i>
|
</button>
|
||||||
<h3>Probably Important</h3>
|
</div>
|
||||||
<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>
|
<div class="fab-container" sec:authorize="isAnonymous()" id="fabContainerPublic">
|
||||||
</section>
|
<button class="fab-main" onclick="openSearch()" style="background: #333;">
|
||||||
|
<i class="icon solid fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="gibberishModal" class="modal-overlay" onclick="closeGibberishModal(event)">
|
||||||
|
<div class="modal-box" onclick="event.stopPropagation()">
|
||||||
|
<h3 style="margin-bottom: 1em; color: #e44c65;"><i class="icon solid fa-quote-left"></i> 오늘의 헛소리</h3>
|
||||||
|
<textarea id="modal-gibberish-content" rows="4" placeholder="무슨 생각을 하고 계신가요?" style="resize: none; margin-bottom: 10px;"></textarea>
|
||||||
|
<div style="text-align: right; font-size: 0.8em; color: #888; margin-bottom: 1em;">
|
||||||
|
<span id="modal-char-count">0/100</span>
|
||||||
|
</div>
|
||||||
|
<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()">×</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">
|
||||||
</div>
|
<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>
|
||||||
</section>
|
</div>
|
||||||
<section id="cta2" class="wrapper style3">
|
|
||||||
<div class="container">
|
<script th:src="@{/js/imagesloaded.pkgd.min.js}"></script>
|
||||||
<header>
|
<script th:src="@{/js/masonry.pkgd.min.js}"></script>
|
||||||
<!-- <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; // [수정] 중복 요청 방지 플래그
|
||||||
|
|
||||||
function loadMoreFeed() {
|
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
|
||||||
const btn = document.getElementById('btn-load-more');
|
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
|
||||||
const spinner = document.getElementById('loading-spinner');
|
|
||||||
const container = document.getElementById('feed-container');
|
|
||||||
|
|
||||||
// 현재 커서 값 가져오기
|
// 레이아웃 재정렬 최적화 (Debounce)
|
||||||
const cursor = btn.getAttribute('data-cursor');
|
let layoutTimeout;
|
||||||
if (!cursor) return;
|
function debouncedLayout() {
|
||||||
|
clearTimeout(layoutTimeout);
|
||||||
// UI 상태 변경 (로딩 중)
|
layoutTimeout = setTimeout(() => {
|
||||||
btn.style.display = 'none';
|
if ($grid) $grid.layout();
|
||||||
spinner.style.display = 'inline-block';
|
}, 100);
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const query = urlParams.get('q') || '';
|
|
||||||
|
|
||||||
// API 호출 시 q 파라미터 포함
|
|
||||||
fetch(`/api/feed?cursor=${cursor}&q=${encodeURIComponent(query)}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
// 데이터가 없으면 버튼 숨기고 종료
|
|
||||||
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) {
|
|
||||||
btn.setAttribute('data-cursor', data.nextCursor);
|
|
||||||
btn.style.display = 'inline-block';
|
|
||||||
} else {
|
|
||||||
// 더 이상 불러올 게 없으면 버튼 제거
|
|
||||||
btn.remove();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Feed load error:', err);
|
|
||||||
alert('추가 콘텐츠를 불러오는 중 오류가 발생했습니다.');
|
|
||||||
btn.style.display = 'inline-block';
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
spinner.style.display = 'none';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON 데이터를 HTML 문자열로 변환하는 헬퍼 함수
|
|
||||||
function createFeedItemHtml(item) {
|
|
||||||
const dateStr = new Date(item.createdAt).toISOString().split('T')[0];
|
|
||||||
|
|
||||||
if (item.type === 'POST') {
|
|
||||||
const thumbSrc = item.thumbnail ? (apiBaseUrl + item.thumbnail) : '/images/pic01.jpg';
|
|
||||||
return `
|
|
||||||
<div class="box post" onclick="location.href='${item.url}'" style="cursor: pointer;">
|
|
||||||
<span class="image left"><img src="${thumbSrc}" onerror="this.onerror=null; this.src='/images/pic01.jpg';" /></span>
|
|
||||||
<div class="inner">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
else if (item.type === 'GIBBERISH') {
|
|
||||||
return `
|
|
||||||
<div class="box post gibberish-card" 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 style="font-size: 1.1em; font-weight: bold; color: #333;">${item.content}</span>
|
|
||||||
</blockquote>
|
|
||||||
<p style="text-align: right; font-size: 0.8em; color: #777;">${dateStr}</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
else if (item.type === 'BOOKMARK') {
|
|
||||||
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>` : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="box post bookmark-card" style="border: 1px dashed #3498db;">
|
|
||||||
<div class="inner" style="display: flex; align-items: center;">
|
|
||||||
${thumbImg}
|
|
||||||
<div style="flex-grow: 1;">
|
|
||||||
<h4>
|
|
||||||
<a href="${item.url}" target="_blank" style="text-decoration: none; color: #3498db;">
|
|
||||||
<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; color: #666; margin-bottom: 0.5em;">${item.content || ''}</p>
|
|
||||||
<p style="font-size: 0.8em; color: #999;">Saved on ${dateStr}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
/* home.html 하단 스크립트 */
|
|
||||||
<script th:inline="javascript">
|
|
||||||
var $grid; // Masonry 인스턴스 저장 변수
|
|
||||||
|
|
||||||
// 1. 페이지 로드 완료 후 초기화
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var gridElement = document.querySelector('.feed-grid');
|
var gridElement = document.querySelector('.feed-grid');
|
||||||
|
|
||||||
// 이미지 로딩이 완료된 후 레이아웃 실행 (겹침 방지)
|
// 1. Masonry 초기화
|
||||||
imagesLoaded(gridElement, function() {
|
imagesLoaded(gridElement, function() {
|
||||||
$grid = new Masonry(gridElement, {
|
$grid = new Masonry(gridElement, {
|
||||||
itemSelector: '.feed-item',
|
itemSelector: '.feed-item',
|
||||||
columnWidth: '.grid-sizer',
|
columnWidth: '.grid-sizer',
|
||||||
percentPosition: true
|
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() {
|
||||||
|
if (isFetching) return; // 이미 로딩 중이면 무시
|
||||||
|
isFetching = true;
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-load-more');
|
||||||
|
const cursor = btn.getAttribute('data-cursor');
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const query = urlParams.get('q') || '';
|
||||||
|
|
||||||
|
btn.style.display = 'none';
|
||||||
|
document.getElementById('loading-spinner').style.display = 'inline-block';
|
||||||
|
|
||||||
|
fetch(`/api/feed?cursor=${cursor}&q=${encodeURIComponent(query)}`)
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error("서버 응답 오류");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
// [핵심 1] 커서부터 먼저 갱신 (렌더링 에러가 나도 페이징은 넘어가도록)
|
||||||
|
if (data.nextCursor) {
|
||||||
|
btn.setAttribute('data-cursor', data.nextCursor);
|
||||||
|
} else {
|
||||||
|
btn.removeAttribute('data-cursor'); // 더 이상 데이터 없음
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
console.error("더보기 로드 실패:", err);
|
||||||
|
alert("데이터를 불러오는 중 문제가 발생했습니다.");
|
||||||
|
btn.style.display = 'inline-block'; // 에러 나면 버튼 다시 복구
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
document.getElementById('loading-spinner').style.display = 'none';
|
||||||
|
isFetching = false; // 로딩 락 해제
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFab() {
|
||||||
|
const container = document.getElementById('fabContainer');
|
||||||
|
const mainBtn = container.querySelector('.fab-main');
|
||||||
|
container.classList.toggle('active');
|
||||||
|
mainBtn.classList.toggle('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
const container = document.getElementById('fabContainer');
|
||||||
|
if(container && container.classList.contains('active')) {
|
||||||
|
toggleFab();
|
||||||
|
}
|
||||||
|
toggleSearchOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGibberishModal() {
|
||||||
|
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, '"');
|
||||||
|
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, '"').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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 썸네일 경로 처리 (null일 경우 대비)
|
||||||
|
const thumbUrl = item.thumbnail ? (item.thumbnail.startsWith('http') ? item.thumbnail : (apiBaseUrl + item.thumbnail)) : '/images/pic01.jpg';
|
||||||
|
|
||||||
|
if (item.type === 'POST') {
|
||||||
|
return `
|
||||||
|
<div class="box post" onclick="location.href='${item.url}'" style="cursor:pointer; position:relative;">
|
||||||
|
${editBtnHtml}
|
||||||
|
<span class="image left">
|
||||||
|
<img src="${thumbUrl}" onerror="${imgErrorHandler}" />
|
||||||
|
</span>
|
||||||
|
<div class="inner">
|
||||||
|
<h3>${item.title || 'Untitled'}</h3>
|
||||||
|
<p style="font-size:0.9em;color:#555;">${dateStr} by ${item.writer || 'Anonymous'}</p>
|
||||||
|
<p>${item.content ? item.content.substring(0, 100) + '...' : ''}</p>
|
||||||
|
${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>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
} else if (item.type === 'BOOKMARK') {
|
||||||
|
const viewerUrl = `/blog/viewer/${item.id}`;
|
||||||
|
const thumbImg = item.thumbnail ?
|
||||||
|
`<div style="margin-bottom:10px;"><img src="${thumbUrl}" style="width:100%; height:150px; object-fit:cover; border-radius:4px;" onerror="${imgErrorHandler}"></div>` : '';
|
||||||
|
return `
|
||||||
|
<div class="box post" onclick="location.href='${viewerUrl}'" style="border:1px dashed #3498db; position:relative; cursor:pointer;">
|
||||||
|
${editBtnHtml}
|
||||||
|
<div class="inner">
|
||||||
|
${thumbImg}
|
||||||
|
<h4>
|
||||||
|
<a href="${item.url}" target="_blank" style="color:#3498db;" onclick="event.stopPropagation()">
|
||||||
|
<i class="icon solid fa-bookmark"></i> ${item.title || 'Bookmark'} <i class="icon solid fa-external-link-alt" style="font-size:0.7em;"></i>
|
||||||
|
</a>
|
||||||
|
</h4>
|
||||||
|
<p style="font-size:0.9em;">${item.content || ''}</p>
|
||||||
|
${metaHtml}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
@ -3,147 +3,127 @@
|
|||||||
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 id="content_inner">
|
||||||
<div class="col-12">
|
|
||||||
<div id="content_inner">
|
|
||||||
<article>
|
|
||||||
<header class="major" th:if="${filterTitle}">
|
|
||||||
<h2 th:text="${filterTitle}">필터링된 게시물</h2>
|
|
||||||
<p><a th:href="@{/blog/posts}">모든 글 보기</a></p>
|
|
||||||
</header>
|
|
||||||
<th:block th:each="post, iterStat : ${postsPage.content}">
|
|
||||||
<section th:id="'post-section-' + ${post.id}">
|
|
||||||
<div class="box post" th:id="${post.id}">
|
|
||||||
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left">
|
|
||||||
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}"
|
|
||||||
alt="Post Thumbnail"
|
|
||||||
th:onerror="|this.onerror=null; this.src='@{/images/bum.png}';|" />
|
|
||||||
</a>
|
|
||||||
<div class="inner">
|
|
||||||
<h3 style="display: flex; justify-content: space-between; align-items: center;">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</h3>
|
|
||||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p>
|
|
||||||
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
|
|
||||||
</div>
|
|
||||||
<footer sec:authorize="isAuthenticated()"
|
|
||||||
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
|
|
||||||
style="text-align: right; margin-top: 1em;">
|
|
||||||
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small">수정</a>
|
|
||||||
<button class="button small alt" th:onclick="handleDeletePostInList([[${post.id}]], event)">삭제</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<th:block th:if="${iterStat.count % 3 == 0}">
|
<header class="major" th:if="${filterTitle}" style="text-align: center; margin-bottom: 2em;">
|
||||||
<section>
|
<h2 th:text="${filterTitle}" style="font-size: 1.5em; color: #e44c65;">필터링된 게시물</h2>
|
||||||
<div class="box ad-container" style="padding: 2em; text-align: center;">
|
<p><a th:href="@{/}" class="button small">홈으로 돌아가기</a></p>
|
||||||
<p style="margin-bottom: 1em; color: #888;">- Advertisement -</p>
|
</header>
|
||||||
<ins class="adsbygoogle"
|
<header class="major" th:unless="${filterTitle}" style="text-align: center; margin-bottom: 2em;">
|
||||||
style="display:block"
|
<h2>전체 글 목록</h2> </header>
|
||||||
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>
|
|
||||||
</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="feed-grid">
|
||||||
<ul class="pagination" style="display: inline-block; padding-left: 0; list-style: none; border-radius: 5px; border: 1px solid #e0e0e0; overflow: hidden;">
|
<div class="grid-sizer"></div>
|
||||||
<li th:styleappend="${postsPage.isFirst()} ? 'opacity: 0.5; pointer-events: none;' : ''" style="display: inline; float: left;">
|
|
||||||
<a th:href="${postsPage.isFirst()} ? '#' : @{/blog/posts(page=${postsPage.number - 1}, category=${currentCategory}, tag=${currentTag})}"
|
<section th:each="post : ${postsPage.content}" class="feed-item" th:id="'post-section-' + ${post.id}">
|
||||||
class="button alt small" style="border-radius:0; margin:0; border-right: 1px solid #e0e0e0;">
|
<div class="box post" th:onclick="|location.href='@{/blog/viewer/{id}(id=${post.id})}'|" style="cursor: pointer;">
|
||||||
« Prev
|
<span class="image left">
|
||||||
</a>
|
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}"
|
||||||
</li>
|
alt="Thumb" th:onerror="|this.onerror=null; this.src='@{/images/pic01.jpg}';|" />
|
||||||
<li th:each="pageNum : ${#numbers.sequence(0, postsPage.totalPages - 1)}"
|
</span>
|
||||||
style="display: inline; float: left; border-right: 1px solid #e0e0e0;">
|
<div class="inner">
|
||||||
<a th:href="@{/blog/posts(page=${pageNum}, category=${currentCategory}, tag=${currentTag})}"
|
<h3 style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
th:text="${pageNum + 1}"
|
<span>
|
||||||
th:class="${pageNum == postsPage.number} ? 'button small' : 'button alt small'"
|
<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>
|
||||||
style="border-radius:0; margin:0;">
|
<span th:text="${post.title}">제목</span>
|
||||||
</a>
|
</span>
|
||||||
</li>
|
</h3>
|
||||||
<li th:styleappend="${postsPage.isLast()} ? 'opacity: 0.5; pointer-events: none;'" style="display: inline; float: left;">
|
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p>
|
||||||
<a th:href="${postsPage.isLast()} ? '#' : @{/blog/posts(page=${postsPage.number + 1}, category=${currentCategory}, tag=${currentTag})}"
|
<p th:text="${#strings.abbreviate(post.html, 80)}">내용요약</p>
|
||||||
class="button alt small" style="border-radius:0; margin:0;">
|
</div>
|
||||||
Next »
|
|
||||||
</a>
|
<footer sec:authorize="isAuthenticated()"
|
||||||
</li>
|
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
|
||||||
</ul>
|
style="padding: 1em; border-top: 1px solid #eee; text-align: right;" onclick="event.stopPropagation();">
|
||||||
</nav>
|
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small icon solid fa-pen"></a>
|
||||||
</div>
|
<button class="button small alt icon solid fa-trash" th:onclick="handleDeletePostInList([[${post.id}]], event)"></button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-wrapper" th:if="${postsPage.totalPages > 1}">
|
||||||
|
<ul class="pagination">
|
||||||
|
<li th:classappend="${postsPage.isFirst()} ? 'disabled'">
|
||||||
|
<a th:href="${postsPage.isFirst()} ? '#' : @{/blog/posts(page=${postsPage.number - 1}, category=${currentCategory}, tag=${currentTag})}">« Prev</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li th:each="pageNum : ${#numbers.sequence(0, postsPage.totalPages - 1)}">
|
||||||
|
<a th:href="@{/blog/posts(page=${pageNum}, category=${currentCategory}, tag=${currentTag})}"
|
||||||
|
th:text="${pageNum + 1}"
|
||||||
|
th:classappend="${pageNum == postsPage.number} ? 'active'">1</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li th:classappend="${postsPage.isLast()} ? 'disabled'">
|
||||||
|
<a th:href="${postsPage.isLast()} ? '#' : @{/blog/posts(page=${postsPage.number + 1}, category=${currentCategory}, tag=${currentTag})}">Next »</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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('이 게시물을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) {
|
method: 'DELETE',
|
||||||
fetch(`/blog/post/${cleanPostId}`, {
|
headers: { [csrfHeader]: csrfToken }
|
||||||
method: 'DELETE',
|
})
|
||||||
headers: {
|
.then(res => {
|
||||||
[csrfHeader]: csrfToken
|
if (res.ok) {
|
||||||
|
alert('삭제되었습니다.');
|
||||||
|
// Masonry 레이아웃 재조정이 복잡하므로 간단히 새로고침
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('삭제 실패');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
return response.json();
|
|
||||||
} else {
|
|
||||||
// 서버에서 에러 메시지를 보냈을 경우를 대비
|
|
||||||
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>
|
||||||
|
|||||||
@ -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 th:if="${srcPost.images.size() > 1}" class="row gtr-50 gtr-uniform">
|
||||||
|
<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>
|
||||||
|
|
||||||
<h3 id="write" th:text="${srcPost.firstAddress}"></h3>
|
|
||||||
<h3 id="modify" th:text="${srcPost.modifyAddress}"></h3>
|
<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>
|
|
||||||
<textarea id="comment-input" placeholder="댓글을 입력하세요..."></textarea>
|
|
||||||
<button id="submit-comment" class="button">등록</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-list">
|
|
||||||
|
<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 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>
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user