This commit is contained in:
lunaticbum 2025-09-08 16:35:09 +09:00
parent 26a0f14e54
commit 51b97e2422
17 changed files with 1328 additions and 258 deletions

View File

@ -44,14 +44,13 @@ class SecurityConfig(
@Bean @Bean
fun webSecurityCustomizer(): WebSecurityCustomizer { fun webSecurityCustomizer(): WebSecurityCustomizer {
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. (권한 검사 불필요)
return WebSecurityCustomizer { web -> return WebSecurityCustomizer { web ->
web.ignoring().requestMatchers("/blog/post/images/**") web.ignoring().requestMatchers("/blog/post/images/**")
} }
} }
// RememberMeServices를 Bean으로 생성하고 필드에 할당하거나, 생성자 주입을 할 수 있음 // RememberMeServices를 Bean으로 생성하고 필드에 할당하거나, 생성자 주입을 할 수 있음
@Bean @Bean
fun rememberMeServices(): RememberMeServices { fun rememberMeServices(): RememberMeServices {
val key = "your-remember-me-key" val key = "your-remember-me-key"
@ -68,15 +67,24 @@ class SecurityConfig(
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.csrf { csrf -> http.csrf { csrf ->
// [수정]
// CSRF 보호 예외 목록에서 like/unlike 엔드포인트를 제거합니다.
// 이 엔드포인트들은 POST 요청이며 인증이 필요하므로, CSRF 보호를 받는 것이 올바릅니다.
// (common.js에서 X-CSRF-TOKEN 헤더를 정상적으로 보내고 있습니다.)
csrf.ignoringRequestMatchers( csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx", "/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx", "/blog/post/imageUpload.bjx", "/blog/post.bjx",
"/blog/post/images/**","/puzzle/**","/puzzle/play/**", // "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨
"/puzzle/**","/puzzle/play/**",
"/rank/**","/spider/**", "/rank/**","/spider/**",
"/sudoku/**", "/sudoku/**",
) // 여기 예외 추가 )
}.authorizeHttpRequests { auth -> }.authorizeHttpRequests { auth ->
auth auth
// [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다.
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/like.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll()
// permitAll() 목록
.requestMatchers( .requestMatchers(
"/", "/",
"/home.bs", "/home.bs",
@ -84,12 +92,13 @@ class SecurityConfig(
"/tlg/repotToMe.bjx", "/tlg/repotToMe.bjx",
"/user/login.bs", "/user/signup.bs","/user/login.bjx", "/user/login.bs", "/user/signup.bs","/user/login.bjx",
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx", "/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
// "/blog/post/imageUpload.bjx", // "/blog/post/images/**", // WebSecurityCustomizer에서 ignoring 처리되었으므로 여기서 제외해도 됩니다.
"/blog/post/images/**",
"/spider/new**", "/spider/new**",
"/rank/**","/sudoku/**","/spider/**", "/rank/**","/sudoku/**","/spider/**",
"/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku","/puzzle/spider", "/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku","/puzzle/spider",
"/webfonts/**", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll() "/webfonts/**", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
// 나머지 모든 요청은 인증이 필요합니다.
.anyRequest().authenticated() .anyRequest().authenticated()
}.formLogin { form -> }.formLogin { form ->
form.loginPage("/user/login.bs") form.loginPage("/user/login.bs")

View File

@ -25,9 +25,12 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
import org.springframework.core.io.UrlResource import org.springframework.core.io.UrlResource
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.web.PageableDefault
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@ -36,6 +39,7 @@ import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.io.* import java.io.*
import java.net.URLDecoder import java.net.URLDecoder
import java.security.Principal
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -77,6 +81,7 @@ class BlogController(private val commentService : CommentService) {
fun post(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> { fun post(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log(httpServletRequest.requestURI) logService.log(httpServletRequest.requestURI)
logService.log(jsonString) logService.log(jsonString)
var postId = ""
var lResultCode = 0 var lResultCode = 0
var lResultMsg = "Suscces" var lResultMsg = "Suscces"
val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString) val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString)
@ -93,27 +98,79 @@ class BlogController(private val commentService : CommentService) {
var max = nb.size + na.size var max = nb.size + na.size
var fullData = arrayListOf<String>() var fullData = arrayListOf<String>()
for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } } for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } }
// 1. 클라이언트 JSON을 가져옵니다. (콘솔에서 정상 확인됨)
val jsonFromClient = fullData.joinToString("")
logService.log(fullData.joinToString("")) logService.log(fullData.joinToString(""))
var target = Gson().fromJson(fullData.joinToString(""), Post::class.java) ?: Post()
// 2. JSON을 객체로 "단 한 번만" 파싱합니다.
var target = Gson().fromJson(jsonFromClient, Post::class.java) ?: Post()
if (target.writeTime < 1L) { if (target.writeTime < 1L) {
target.id = null // === A. 신규 게시물 저장 로직 ===
target.id = null // 새 문서이므로 ID는 null
target.writeTime = System.currentTimeMillis() target.writeTime = System.currentTimeMillis()
// [버그 수정] 새 글 저장 시 modifyTime을 writeTime과 동일하게 설정
target.modifyTime = target.writeTime
// [신규 추가] 새 글 저장 시, 현재 인증된 사용자를 'writer'로 설정
try {
val authentication = SecurityContextHolder.getContext().authentication
val username = (authentication.principal as? UserDetails)?.username ?: authentication.name
target.writer = username
} catch (e: Exception) {
target.writer = "Anonymous" // 인증 정보 가져오기 실패 시
}
// 3. 새 마스터 게시물을 저장하고, 저장된 객체(ID 포함)를 받아옵니다.
val savedPost = postManager.save(target).block() // 동기식으로 저장 완료 대기
if (savedPost?.id != null) {
lResultCode = 0
lResultMsg = "New post saved"
// 4. 새로 생성된 마스터 게시물의 ID를 반환합니다.
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
this.resultCode = lResultCode
this.resultMsg = lResultMsg
this.data.put("postId" , savedPost.id!!)
})
return responce
} else {
lResultMsg = "Failed to save new post"
lResultCode = 7200
}
} else { } else {
logService.log("target.writeTime >>> ${target.writeTime}") // === B. 기존 게시물 수정 (새 버전 생성) 로직 ===
// (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음)
// 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다.
target.modifyTime = System.currentTimeMillis() target.modifyTime = System.currentTimeMillis()
postManager.save(target)
target = Gson().fromJson(fullData.joinToString(""), Post::class.java) ?: Post() // 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다.
target.originId = target.id target.originId = target.id // 원본 마스터 ID를 originId 필드에 저장
target.id = null target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경
}
var postMono = postManager.save(target) // 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다.
if (postMono != null) { val savedVersionPost = postManager.save(target).block() // 동기식으로 저장 완료 대기
lResultMsg = "save post"
lResultCode = 0 if (savedVersionPost?.id != null) {
} else { lResultCode = 0
lResultMsg = "not founding user[can't find same id,email.. ]" lResultMsg = "New post version saved"
lResultCode = 7100 // 6. 새로 생성된 "버전 문서"의 ID를 클라이언트에게 반환합니다.
val responce = ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
this.resultCode = lResultCode
this.resultMsg = lResultMsg
this.data.put("postId",savedVersionPost.id!!)
})
return responce
} else {
lResultMsg = "Failed to save post version"
lResultCode = 7300
}
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
lResultMsg = "unknown exception" lResultMsg = "unknown exception"
@ -126,6 +183,7 @@ class BlogController(private val commentService : CommentService) {
}.contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply { }.contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
this.resultCode = lResultCode this.resultCode = lResultCode
this.resultMsg = lResultMsg this.resultMsg = lResultMsg
this.data.put("postId",postId)
}) })
return responce return responce
} }
@ -183,9 +241,8 @@ class BlogController(private val commentService : CommentService) {
} else { } else {
this?.content = Gson().toJson(this?.content) this?.content = Gson().toJson(this?.content)
} }
this?.category = URLDecoder.decode(this?.category?:"", "UTF-8")
this?.tags = URLDecoder.decode(this?.tags ?:"", "UTF-8")
globalEvv.gapiKey?.let { globalEvv.gapiKey?.let {
if (this?.firstAddress?.length ?: 0 < 4){ if (this?.firstAddress?.length ?: 0 < 4){
@ -209,30 +266,30 @@ class BlogController(private val commentService : CommentService) {
} }
@GetMapping("modify.bs") // @GetMapping("modify.bs")
fun modify(httpServletRequest: HttpServletRequest, @RequestParam("token") token : String?) : ResultMV{ // fun modify(httpServletRequest: HttpServletRequest, @RequestParam("token") token : String?) : ResultMV{
logService.log("incoming modify") // logService.log("incoming modify")
val vm = ResultMV("content/blog/modify") // val vm = ResultMV("content/blog/modify")
val authentication = SecurityContextHolder.getContext().authentication // val authentication = SecurityContextHolder.getContext().authentication
val principal = authentication.principal // val principal = authentication.principal
if (principal is UserDetails) { // if (principal is UserDetails) {
val username = principal.username // val username = principal.username
// 추가 정보 사용 가능 // // 추가 정보 사용 가능
postManager.find20()?.apply { // postManager.find20()?.apply {
forEach { // forEach {
it.title = URLDecoder.decode(it.title) // it.title = URLDecoder.decode(it.title)
val content = URLDecoder.decode(it.content) // val content = URLDecoder.decode(it.content)
it.content = if (content.length > 50) content.substring(0,150) else content // it.content = if (content.length > 50) content.substring(0,150) else content
} // }
vm.modelMap.put("chunkedPosts", this.chunked(3)) // vm.modelMap.put("chunkedPosts", this.chunked(3))
} // }
vm.modelMap.put(WRITE_PERMISSION_KEY,"OK") // vm.modelMap.put(WRITE_PERMISSION_KEY,"OK")
vm.modelMap.put("path","editor/") // vm.modelMap.put("path","editor/")
vm.modelMap.put("SK",token) // vm.modelMap.put("SK",token)
} // }
vm.modelMap.put("rowKey","chunkedPosts_") // vm.modelMap.put("rowKey","chunkedPosts_")
return vm // return vm
} // }
// @GetMapping("editor/{postId}") // @GetMapping("editor/{postId}")
// fun editor(@PathVariable postId : String) : ResultMV{ // fun editor(@PathVariable postId : String) : ResultMV{
@ -264,6 +321,10 @@ class BlogController(private val commentService : CommentService) {
postManager.getPost(postId).block()?.apply { postManager.getPost(postId).block()?.apply {
this.title = URLDecoder.decode(this.title) this.title = URLDecoder.decode(this.title)
this.content = URLDecoder.decode(this.content) this.content = URLDecoder.decode(this.content)
this.category = URLDecoder.decode(this.category, "UTF-8")
this.tags = URLDecoder.decode(this.tags, "UTF-8")
vm.modelMap["srcPost"] = this vm.modelMap["srcPost"] = this
vm.modelMap["pageTitle"] = "글 수정" // 페이지 제목을 동적으로 설정 vm.modelMap["pageTitle"] = "글 수정" // 페이지 제목을 동적으로 설정
} }
@ -277,30 +338,72 @@ class BlogController(private val commentService : CommentService) {
return vm return vm
} }
/**
* [수정] /posts 엔드포인트 로직
* @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8 설정
*/
@GetMapping("posts") @GetMapping("posts")
fun posts(pageable: Pageable) : ResultMV{ fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가
val vm = ResultMV("content/blog/posts") val vm = ResultMV("content/blog/posts")
try { try {
vm.modelMap.put("Posts", postManager.find20(pageable).apply { val postsList: List<Post>
this.forEach { val totalPosts: Long
println("it.id ==> ${it.id}")
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content) // [수정] 사용자의 권한 중 'ROLE_ADMIN'이 있는지 확인합니다.
val parser: Parser = Parser.builder().build() val isAdmin = authentication?.authorities?.any { it.authority == "ROLE_ADMIN" } ?: false
val document: Node = parser.parse(it.content)
val renderer = HtmlRenderer.builder().build() if (isAdmin) {
Jsoup.parse(renderer.render(document))?.let { doc -> // [관리자]: 모든 버전의 글을 조회합니다.
val firstImg: Element? = doc.select("img")?.first() logService.log("User is ADMIN. Loading all post versions.")
val imgSrc: String = firstImg?.attr("src") ?: "" postsList = postManager.findAllVersionsPaginated(pageable)
it.image = imgSrc totalPosts = postManager.countAllVersions().block() ?: 0L
it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail.")) } else {
generateThumbnail(imgSrc.split("/").last(), 200) // [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다.
it.html = doc.text() logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.")
} postsList = postManager.findLatestUniquePaginated(pageable)
it.title = if ((it.title?.length ?: 0) >= 1) it.title else "" totalPosts = postManager.countLatestUnique().block() ?: 0L
}
// if (principal != null) {
// // [인증 사용자]: 모든 버전의 글을 조회합니다.
// postsList = postManager.findAllVersionsPaginated(pageable) // 이름 변경된 메서드 호출
// totalPosts = postManager.countAllVersions().block() ?: 0L
// } else {
// // [익명 사용자]: 고유한 최신 버전의 글만 조회합니다.
// postsList = postManager.findLatestUniquePaginated(pageable) // 신규 메서드 호출
// totalPosts = postManager.countLatestUnique().block() ?: 0L
// }
// 조회된 목록에 대해 Jsoup 파싱 등 후처리 (기존 로직과 동일)
postsList.forEach {
println("it.id ==> ${it.id}")
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
val parser: Parser = Parser.builder().build()
val document: Node = parser.parse(it.content)
val renderer = HtmlRenderer.builder().build()
Jsoup.parse(renderer.render(document))?.let { doc ->
val firstImg: Element? = doc.select("img")?.first()
val imgSrc: String = firstImg?.attr("src") ?: ""
it.image = imgSrc
it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail."))
generateThumbnail(imgSrc.split("/").last(), 200)
it.html = doc.text()
} }
}) it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
}catch (ex: Exception){ex.printStackTrace()} }
// [수정] 결과를 List 대신 PageImpl 객체로 생성합니다.
val postsPage = PageImpl(postsList, pageable, totalPosts)
// [수정] 모델에 'Posts'(List) 대신 'postsPage'(Page)를 담습니다.
vm.modelMap.put("postsPage", postsPage)
} catch (ex: Exception) {
ex.printStackTrace()
// [수정] 에러 발생 시에도 빈 Page 객체를 전달합니다.
vm.modelMap.put("postsPage", PageImpl(emptyList<Post>(), pageable, 0))
}
return vm return vm
} }
@ -562,4 +665,31 @@ class BlogController(private val commentService : CommentService) {
})) }))
} }
} }
/**
* [신규 추가] '좋아요' 버튼 클릭 호출될 엔드포인트
* PostManager를 호출하여 voteCount를 1 증가시키고, 업데이트된 카운트를 Map으로 반환합니다.
*/
@PostMapping("post/{postId}/like.bjx")
fun likePost(@PathVariable postId: String): Mono<ResponseEntity<Map<String, Long>>> {
return postManager.incrementVote(postId)
.map { updatedPost ->
// JS에서 즉시 UI를 업데이트할 수 있도록 최신 카운트를 반환
ResponseEntity.ok(mapOf("voteCount" to updatedPost.voteCount, "unlikeCount" to updatedPost.unlikeCount))
}
.defaultIfEmpty(ResponseEntity.notFound().build()) // 해당 ID의 게시물이 없으면 404
}
/**
* [신규 추가] '싫어요' 버튼 클릭 호출될 엔드포인트
* PostManager를 호출하여 unlikeCount를 1 증가시키고, 업데이트된 카운트를 Map으로 반환합니다.
*/
@PostMapping("post/{postId}/unlike.bjx")
fun unlikePost(@PathVariable postId: String): Mono<ResponseEntity<Map<String, Long>>> {
return postManager.incrementUnlike(postId)
.map { updatedPost ->
ResponseEntity.ok(mapOf("voteCount" to updatedPost.voteCount, "unlikeCount" to updatedPost.unlikeCount))
}
.defaultIfEmpty(ResponseEntity.notFound().build())
}
} }

View File

@ -2,21 +2,20 @@ package kr.lunaticbum.back.lun.controllers
import com.google.gson.Gson import com.google.gson.Gson
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kr.lunaticbum.back.lun.configs.GlobalEnvironment import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.model.* import kr.lunaticbum.back.lun.model.*
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText import kr.lunaticbum.back.lun.utils.plainText
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.Page
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient // Spring Data Paging을 위한 Import 추가
import org.springframework.web.servlet.ModelAndView import org.springframework.data.domain.PageRequest
import java.util.Base64 import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import reactor.core.publisher.Flux
@RestController @RestController
@RequestMapping("/bums") @RequestMapping("/bums")
@ -31,12 +30,20 @@ class BumsPrivate {
lateinit var locationService: LocationLogService lateinit var locationService: LocationLogService
@GetMapping("where.bs") @GetMapping("where.bs")
fun where() : ResultMV { fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기
val m = ResultMV("content/private/where") val m = ResultMV("content/private/where")
locationService.find10().apply { // (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순)
m.modelMap.put("locations",this) // 예시: 날짜 필드명이 "createdAt"일 경우
} val pageable: Pageable = PageRequest.of(page, 30, Sort.by("time").descending())
// (3) 서비스 호출 변경 (List 대신 Page 객체를 반환하는 메서드 호출)
// 참고: locationService에 findAll(Pageable) 메서드가 구현되어 있어야 합니다.
val locationPage: Page<LocationLog> = locationService.findAll(pageable)
// (4) 모델에 Page 객체 전체를 전달
m.modelMap.put("locationPage", locationPage)
m.setTitle("돼지 여기있다요~!!") m.setTitle("돼지 여기있다요~!!")
return m return m
} }

View File

@ -153,7 +153,7 @@ class Home {
@GetMapping("/h2") @GetMapping("/h2")
fun home2() : ResultMV { fun home2() : ResultMV {
val vm = ResultMV("content/index_ex") val vm = ResultMV("content/index_ex")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply { vm.modelMap.put("Posts", postManager.find20().apply {
this.forEach { this.forEach {
it.title = URLDecoder.decode(it.title) it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content) it.content = URLDecoder.decode(it.content)
@ -166,7 +166,7 @@ class Home {
@GetMapping("/left-sidebar") @GetMapping("/left-sidebar")
fun lside() : ResultMV { fun lside() : ResultMV {
val vm = ResultMV("content/left-sidebar") val vm = ResultMV("content/left-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply { vm.modelMap.put("Posts", postManager.find20().apply {
this.forEach { this.forEach {
it.title = URLDecoder.decode(it.title) it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content) it.content = URLDecoder.decode(it.content)
@ -178,7 +178,7 @@ class Home {
@GetMapping("/no-sidebar") @GetMapping("/no-sidebar")
fun nside() : ResultMV { fun nside() : ResultMV {
val vm = ResultMV("content/no-sidebar") val vm = ResultMV("content/no-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply { vm.modelMap.put("Posts", postManager.find20().apply {
this.forEach { this.forEach {
it.title = URLDecoder.decode(it.title) it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content) it.content = URLDecoder.decode(it.content)
@ -191,7 +191,7 @@ class Home {
@GetMapping("/right-sidebar") @GetMapping("/right-sidebar")
fun rside() : ResultMV { fun rside() : ResultMV {
val vm = ResultMV("content/right-sidebar") val vm = ResultMV("content/right-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply { vm.modelMap.put("Posts", postManager.find20().apply {
this.forEach { this.forEach {
it.title = URLDecoder.decode(it.title) it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content) it.content = URLDecoder.decode(it.content)
@ -204,7 +204,7 @@ class Home {
@GetMapping("/two-sidebar") @GetMapping("/two-sidebar")
fun bside() : ResultMV { fun bside() : ResultMV {
val vm = ResultMV("content/two-sidebar") val vm = ResultMV("content/two-sidebar")
vm.modelMap.put("Posts", postManager.find20(Pageable.ofSize(20)).apply { vm.modelMap.put("Posts", postManager.find20().apply {
this.forEach { this.forEach {
it.title = URLDecoder.decode(it.title) it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content) it.content = URLDecoder.decode(it.content)

View File

@ -10,11 +10,12 @@ import org.bson.codecs.pojo.annotations.BsonIgnore
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Query import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClient
@ -22,9 +23,9 @@ import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
import org.springframework.data.domain.PageImpl
import java.time.format.DateTimeFormatter
class BumsPrivate { class BumsPrivate {
} }
@ -56,6 +57,24 @@ class LocationLog {
var bettween : String? = null var bettween : String? = null
val displayTime: String
get() {
// 1. timeString 값이 존재하고 비어있지 않으면, 그 값을 사용한다.
if (!this.timeString.isNullOrBlank()) {
return this.timeString!!
}
// 2. timeString이 없을 경우, 원본 logTime 객체가 있다면 포맷팅해서 반환한다.
if (this.time != null) {
// 원하는 날짜/시간 포맷 정의
val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
return formatter.format(Date(this.time))
}
// 3. 둘 다 없으면 "시간 없음"을 반환한다.
return "[시간 정보 없음]"
}
override fun toString(): String { override fun toString(): String {
val buffer = StringBuffer() val buffer = StringBuffer()
buffer.append(mFeatureName).append("|").append("\n") buffer.append(mFeatureName).append("|").append("\n")
@ -104,8 +123,28 @@ class LocationLogService : LocationService {
@Autowired @Autowired
private lateinit var logRepository: LocationLogRepository private lateinit var logRepository: LocationLogRepository
fun findAll(pageable: Pageable): Page<LocationLog> {
// 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()')
// Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다.
val items: List<LocationLog> = logRepository
.findAll(pageable.getSort())
.skip(pageable.getOffset())
.take(pageable.getPageSize().toLong())
.collectList() // Flux<T>를 Mono<List<T>>로 변환
.block() ?: emptyList() // Mono를 block()하여 실제 List<T>를 추출
// 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요)
val totalCount: Long = logRepository
.count() // Flux<Long> (count)
.block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출
// 3. Page 구현체(PageImpl)로 조합하여 반환
return PageImpl(items, pageable, totalCount)
}
fun find10() : List<LocationLog> { fun find10() : List<LocationLog> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 7) val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100)
println("sinceMills >> $sinceMills") println("sinceMills >> $sinceMills")
val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬 val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬
// val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") // val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

View File

@ -9,8 +9,10 @@ import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.mongodb.core.FindAndModifyOptions // [추가됨]
import org.springframework.data.mongodb.core.ReactiveMongoTemplate import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.mapping.Document import org.springframework.data.mongodb.core.mapping.Document
import org.springframework.data.mongodb.core.query.Criteria import org.springframework.data.mongodb.core.query.Criteria
@ -94,6 +96,11 @@ class CommentsResult {
var comments: List<Comment>? = null var comments: List<Comment>? = null
} }
/**
* @Aggregation의 $count 단계에서 결과를 매핑하기 위한 헬퍼 데이터 클래스입니다.
*/
data class AggregationCount(val totalCount: Long)
@Repository @Repository
interface CommentRepository : ReactiveMongoRepository<Comment, String> { interface CommentRepository : ReactiveMongoRepository<Comment, String> {
@ -121,19 +128,34 @@ class CommentService(private val commentRepository: CommentRepository) {
@Repository @Repository
interface PostRepository : ReactiveMongoRepository<Post, String> { interface PostRepository : ReactiveMongoRepository<Post, String> {
fun findAllByModifyTime(time : Long? = 0): Flux<Post> fun findAllByModifyTime(time : Long? = 0): Flux<Post>
// @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }") // @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }")
fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post> fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
fun countByOrderByModifyTimeDesc(): Mono<Long> fun countByOrderByModifyTimeDesc(): Mono<Long>
fun findTop5ByOrderByReadCountDesc(): Flux<Post> fun findTop5ByOrderByReadCountDesc(): Flux<Post>
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post> fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
/**
* 익명 사용자를 위한 '고유 최신 ' 목록을 페이지네이션으로 조회합니다.
* [버그 수정] 2 정렬 경로를 "post.post.modifyTime" -> "post.modifyTime" 으로 변경
*/
@Aggregation(pipeline = [ @Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }", "{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: \"\$originId\", post: { \$first: \"\$\$ROOT\" } } }", "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$sort: { \"post.modifyTime\": -1 } }", "{ \$sort: { \"post.modifyTime\": -1 } }", // [수정됨]
"{ \$limit: 8 }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }" "{ \$replaceRoot: { newRoot: \"\$post\" } }"
]) ])
fun findLatestUniqueOrigin(): Flux<Post> fun findLatestUniqueOriginPaginated(pageable: Pageable): Flux<Post>
/**
* '고유 최신 ' 개수를 카운트합니다. (페이지네이션의 totalElements 계산용)
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", // 고유 ID로 그룹화
"{ \$count: \"totalCount\" }" // 고유 그룹의 개수를 셈
])
fun countLatestUniqueOrigin(): Mono<AggregationCount> // 헬퍼 클래스로 매핑
} }
@ -147,17 +169,23 @@ class PostManager(
@Autowired @Autowired
private lateinit var bCryptPasswordEncoder: PasswordEncoder private lateinit var bCryptPasswordEncoder: PasswordEncoder
// fun getPost(id : String) : Mono<Post> = postRepository.findById(id)
fun getPost(id: String): Mono<Post> { fun getPost(id: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(id)) val query = Query.query(Criteria.where("id").`is`(id))
val update = Update().inc("readCount", 1) val update = Update().inc("readCount", 1)
// 이 메서드는 기본값(returnNew=false)를 사용하여, 증가되기 *전*의 문서를 반환합니다.
// (뷰어 로딩과 동시에 DB 카운트만 1 증가시킴)
return reactiveMongoTemplate.findAndModify(query, update, Post::class.java) return reactiveMongoTemplate.findAndModify(query, update, Post::class.java)
.switchIfEmpty(Mono.error(NoSuchElementException("Post not found with id $id"))) .switchIfEmpty(Mono.error(NoSuchElementException("Post not found with id $id")))
} }
fun find20(pageable :Pageable) : List<Post> { /**
* [이름 변경] find20 -> findAllVersionsPaginated
* 인증된 사용자를 위한 메서드 (모든 버전 조회)
*/
fun findAllVersionsPaginated(pageable :Pageable) : List<Post> {
println("pageSize >>> ${pageable.pageSize}") println("pageSize >>> ${pageable.pageSize}")
println("pageNumber >>> ${pageable.pageNumber}") println("pageNumber >>> ${pageable.pageNumber}")
return postRepository.findAllByOrderByModifyTimeDesc(pageable) return postRepository.findAllByOrderByModifyTimeDesc(pageable)
@ -167,6 +195,56 @@ class PostManager(
?: listOf() ?: listOf()
} }
/**
* 익명 사용자를 위한 메서드 (고유 최신 페이지네이션 조회)
*/
fun findLatestUniquePaginated(pageable: Pageable) : List<Post> {
return postRepository.findLatestUniqueOriginPaginated(pageable)
.collectList()
.block(Duration.ofSeconds(30)) ?: listOf()
}
/**
* 인증된 사용자가 보는 글의 개수
*/
fun countAllVersions(): Mono<Long> {
return postRepository.countByOrderByModifyTimeDesc()
}
/**
* 익명 사용자가 보는 글의 개수
*/
fun countLatestUnique(): Mono<Long> {
// AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다.
return postRepository.countLatestUniqueOrigin()
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
/**
* [신규 추가]
* 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 있도록 *업데이트된* 문서를 반환합니다.
*/
fun incrementVote(postId: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(postId))
val update = Update().inc("voteCount", 1)
// options().returnNew(true) : 업데이트된 후의 새 문서를 반환하도록 설정
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java)
}
/**
* [신규 추가]
* 싫어요 카운트를 1 증가시키고, *업데이트된* 문서를 반환합니다.
*/
fun incrementUnlike(postId: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(postId))
val update = Update().inc("unlikeCount", 1)
val options = FindAndModifyOptions.options().returnNew(true)
return reactiveMongoTemplate.findAndModify(query, update, options, Post::class.java)
}
fun getTop10Posts(): Flux<Post> { fun getTop10Posts(): Flux<Post> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p -> return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title) p.title = URLDecoder.decode(p.title)
@ -191,10 +269,15 @@ class PostManager(
} }
/**
* [로직 수정]
* 화면은 이제 "익명 사용자용 최신 글" 0 페이지, 8 아이템을 명시적으로 요청합니다.
*/
fun find8() : List<Post> { fun find8() : List<Post> {
return postRepository.findLatestUniqueOrigin().collectList() // Mono<List<Post>>로 변환 // Flux<Post> → Mono<List<Post>> // 홈 화면은 항상 0번 페이지의 8개 아이템을 요청합니다.
.block(Duration.ofSeconds(30)) ?: emptyList() val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
// 하드코딩된 쿼리 대신, 익명사용자용 페이지네이션 메서드를 호출합니다.
return this.findLatestUniquePaginated(pageRequest)
} }
fun find20() : List<Post> { fun find20() : List<Post> {

View File

@ -5,7 +5,7 @@ import lombok.Getter
@Getter @Getter
open class ResponceResult : BaseResult() { open class ResponceResult : BaseResult() {
var data : HashMap<String, String> = hashMapOf()
} }
@Getter @Getter

View File

@ -90,7 +90,7 @@ resource.handler=.
resource.location=. resource.location=.
server.forward-headers-strategy=framework server.forward-headers-strategy=framework
#>>>>>>> ab915d0a416c69708f1df1ad76d7a14c779c1f59 #>>>>>>> ab915d0a416c69708f1df1ad76d7a14c779c1f59
logging.level.org.thymeleaf=DEBUG
spring.jpa.show-sql=true spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.SQL=DEBUG

View File

@ -140,24 +140,28 @@ a.btn_layerClose:hover {
margin-top: 2em; margin-top: 2em;
padding: 0; padding: 0;
display: flex; display: flex;
justify-content: space-between; flex-direction: row; /* [수정] 아이템을 가로(row)로 정렬합니다. */
align-items: stretch; /* [수정] 모든 박스가 동일한 높이를 갖도록 stretch로 변경 */
list-style: none; list-style: none;
gap: 15px; /* Use gap for spacing */ gap: 15px; /* Use gap for spacing */
} }
.write_option { .write_option {
display: flex; /* Use flexbox for centering */ display: flex;
justify-content: center; flex-direction: column; /* [수정] 자식(제목, 내용)을 세로로 쌓습니다. */
align-items: center; justify-content: flex-start; /* 내용을 상단부터 표시합니다. */
text-align: center; align-items: flex-start; /* 모든 내용을 왼쪽 정렬합니다. */
width: 100%;
flex: 1 1 0%; /* [핵심] 1:1:1 비율로 동일한 너비를 갖도록 설정 */
min-width: 0; /* [추가] flex item이 내용 래핑을 위해 축소될 수 있도록 허용 */
padding: 0.75em; padding: 0.75em;
margin: 0; margin: 0;
background: var(--pure-white, #fff); background: var(--pure-white, #fff);
border: solid 1px #e0e0e0; border: solid 1px #e0e0e0;
border-radius: 5px; border-radius: 5px;
color: inherit; color: inherit;
min-height: 48px; /* Set a minimum height */ min-height: 48px;
} }
@ -178,11 +182,35 @@ a.btn_layerClose:hover {
} }
.ql-container.ql-snow { .ql-container.ql-snow {
min-width: unset;
background: var(--pure-white, #fff); background: var(--pure-white, #fff);
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
border-radius: 0 0 5px 5px; border-radius: 0 0 5px 5px;
color: var(--font-color_default, #474747); color: var(--font-color_default, #474747);
} }
/* * --- QUILL TOOLBAR SEPARATOR (구분선) ---
* 기본 'margin-right: 15px' 대신 패딩과 보더로 구분선을 추가합니다.
*/
.ql-toolbar.ql-snow .ql-formats {
margin-right: unset;
}
.ql-toolbar.ql-snow .ql-formats {
min-width: unset;
margin-right: 8px; /* 기본 마진 감소 */
padding-right: 8px; /* 보더 오른쪽에 패딩 추가 */
border-right: 1px solid #e0e0e0; /* 테마의 보더 색상으로 구분선 추가 */
}
/* 툴바의 마지막 그룹에는 구분선과 불필요한 여백을 제거합니다. */
.ql-toolbar.ql-snow .ql-formats:last-child {
min-width: unset;
margin-right: 0;
padding-right: 0;
border-right: none;
}
/* Ensure editor content uses the theme's default font and size */ /* Ensure editor content uses the theme's default font and size */
.ql-editor { .ql-editor {
@ -191,6 +219,27 @@ a.btn_layerClose:hover {
line-height: 1.65em; line-height: 1.65em;
} }
.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button {
background: none;
border: none;
cursor: pointer;
display: inline-block;
float: left;
height: 24px;
padding: 3px 5px;
min-width: unset;
}
.ql-snow .ql-picker-label {
cursor: pointer;
display: inline-block;
height: 100%;
padding-left: unset;
padding-right: unset;
position: relative;
width: 100%;
}
/* /*
* --- COMMENT SECTION --- * --- COMMENT SECTION ---
* Styled to fit the main theme. * Styled to fit the main theme.
@ -239,13 +288,20 @@ a.btn_layerClose:hover {
word-wrap: break-word; word-wrap: break-word;
} }
/* 읽기 모드 컨트롤 박스 내부의 태그 스타일 */ /* 컨트롤 박스 내부 공통 제목 스타일 */
.write_option .tag-title { .write_option .tag-title {
font-weight: 600; font-weight: 600;
margin-right: 0.5em; margin-bottom: 0.35em; /* [수정] 제목과 내용 사이에 하단 여백을 줍니다. */
color: #555; color: #555;
font-size: 0.85em; /* [추가] 제목 라벨 폰트를 살짝 작게 */
text-transform: uppercase; /* [추가] 라벨처럼 보이도록 영문 대문자 처리 */
/* 태그 제목(HASHTAGS)이 태그 아이템과 동일한 스타일을 공유할 경우 대비 (선택 사항) */
display: block;
width: 100%;
} }
/* 컨트롤 박스 내부 공통 태그 아이템 스타일 (뷰어 모드용) */
.write_option .tag-item { .write_option .tag-item {
display: inline-block; display: inline-block;
background-color: var(--almost-white, #f7f7f7); background-color: var(--almost-white, #f7f7f7);
@ -261,4 +317,149 @@ a.btn_layerClose:hover {
.write_option.controlbox-category:not(.btn-example), .write_option.controlbox-category:not(.btn-example),
.write_option.controlbox-hashtag:not(.btn-example) { .write_option.controlbox-hashtag:not(.btn-example) {
cursor: default; cursor: default;
}
/* [추가] 태그/내용을 감싸는 래퍼 (줄바꿈 담당) */
.tag-content-wrapper {
display: flex; /* 내부 아이템(태그 등)을 가로로 배치 */
flex-wrap: wrap; /* 태그가 많으면 자동 줄바꿈 (기본값) */
gap: 4px 6px; /* 태그 사이의 간격 (세로 4px, 가로 6px) */
line-height: 1.4; /* 내용 줄간격 */
width: 100%; /* 부모 박스(write_option) 너비를 채움 */
}
/* --- 컨트롤 박스 공통 스타일 (카테고리, 해시태그, 위치) --- */
/* [수정] 3개 박스에 공통 스타일을 적용합니다. */
.write_option.controlbox-category,
.write_option.controlbox-hashtag,
.write_option.controlbox-location {
/* 모든 박스는 .write_option의 flex: 1 1 0% 규칙을 따릅니다. */
box-sizing: border-box;
min-height: 50px; /* 모든 박스의 최소 높이 통일 */
}
/* --- 컨트롤 박스 내부 태그 아이템 공통 스타일 (팝업 편집용) --- */
/* (카테고리와 해시태그 박스 내부의 태그 아이템만 이 스타일을 공유) */
.write_option.controlbox-location .tag-item,
.write_option.controlbox-category .tag-item,
.write_option.controlbox-hashtag .tag-item {
background-color: var(--highlight-bg-color, #e9e9e9);
color: var(--highlight-text-color, #333);
border-radius: 5px;
padding: 3px 8px;
font-size: 0.9em;
white-space: normal;
margin-right: 0; /* .tag-content-wrapper의 gap이 간격을 제어하므로 마진 제거 */
}
/* --- 컨트롤 박스: 카테고리 (개별 내부 스타일) --- */
.write_option.controlbox-category .tag-title {
font-weight: bold;
color: #555;
/* margin-right: 8px; <-- 세로 레이아웃으로 변경되어 불필요 */
white-space: nowrap;
}
/* --- 컨트롤 박스: 해시태그 (개별 내부 스타일) --- */
.write_option.controlbox-hashtag {
/* 태그 사이의 내부 간격 (래퍼가 대신 처리) */
/* gap: 8px 10px; */
}
.write_option.controlbox-hashtag .tag-title {
font-weight: bold;
color: #555;
margin-right: 0px;
white-space: nowrap;
}
/* --- 컨트롤 박스: 콘텐츠 래퍼 동작 정의 --- */
/* 해시태그는 .tag-content-wrapper의 기본값(flex-wrap: wrap)을 그대로 사용합니다. */
/* [추가/그룹화] 카테고리와 위치 박스는 내용이 줄바꿈되지 않도록 공통 처리합니다. */
/*.write_option.controlbox-category .tag-content-wrapper,*/
/*.write_option.controlbox-location .tag-content-wrapper {*/
/* white-space: nowrap; !* 내용이 길어도 한 줄로 표시 (줄바꿈 방지) *!*/
/* overflow: hidden;*/
/* text-overflow: ellipsis;*/
/*}*/
/* 추가: 컨트롤 박스 자체의 레이아웃 (이전 CSS에서 남음. 현재 구조에서는 영향 없음) */
#main_container .write_option {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
/* === POPUP TAG LIST STYLES (신규 추가) === */
.pop_conts .tag-list {
display: flex; /* 태그들을 가로로 나열 */
flex-wrap: wrap; /* 공간이 부족하면 다음 줄로 자동 줄바꿈 */
gap: 8px; /* 태그 아이템 사이의 간격을 8px로 지정 */
margin-bottom: 1.5em; /* 태그 목록과 하단 입력창 사이의 간격 */
padding-bottom: 1.5em; /* 태그 목록 하단 패딩 */
border-bottom: 1px solid #e0e0e0; /* 입력창과 목록을 구분하는 선 */
}
.pop_conts .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
border: 1px solid #e0e0e0;
border-radius: 15px; /* 둥근 알약 모양 */
padding: 0.3em 0.9em; /* 내부 여백 */
font-size: 0.9em;
cursor: pointer; /* 클릭 가능하도록 커서 변경 */
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.pop_conts .tag-item:hover {
background-color: #e0e0e0; /* 호버 시 배경색 변경 */
border-color: #ccc;
}
/* === (신규 추가) POPUP STAGING AREA STYLES === */
.pop_conts .tag-list-label {
font-weight: 600;
font-size: 0.9em;
color: #777;
margin-top: 1.2em; /* 각 섹션 상단 여백 */
margin-bottom: 0.5em;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
/* 선택된 항목을 담는 스테이징 영역 스타일 */
.pop_conts .staging-area {
min-height: 40px;
padding: 0.5em;
background: #fdfdfd;
border: 1px dashed #ccc; /* 점선 테두리로 구분 */
border-radius: 5px;
}
/* 스테이징 영역 내부의 태그에서 'X' (삭제) 버튼 스타일 */
.staging-area .tag-item .remove-tag {
font-family: "Courier New", monospace; /* 'X' 버튼 글꼴 */
font-weight: bold;
color: #cc0000; /* 어두운 빨간색 */
margin-left: 8px;
cursor: pointer; /* 클릭 가능하도록 */
display: inline-block;
padding: 0 2px;
}
.staging-area .tag-item .remove-tag:hover {
color: #ff0000; /* 밝은 빨간색 */
background-color: #eee;
}
/* 카테고리 스테이징 영역은 flex가 아니므로, 내부 태그 마진을 별도 지정 */
#selected-category-area .tag-item {
margin: 0;
}
#selected-category-area i, #selected-hashtags-area i {
color: #999;
} }

View File

@ -1,18 +1,128 @@
/*.layer {*/ /* 기본 레이아웃 및 폰트 */
/* height: 100%;*/ body {
/* place-content: space-between;*/ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
/* place-items: stretch;*/ background-color: #f4f7f6;
/* display: grid;*/ color: #333;
/* gap: 10px;*/ line-height: 1.6;
/* grid-auto-rows: minmax(200px, auto);*/ }
/* grid-template-columns: repeat(auto-fill, minmax(200px, auto));*/
/* width: 100%;*/ #main_layer {
/*}*/ max-width: 900px;
/*!*.where_item {*!*/ margin: 30px auto;
/*!* justify-content: space-between;*!*/ background-color: #ffffff;
/*!* flex-wrap: wrap;*!*/ padding: 25px;
/*!* flex-direction: row;*!*/ border-radius: 8px;
/*!* width: 100%;*!*/ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
/*!* border-radius: 10px;*!*/ }
/*!* background: #F0F0F524;*!*/
/*!*}*!*/ .layer {
padding: 10px 0;
}
/* 개별 위치 로그 아이템 스타일 */
.location-item {
background-color: #fcfcfc;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 20px;
padding: 15px 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease-in-out;
}
.location-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* 헤더 (날짜 및 경과 시간) */
.location-header {
display: flex; /* 가로로 배치 */
justify-content: space-between; /* 양 끝 정렬 */
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.location-time {
font-weight: bold;
color: #555;
font-size: 1.1em;
}
.location-between {
font-size: 0.9em;
color: #888;
background-color: #eef;
padding: 3px 8px;
border-radius: 4px;
}
/* 본문 (주소 및 좌표) */
.location-body {
font-size: 0.95em;
}
.location-address {
margin-top: 0;
margin-bottom: 8px;
color: #444;
word-break: break-all; /* 긴 주소 자동 줄바꿈 */
}
.location-coords {
margin: 0;
color: #666;
font-style: italic;
display: flex; /* 위도, 경도, 국가명 가로 배치 */
gap: 15px; /* 각 요소 사이 간격 */
flex-wrap: wrap; /* 화면 좁아지면 줄바꿈 */
}
.location-coords b {
color: #333;
font-weight: bold;
}
.location-country {
color: #777;
font-size: 0.9em;
}
/* 페이지네이션 스타일 */
.pagination {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.pagination a, .pagination span {
display: inline-block;
padding: 8px 15px;
margin: 0 4px;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #007bff;
background-color: #f8f9fa;
transition: all 0.2s ease;
}
.pagination a:hover {
background-color: #e9ecef;
border-color: #007bff;
color: #0056b3;
}
.pagination span { /* 현재 페이지 또는 비활성화된 버튼 */
color: #6c757d;
background-color: #e2e6ea;
border-color: #e2e6ea;
cursor: default;
}
/* 기존 CSS에서 불필요한 부분 제거 또는 통합 */
.where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */
/* display: none; */
}

View File

@ -8,6 +8,9 @@
* ================================================================================= * =================================================================================
*/ */
var stagedCategory = 'none';
var stagedHashtags = []; // 해시태그는 배열로 관리
// 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다. // 전역 변수: Quill 에디터 인스턴스와 게시물 기본 데이터를 저장합니다.
var quill = null; var quill = null;
var currentLat = 0.0; var currentLat = 0.0;
@ -55,6 +58,83 @@ $(document).ready(function() {
$('.open-login-popup').on('click', function() { $('.open-login-popup').on('click', function() {
openPopup(this); openPopup(this);
}); });
/* === (대규모 수정) 팝업 입력/적용/취소 버튼 로직 === */
// --- 1. Category Popup Logic ---
const categoryInput = document.getElementById('category-input');
const addCategoryBtn = document.getElementById('add-category-btn');
const applyCategoryBtn = document.getElementById('apply-category-btn'); // (신규) 적용 버튼 선택
// "Add" 버튼 (입력창에서 추가) 로직 (수정됨)
if (addCategoryBtn) {
addCategoryBtn.addEventListener('click', function() {
const newCategory = categoryInput.value.trim();
if (newCategory) {
stagedCategory = newCategory; // 1. 임시 변수(stagedCategory) 업데이트
renderStagedCategory(); // 2. 스테이징 UI 새로고침
categoryInput.value = ''; // 3. 입력창 비우기 (팝업 유지)
}
});
}
// "Enter" 키 지원 (기존과 동일)
if (categoryInput) {
categoryInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter' || e.keyCode === 13) {
addCategoryBtn.click();
}
});
}
// (신규) "Apply" 버튼 로직
if (applyCategoryBtn) {
applyCategoryBtn.addEventListener('click', function(e) {
e.preventDefault(); // A태그 기본 동작(새로고침/이동) 방지
// "적용" 시점에만 실제 baseData를 임시 변수 값으로 덮어쓰기
baseData.category = stagedCategory;
updateControlBoxDisplay(); // 본문 에디터 UI 갱신
closePopup(); // 팝업 닫기
});
}
// --- 2. Hashtag Popup Logic ---
const hashtagInput = document.getElementById('hashtag-input');
const addHashtagBtn = document.getElementById('add-hashtag-btn');
const applyHashtagBtn = document.getElementById('apply-hashtag-btn'); // (신규) 적용 버튼 선택
// "Add" 버튼 (입력창에서 추가) 로직 (수정됨)
if (addHashtagBtn) {
addHashtagBtn.addEventListener('click', function() {
// 1. 임시 배열(stagedHashtags)에 추가 (신규 헬퍼 함수 사용)
if (addTagToStaged(hashtagInput.value)) {
renderStagedHashtags(); // 2. 추가 성공 시 스테이징 UI 새로고침
}
hashtagInput.value = ''; // 3. 입력창은 항상 비움
});
}
// "Enter" 키 지원 (기존과 동일)
if (hashtagInput) {
hashtagInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter' || e.keyCode === 13) {
addHashtagBtn.click();
}
});
}
// (신규) "Apply" 버튼 로직
if (applyHashtagBtn) {
applyHashtagBtn.addEventListener('click', function(e) {
e.preventDefault(); // A태그 기본 동작 방지
// "적용" 시점에 임시 배열을 쉼표(,)로 구분된 문자열로 변환하여 실제 baseData에 저장
baseData.tags = stagedHashtags.join(',');
updateControlBoxDisplay(); // 본문 에디터 UI 갱신
closePopup(); // 팝업 닫기
});
}
/* =============================================================== */
}); });
@ -98,8 +178,8 @@ function initEditor(useEditor = false) {
['bold', 'italic', 'underline', 'strike'], [{ 'color': [] }, { 'background': [] }], ['bold', 'italic', 'underline', 'strike'], [{ 'color': [] }, { 'background': [] }],
[{ 'header': 1 }, { 'header': 2 }, 'blockquote', 'code-block'], [{ 'header': 1 }, { 'header': 2 }, 'blockquote', 'code-block'],
[{ 'script': 'sub'}, { 'script': 'super' }], [{ 'list': 'ordered'}, { 'list': 'bullet' }], [{ 'script': 'sub'}, { 'script': 'super' }], [{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }], ['link', 'image', 'video'], [{ 'indent': '-1'}, { 'indent': '+1' }], [{ 'align': [] }],
['table-better'], [{ 'direction': 'rtl' }], [{ 'align': [] }], ['clean'] ['table-better'], [{ 'direction': 'rtl' }], ['clean'], ['link', 'image', 'video'],
], ],
handlers: { image: function() { selectLocalImage(); }, video: function() { selectLocalVideo(); } } handlers: { image: function() { selectLocalImage(); }, video: function() { selectLocalVideo(); } }
}, },
@ -248,56 +328,88 @@ function setupControlBox(mode) {
if (mode === 'edit') { if (mode === 'edit') {
categoryBox.setAttribute('onclick', 'openPopup(this)'); categoryBox.setAttribute('onclick', 'openPopup(this)');
hashtagBox.setAttribute('onclick', 'openPopup(this)'); hashtagBox.setAttribute('onclick', 'openPopup(this)');
categoryBox.innerText = '카테고리 설정';
hashtagBox.innerText = '해시태그 편집'; // === 수정된 부분 ===
fetchCategoriesAndHashtags(); // 기존 "innerText" 설정 줄을 삭제하고,
// 페이지 로드 시 현재 데이터로 UI를 업데이트하는 함수를 호출합니다.
updateControlBoxDisplay();
// ==================
fetchCategoriesAndHashtags(); // 팝업 목록 채우는 로직은 그대로 실행
} else { } else {
// (읽기 모드 'else' 블록 수정)
categoryBox.removeAttribute('onclick'); categoryBox.removeAttribute('onclick');
hashtagBox.removeAttribute('onclick'); hashtagBox.removeAttribute('onclick');
categoryBox.classList.remove('btn-example'); categoryBox.classList.remove('btn-example');
hashtagBox.classList.remove('btn-example'); hashtagBox.classList.remove('btn-example');
categoryBox.innerHTML = `<span class="tag-title">카테고리: </span><span class="tag-item">${baseData.category || '지정되지 않음'}</span>`; // [수정] 카테고리를 새 구조(제목 + 래퍼)로 변경
const categoryContent = `<span class="tag-item">${baseData.category || '지정되지 않음'}</span>`;
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
hashtagBox.innerHTML = '<span class="tag-title">태그: </span>'; // [수정] 해시태그를 새 구조(제목 + 래퍼)로 변경
let hashtagContent = '';
if (baseData.tags && baseData.tags.length > 0) { if (baseData.tags && baseData.tags.length > 0) {
baseData.tags.split(',').forEach(tag => { hashtagContent = baseData.tags.split(',').map(tag => {
hashtagBox.innerHTML += `<span class="tag-item">#${tag.trim()}</span>`; return `<span class="tag-item">#${tag.trim()}</span>`;
}); }).join(' '); // join으로 하나의 문자열로 만듭니다.
} else { } else {
hashtagBox.innerHTML += '<span>없음</span>'; hashtagContent = '<span>없음</span>';
} }
hashtagBox.innerHTML = `<span class="tag-title">HASHTAGS</span><div class="tag-content-wrapper">${hashtagContent}</div>`;
} }
} }
/** /**
* 백엔드 API를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다. * 백엔드 API를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다.
*/ */
/**
* 백엔드 API를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다.
* (수정됨: 클릭 baseData 대신 Staging 변수를 업데이트하도록 변경)
*/
function fetchCategoriesAndHashtags() { function fetchCategoriesAndHashtags() {
// Fetch Categories
fetch(`${getMainPath()}/blog/categories.bjx`).then(res => res.json()).then(data => { fetch(`${getMainPath()}/blog/categories.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) { if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#category-list'); const list = document.querySelector('#category-list');
if(list) { if (list) {
list.innerHTML = ''; list.innerHTML = '';
data.tags.forEach(tag => { data.tags.forEach(tag => {
const el = document.createElement('span'); const el = document.createElement('span');
el.className = 'tag-item'; el.className = 'tag-item';
el.innerText = tag; el.innerText = tag;
// (로직 변경) 클릭 시
el.onclick = function() {
stagedCategory = tag; // 1. 임시 변수(stagedCategory) 업데이트
renderStagedCategory(); // 2. 스테이징 UI만 새로고침 (팝업 안 닫음)
};
list.appendChild(el); list.appendChild(el);
}); });
} }
} }
}).catch(err => console.error('Error fetching categories:', err)); }).catch(err => console.error('Error fetching categories:', err));
// Fetch Hashtags
fetch(`${getMainPath()}/blog/hashtags.bjx`).then(res => res.json()).then(data => { fetch(`${getMainPath()}/blog/hashtags.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) { if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#hashtag-list'); const list = document.querySelector('#hashtag-list');
if(list) { if (list) {
list.innerHTML = ''; list.innerHTML = '';
data.tags.forEach(tag => { data.tags.forEach(tag => {
const rawTag = tag;
const el = document.createElement('span'); const el = document.createElement('span');
el.className = 'tag-item'; el.className = 'tag-item';
el.innerText = `#${tag}`; el.innerText = `#${rawTag}`;
// (로직 변경) 클릭 시
el.onclick = function() {
// 1. 임시 배열(stagedHashtags)에 추가 (중복 방지 헬퍼 사용)
if (addTagToStaged(rawTag)) {
// 2. 추가 성공 시에만 스테이징 UI 새로고침
renderStagedHashtags();
}
};
list.appendChild(el); list.appendChild(el);
}); });
} }
@ -333,18 +445,66 @@ function loadEditor() {
*/ */
function save() { function save() {
const titleField = document.getElementById('title_field'); const titleField = document.getElementById('title_field');
// --- (수정/신규 로직) ---
// 1. baseData의 복사본을 만들어 전송용 임시 객체(dataToSend)를 생성합니다.
// (원본 baseData를 직접 수정하면 팝업 UI가 인코딩된 문자로 깨집니다)
let dataToSend = JSON.parse(JSON.stringify(baseData));
// 2. dataToSend 객체의 모든 텍스트 필드를 encodeURIComponent로 인코딩합니다.
if (titleField) { if (titleField) {
baseData.title = encodeURIComponent(titleField.value); dataToSend.title = encodeURIComponent(titleField.value);
} else {
dataToSend.title = encodeURIComponent(dataToSend.title || '');
} }
baseData.content = encodeURIComponent(JSON.stringify(quill.getContents())); dataToSend.content = encodeURIComponent(JSON.stringify(quill.getContents()));
baseData.modifyLat = currentLat; // (누락되었던 필드 추가)
baseData.modifyLon = currentLon; dataToSend.category = encodeURIComponent(dataToSend.category || 'none');
dataToSend.tags = encodeURIComponent(dataToSend.tags || '');
// 3. 좌표 데이터를 임시 객체에 업데이트합니다.
dataToSend.modifyLat = currentLat;
dataToSend.modifyLon = currentLon;
// (신규 게시물일 경우 원본 위치 좌표 설정)
if (dataToSend.firstPostLat === 0.0 || dataToSend.firstPostLat === null) {
dataToSend.firstPostLat = currentLat;
}
if (dataToSend.firstPostLon === 0.0 || dataToSend.firstPostLon === null) {
dataToSend.firstPostLon = currentLon;
}
const uploadUrl = `${getMainPath()}/blog/post.bjx`; const uploadUrl = `${getMainPath()}/blog/post.bjx`;
if (confirm("해당 내용으로 저장하시겠습니까?")) { if (confirm("해당 내용으로 저장하시겠습니까?")) {
post(uploadUrl, serverData.enc, JSON.stringify(baseData), serverData.keyword, function(resultData) { console.log("Data being sent to server:", dataToSend);
alert("저장되었습니다."); console.log("JSON string being sent:", JSON.stringify(dataToSend));
post(uploadUrl, serverData.enc, JSON.stringify(dataToSend), serverData.keyword, function(resultData) {
// --- (수정된 콜백 로직) ---
try {
// 1. 서버로부터 받은 JSON 문자열을 객체로 파싱합니다.
const response = JSON.parse(resultData);
// 2. 서버 응답이 성공(resultCode === 0)이고,
// 서버가 postId를 (예: response.data.postId) 보내줬는지 확인합니다.
// (참고: 'response.data.postId'는 서버 응답 구조에 따라 변경해야 할 수 있습니다.)
if (response.resultCode === 0 && response.data && response.data.postId) {
// 3. 알림 후, 응답받은 ID를 사용해 뷰어 페이지로 리디렉션합니다.
alert("저장되었습니다. 게시물 보기 페이지로 이동합니다.");
location.href = getMainPath() + "/blog/viewer/" + response.data.postId;
} else {
// 저장은 성공했으나 ID를 받지 못한 경우 (또는 서버가 다른 에러 코드를 보낸 경우)
alert("저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error"));
}
} catch (e) {
// JSON 파싱 실패 등 예외 처리
console.error("Failed to parse save response:", e, resultData);
alert("저장에 성공했으나 서버 응답을 처리할 수 없습니다.");
}
// --- (여기까지 수정된 로직) ---
}); });
} }
} }
@ -353,7 +513,32 @@ function save() {
* 사용자의 현재 위치(위도, 경도) 가져옵니다. * 사용자의 현재 위치(위도, 경도) 가져옵니다.
*/ */
function getLocation() { function getLocation() {
if (navigator.geolocation) { if (baseData.firstPostLat !== 0.0 || baseData.firstPostLon !== 0.0) {
try {
var requestOptions = {
method: 'GET',
};
fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+baseData.firstPostLat+"&lon="+baseData.firstPostLon+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions)
.then(response => response.json())
.then(function(result) {
const locationField = document.getElementById('location_field');
try {
var inh = `<span class="tag-title">LOCATION</span>`;
inh = inh + `<div class="tag-content-wrapper">`;
try{
inh = inh + `<div class="tag-item">${result.features[0].properties.formatted}</div>`;
}catch(err) {}
inh = inh + `<div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div>`;
inh = inh + `<div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
locationField.innerHTML = inh;
} catch (e) {
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
}
})
.catch(error => console.log('error', error));
}catch (e) { }
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => { navigator.geolocation.getCurrentPosition(pos => {
currentLat = pos.coords.latitude; currentLat = pos.coords.latitude;
currentLon = pos.coords.longitude; currentLon = pos.coords.longitude;
@ -363,9 +548,18 @@ function getLocation() {
baseData.modifyLon = currentLon; baseData.modifyLon = currentLon;
const locationField = document.getElementById('location_field'); const locationField = document.getElementById('location_field');
if (locationField) { if (locationField) {
locationField.textContent = `Lat: ${currentLat.toFixed(4)}, Lon: ${currentLon.toFixed(4)}`; // [수정] 제목과 내용 래퍼 구조로 변경
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
} }
}); });
} else {
const locationField = document.getElementById('location_field');
if (locationField) {
// [수정] 제목과 내용 래퍼 구조로 변경
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
}
} }
} }
@ -373,10 +567,29 @@ function getLocation() {
* 팝업 레이어를 엽니다. * 팝업 레이어를 엽니다.
*/ */
function openPopup(element) { function openPopup(element) {
const targetId = element.getAttribute('to'); const targetId = element.getAttribute('to');
const popup = document.querySelector(targetId); const popup = document.querySelector(targetId);
const overlay = document.querySelector('.dim_layer'); const overlay = document.querySelector('.dim_layer');
if (popup && overlay) { if (popup && overlay) {
// === (신규) Staging 변수 초기화 로직 ===
if (targetId === '#popLayer1') { // 카테고리 팝업
// 1. 실제 데이터(baseData)에서 임시 변수(stagedCategory)로 값을 복사
stagedCategory = baseData.category || 'none';
// 2. 임시 변수 기준으로 스테이징 UI 렌더링
renderStagedCategory();
}
else if (targetId === '#popLayer2') { // 해시태그 팝업
// 1. 실제 데이터(baseData)에서 임시 배열(stagedHashtags)로 값을 복사
// (문자열을 배열로 변환하고, 빈 문자열 필터링)
stagedHashtags = baseData.tags ? baseData.tags.split(',').filter(t => t.trim() !== '') : [];
// 2. 임시 배열 기준으로 스테이징 UI 렌더링
renderStagedHashtags();
}
// ===================================
overlay.style.display = 'block'; overlay.style.display = 'block';
popup.style.display = 'block'; popup.style.display = 'block';
} }
@ -461,7 +674,7 @@ function submitLoginForm() {
function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); } function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); }
function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } // 수정된 URL function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } // 수정된 URL
function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); } // 수정된 URL function gotoModify() { document.location.replace(`${getMainPath()}/blog/posts`); } // 수정된 URL
function gotoLogin() { document.location.replace(`${getMainPath()}/login.bs`); } function gotoWhere() { document.location.replace(`${getMainPath()}/bums/where.bs`); }
function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); } function gotoJoin() { document.location.replace(`${getMainPath()}/user/join.bs`); }
/** /**
@ -543,4 +756,172 @@ function unformat(type, data, key) {
default: default:
return [odd.reverse().join(""), dividerStr, even.reverse().join("")].join(""); return [odd.reverse().join(""), dividerStr, even.reverse().join("")].join("");
} }
} }
/**
* (신규 추가)
* 중복을 방지하며 baseData.tags (문자열) 태그를 안전하게 추가합니다.
*/
function addTagToData(newTag) {
if (!newTag || newTag.trim() === '') return; // 빈 태그 방지
// 현재 태그 문자열을 배열로 변환 (태그가 없으면 빈 배열)
let tags = baseData.tags ? baseData.tags.split(',') : [];
// 새 태그가 이미 존재하는지 확인 (공백 제거 및 대소문자 무시)
const tagExists = tags.some(t => t.trim().toLowerCase() === newTag.trim().toLowerCase());
if (!tagExists) {
tags.push(newTag.trim()); // 새 태그 추가
baseData.tags = tags.join(','); // 다시 쉼표로 구분된 문자열로 저장
}
}
/**
* (신규 추가)
* 편집기 컨트롤 박스의 텍스트를 현재 baseData 기준으로 새로 고칩니다.
*/
function updateControlBoxDisplay() {
const categoryBox = document.querySelector('.controlbox-category');
const hashtagBox = document.querySelector('.controlbox-hashtag');
if (categoryBox) {
// [수정] 편집 모드도 읽기 모드와 동일한 HTML 구조(제목 + 래퍼)로 생성
const categoryContent = (baseData.category && baseData.category !== 'none')
? `<span class="tag-item">${baseData.category}</span>`
: '<i>카테고리 설정</i>';
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
}
if (hashtagBox) {
// [수정] 편집 모드도 읽기 모드와 동일한 HTML 구조(제목 + 래퍼)로 생성
let hashtagContent = '';
if (baseData.tags && baseData.tags.length > 0) {
hashtagContent = baseData.tags.split(',')
.map(t => `<span class="tag-item">#${t.trim()}</span>`)
.join(' '); // 각 태그를 span으로 감싸고 공백으로 연결
} else {
hashtagContent = '<i>해시태그 편집</i>';
}
hashtagBox.innerHTML = `<span class="tag-title">HASHTAGS</span><div class="tag-content-wrapper">${hashtagContent}</div>`;
}
}
/* === (신규 추가) POPUP STAGING 헬퍼 함수들 === */
/** 1. Staging Category 렌더링: 선택된 카테고리(임시 변수)를 팝업 UI에 표시 */
function renderStagedCategory() {
const area = document.getElementById('selected-category-area');
if (area) {
if (stagedCategory && stagedCategory !== 'none') {
// 선택된 아이템에 삭제(X) 버튼을 포함하여 렌더링
area.innerHTML = `<span class="tag-item">${stagedCategory}
<span class="remove-tag" onclick="removeStagedCategory()">X</span>
</span>`;
} else {
area.innerHTML = '<i>No category selected.</i>';
}
}
}
/** 2. Staging Hashtags 렌더링: 선택된 해시태그 목록(임시 배열)을 팝업 UI에 표시 */
function renderStagedHashtags() {
const area = document.getElementById('selected-hashtags-area');
if (area) {
area.innerHTML = ''; // 영역 초기화
if (stagedHashtags.length > 0) {
stagedHashtags.forEach((tag, index) => {
// 각 아이템에 삭제(X) 버튼과 올바른 index를 전달하는 onclick 이벤트 추가
area.innerHTML += `<span class="tag-item">#${tag}
<span class="remove-tag" onclick="removeStagedHashtag(${index})">X</span>
</span>`;
});
} else {
area.innerHTML = '<i>No tags selected.</i>';
}
}
}
/** 3. Staged Category 삭제: (X) 버튼 클릭 시 호출 */
function removeStagedCategory() {
stagedCategory = 'none';
renderStagedCategory(); // UI 새로고침
}
/** 4. Staged Hashtag 삭제: (X) 버튼 클릭 시 호출 */
function removeStagedHashtag(index) {
stagedHashtags.splice(index, 1); // 배열에서 해당 인덱스의 아이템 1개 제거
renderStagedHashtags(); // UI 새로고침
}
/** 5. Staged Hashtag 추가 (중복 방지 헬퍼): 임시 배열에 태그 추가 */
function addTagToStaged(newTag) {
if (!newTag || newTag.trim() === '') return false; // 빈 값 방지
const tagToAdd = newTag.trim().replace(/#/g, ''); // # 제거 및 공백 제거
// 임시 배열에 이미 존재하는지 확인 (대소문자 무시)
const tagExists = stagedHashtags.some(t => t.toLowerCase() === tagToAdd.toLowerCase());
if (!tagExists) {
stagedHashtags.push(tagToAdd); // 임시 배열에 추가
return true;
}
return false; // 중복이면 false 반환
}
function handleVote(buttonElement, voteType) {
// 1. 가장 가까운 .vote-controls 컨테이너를 찾음
const controls = buttonElement.closest('.vote-controls');
// 2. 컨테이너의 data-post-id 속성에서 postId를 가져옴
const postId = controls.dataset.postId; // (data-post-id="...") 값을 읽음
// 3. 모든 버튼 비활성화 (중복 클릭 방지)
controls.querySelectorAll('button').forEach(btn => btn.disabled = true);
// 4. 요청할 URL 생성
let url = `${getMainPath()}/blog/post/${postId}/${voteType === 'like' ? 'like' : 'unlike'}.bjx`;
// 5. CSRF 토큰 및 헤더 준비 (기본 헤더)
let headers = {
'Content-Type': 'application/json'
};
// [수정] CSRF 메타 태그가 존재하는지 (즉, 사용자가 로그인했는지) 확인
const csrfMeta = document.querySelector('meta[name="_csrf"]');
if (csrfMeta) {
const csrfToken = csrfMeta.getAttribute('content');
if (csrfToken) {
// 토큰이 존재할 경우에만 헤더에 추가
headers['X-CSRF-TOKEN'] = csrfToken;
}
}
// 6. Fetch API를 사용하여 POST 요청 전송
// (익명 사용자는 CSRF 헤더 없이 요청하고, 인증 사용자는 헤더와 함께 요청)
fetch(url, {
method: 'POST',
headers: headers
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
// 7. 성공 시: UI 업데이트
const likeSpan = controls.querySelector('.like-count');
const unlikeSpan = controls.querySelector('.unlike-count');
if (likeSpan) likeSpan.innerText = data.voteCount;
if (unlikeSpan) unlikeSpan.innerText = data.unlikeCount;
})
.catch(error => {
// 8. 실패 시: 버튼 다시 활성화
console.error('Error handling vote:', error);
alert('투표 중 오류가 발생했습니다. 나중에 다시 시도해주세요.');
controls.querySelectorAll('button').forEach(btn => btn.disabled = false);
});
}
/* ============================================= */

View File

@ -6,18 +6,18 @@
layout:decorate="~{layout/default_layout}" layout:decorate="~{layout/default_layout}"
> >
<th:block layout:fragment="head">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block>
<body> <body>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<head>
<th:block id="head">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(true)});</script>
</th:block>
</head>
<section class="wrapper style2" > <section class="wrapper style2" >
<th:block sec:authorize="isAuthenticated()"> <th:block sec:authorize="isAuthenticated()">
<div class="container" > <div class="container" >
@ -33,6 +33,14 @@
<p th:if="${srcPost.writeTime != null and srcPost.writeTime > 0}" <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> th:text="${#temporals.format(T(java.time.Instant).ofEpochMilli(srcPost.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm:ss')}"></p>
</header> </header>
<div 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> </div>
</th:block> </th:block>
</section> </section>
@ -46,44 +54,52 @@
<div class="container"> <div class="container">
<div id="editor"></div> <div id="editor"></div>
<div class="write_controllbox">
<div class="write_option btn-example controlbox-category" to="#popLayer1">
</div>
<div style="width: 15px"></div>
<div class="write_option btn-example controlbox-hashtag" to="#popLayer2" id="hashtag_field">
</div>
<div style="width: 15px"></div>
<div class="write_option" id="location_field"></div>
</div>
<button id="save" class="button fit" style="margin-top: 1em;" onclick="save()">저장하기</button> <button id="save" class="button fit" style="margin-top: 1em;" onclick="save()">저장하기</button>
</div> </div>
</th:block> </th:block>
</section> </section>
</th:block> </th:block>
<th:block layout:fragment="popup_layer"> <th:block layout:fragment="popup_layer">
<div id="popLayer1" class="pop_layer category-popup"> <div id="popLayer1" class="pop_layer category-popup">
<div class="pop_container"> <div class="pop_container">
<div class="pop_conts"> <div class="pop_conts">
<h2>Categories</h2> <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> <div id="category-list" class="tag-list"></div>
<input type="text" id="category-input" placeholder="Add a new category" class="tag-input"> <input type="text" id="category-input" placeholder="Add a new category" class="tag-input">
<button id="add-category-btn" class="button">Add</button> <button id="add-category-btn" class="button">Add</button>
<div class="btn_r"> <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> <a href="#" class="btn_layerClose">Close</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="popLayer2" class="pop_layer hashtag-popup"> <div id="popLayer2" class="pop_layer hashtag-popup">
<div class="pop_container"> <div class="pop_container">
<div class="pop_conts"> <div class="pop_conts">
<h2>Hashtags</h2> <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> <div id="hashtag-list" class="tag-list"></div>
<input type="text" id="hashtag-input" placeholder="Add a new hashtag" class="tag-input"> <input type="text" id="hashtag-input" placeholder="Add a new hashtag" class="tag-input">
<button id="add-hashtag-btn" class="button">Add</button> <button id="add-hashtag-btn" class="button">Add</button>
<div class="btn_r"> <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> <a href="#" class="btn_layerClose">Close</a>
</div> </div>
</div> </div>

View File

@ -1,32 +0,0 @@
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head">
</th:block>
<th:block layout:fragment="content" id="content">
<div id="main_layer">
<th:block sec:authorize="isAnonymous()">
<h1>권한이 없는 뎁쇼?!</h1>
</th:block>
<th:block sec:authorize="isAuthenticated()">
<div class="post_layer" id="posts" th:each="posts, postsStat : ${chunkedPosts}">
<div class="post_layer" th:class="${#strings.append(rowKey, postsStat.index)}" th:each="post, postStat : ${posts}">
<div class="post_item"
th:if="${post.id != null and post.id != ''}"
onclick="goToEditor(this)"
th:attr="data-post-id=${post.id}">
<span id="postTitle" class="post_attr" th:text="${post.title}"></span>
<div id="content" class="post_attr content" th:attr="data=${post.content}"></div>
<span id="writeDate" class="post_attr" th:text="${#dates.format(post.writeTime, 'yyyy.MM.dd HH:mm:ss')}"></span>
<span id="postId" class="post_attr" th:text="${post.id}"></span>
</div>
</div>
</div>
</th:block>
</div>
</th:block>
</html>

View File

@ -9,22 +9,29 @@
<th:block layout:fragment="head"> <th:block layout:fragment="head">
</th:block> </th:block>
</head> </head>
<body>
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<section class="wrapper style1"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-8 col-12-narrower"> <div class="col-8 col-12-narrower">
<div id="content"> <div id="content_inner">
<article> <article>
<section th:each="post : ${Posts}"> <section th:each="post : ${postsPage.content}">
<div class="box post" th:id="${post.id}"> <div class="box post" th:id="${post.id}">
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left"> <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)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" /> <img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
</a> </a>
<div class="inner"> <div class="inner">
<h3 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')} + ']'"></h3> <h3 style="display: flex; justify-content: space-between; align-items: center;">
<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 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> <p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
</div> </div>
<footer sec:authorize="isAuthenticated()" style="text-align: right; margin-top: 1em;"> <footer sec:authorize="isAuthenticated()" style="text-align: right; margin-top: 1em;">
@ -33,11 +40,34 @@
</div> </div>
</section> </section>
</article> </article>
</div>
</div> <nav aria-label="Page navigation" th:if="${postsPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em; font-size: 0.9em;">
<ul class="pagination" style="display: inline-block; padding-left: 0; list-style: none; border-radius: 5px; border: 1px solid #e0e0e0; overflow: hidden;">
<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})}"
class="button alt small" style="border-radius:0; margin:0; border-right: 1px solid #e0e0e0;">
&laquo; Prev
</a>
</li>
<li th:each="pageNum : ${#numbers.sequence(0, postsPage.totalPages - 1)}"
style="display: inline; float: left; border-right: 1px solid #e0e0e0;">
<a th:href="@{/blog/posts(page=${pageNum})}"
th:text="${pageNum + 1}"
th:class="${pageNum == postsPage.number} ? 'button small' : 'button alt small'"
style="border-radius:0; margin:0;">
</a>
</li>
<li th:styleappend="${postsPage.isLast()} ? 'opacity: 0.5; pointer-events: none;'" style="display: inline; float: left;">
<a th:href="${postsPage.isLast()} ? '#' : @{/blog/posts(page=${postsPage.number + 1})}"
class="button alt small" style="border-radius:0; margin:0;">
Next &raquo;
</a>
</li>
</ul>
</nav>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</th:block> </th:block>
</body>
</html> </html>

View File

@ -1,53 +1,81 @@
<!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">
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(false)});</script>
</th:block> <th:block layout:fragment="head">
</head> <link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/quill-table-better@1/dist/quill-table-better.js"></script>
<script>document.addEventListener('DOMContentLoaded', function() {initEditor(false)});</script>
</th:block>
<body>
<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()" onclick="loadEditor()" style="cursor: pointer;" title="클릭하여 수정하기"> <div class="container" sec:authorize="isAuthenticated()" onclick="loadEditor()" style="cursor: pointer;" title="클릭하여 수정하기">
<header class="major"> <header class="major">
<h2 id="title_layer" th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</h2> <h2 id="title_layer">
<p 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> <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> </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>
<div class="container open-login-popup" sec:authorize="isAnonymous()" to="#loginPopup" style="cursor: pointer;"> <div class="container open-login-popup" sec:authorize="isAnonymous()" to="#loginPopup" style="cursor: pointer;">
<header class="major"> <header class="major">
<h2 id="title_layer" th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</h2> <h2 id="title_layer_anon">
<p 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> <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> </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>
<section class="wrapper style1"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div id="content"> <div id="content_inner">
<article> <article>
<div id="editor"></div> <div id="editor"></div>
<div class="write_controllbox"> <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 class="write_option controlbox-category"> <button class="button" onclick="handleVote(this, 'like')">
</div> 👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>)
<div style="width: 15px"></div> </button>
<div class="write_option controlbox-hashtag" id="hashtag_field"> <button class="button" onclick="handleVote(this, 'unlike')" style="margin-left: 1em;">
</div> 👎 Unlike (<span class="unlike-count" th:text="${srcPost.unlikeCount}">0</span>)
<div style="width: 15px"></div> </button>
<div class="write_option" id="location_field">
</div>
</div> </div>
<h3 id="write" th:text="${srcPost.firstAddress}"></h3> <h3 id="write" th:text="${srcPost.firstAddress}"></h3>
@ -67,5 +95,4 @@
</div> </div>
</section> </section>
</th:block> </th:block>
</body>
</html> </html>

View File

@ -10,17 +10,57 @@
<th:block layout:fragment="content" id="content"> <th:block layout:fragment="content" id="content">
<div id="main_layer"> <div id="main_layer">
<div class="layer"> <div class="layer">
<th:block id="where" th:each="location : ${locations}"> <th:block th:each="location : ${locationPage.content}">
<div class="where_item" style="font-family: monospace; white-space: nowrap;"> <div class="location-item"> <div class="location-header">
<span th:text="${location.timeString}"></span> | <span class="location-time" th:text="${location.displayTime}"></span>
<span th:text="${location.mAddressLines}"></span> | <span class="location-between" th:text="${location.bettween}"></span>
<span th:text="${location.bettween}"></span> | </div>
<span th:text="${#numbers.formatDecimal(location.mLatitude, 1, 3)}"></span> | <div class="location-body">
<span th:text="${#numbers.formatDecimal(location.mLongitude, 1, 3)}"></span> | <p class="location-address" th:text="${location.mAddressLines}"></p>
<span th:text="${location.mCountryName}"></span> <p class="location-coords">
<span>위도: <b th:text="${#numbers.formatDecimal(location.mLatitude, 1, 3)}"></b></span>
<span>경도: <b th:text="${#numbers.formatDecimal(location.mLongitude, 1, 3)}"></b></span>
<span class="location-country" th:text="${'(' + location.mCountryName + ')'}"></span>
</p>
</div>
</div> </div>
</th:block> </th:block>
</div> </div>
<div class="pagination">
<th:block th:with="currentPage=${locationPage.number},
totalPages=${locationPage.totalPages}">
<a class="nav-link" th:if="${currentPage > 0}" th:href="@{/bums/where.bs(page=0)}">&lt;&lt; 처음</a>
<span class="nav-link disabled" th:unless="${currentPage > 0}">&lt;&lt; 처음</span>
<a class="nav-link" th:if="${locationPage.hasPrevious()}" th:href="@{/bums/where.bs(page=${currentPage - 1})}">&lt; 이전</a>
<span class="nav-link disabled" th:unless="${locationPage.hasPrevious()}">&lt; 이전</span>
<th:block th:with="startPage=${T(java.lang.Math).max(0, currentPage - 3)},
endPage=${T(java.lang.Math).min(totalPages - 1, currentPage + 3)}">
<th:block th:each="pageNum : ${#numbers.sequence(startPage, endPage)}">
<a class="page-num"
th:if="${pageNum != currentPage}"
th:href="@{/bums/where.bs(page=${pageNum})}"
th:text="${pageNum + 1}">
</a>
<span class="page-num current-page"
th:if="${pageNum == currentPage}"
th:text="${pageNum + 1}">
</span>
</th:block>
</th:block>
<a class="nav-link" th:if="${locationPage.hasNext()}" th:href="@{/bums/where.bs(page=${currentPage + 1})}">다음 &gt;</a>
<span class="nav-link disabled" th:unless="${locationPage.hasNext()}">다음 &gt;</span>
<a class="nav-link" th:if="${currentPage < totalPages - 1}" th:href="@{/bums/where.bs(page=${totalPages - 1})}">&gt;&gt;</a>
<span class="nav-link disabled" th:unless="${currentPage < totalPages - 1}">&gt;&gt;</span>
</th:block>
</div>
</div> </div>
</th:block> </th:block>
</html> </html>

View File

@ -17,16 +17,45 @@
<meta name="_csrf" th:content="${_csrf.token}"/> <meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/> <meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/>
<script th:inline="javascript"> <script th:inline="javascript">
/*
* [수정됨] 이 객체는 Post.kt 모델의 모든 필드를 포함해야 합니다.
* 여기서 누락된 필드는 편집 후 저장 시 서버에서 null 또는 0으로 초기화됩니다.
*/
var serverData = { var serverData = {
id: [[${srcPost != null and srcPost.id != null} ? ${srcPost.id} : 0]], // --- Key IDs ---
originId: [[${srcPost != null and srcPost.originId != null} ? ${srcPost.originId} : 0]], id: [[${srcPost?.id}]], // (Thymeleaf 3.x의 안전 탐색 연산자 '?' 사용)
title: /*[[${srcPost != null and srcPost.title != null} ? ${srcPost.title} : '']]*/, originId: [[${srcPost?.originId}]],
content: /*[[${srcPost != null and srcPost.content != null} ? ${srcPost.content} : '']]*/,
firstPostLat: [[${srcPost != null and srcPost.firstPostLat != null} ? ${srcPost.firstPostLat} : 0]], // --- Core Content (컨트롤러에서 이미 디코딩됨) ---
firstPostLon: [[${srcPost != null and srcPost.firstPostLon != null} ? ${srcPost.firstPostLon} : 0]], title: /*[[${srcPost?.title ?: ''}]]*/,
writeTime: [[${srcPost != null and srcPost.writeTime != null} ? ${srcPost.writeTime} : 0]], content: /*[[${srcPost?.content ?: ''}]]*/,
enc: /*[[${enc != null} ? ${enc} : '']]*/,
keyword: /*[[${keyword != null} ? ${keyword} : '']]*/ // === (수정) 치명적으로 누락되었던 필드들 ===
category: /*[[${srcPost?.category ?: 'none'}]]*/,
tags: /*[[${srcPost?.tags ?: ''}]]*/,
// --- Timestamps (데이터 보존을 위해 필수) ---
writeTime: [[${srcPost?.writeTime ?: 0}]],
modifyTime: [[${srcPost?.modifyTime ?: 0}]],
// --- Location Data (데이터 보존을 위해 필수) ---
firstPostLat: [[${srcPost?.firstPostLat ?: 0.0}]],
firstPostLon: [[${srcPost?.firstPostLon ?: 0.0}]],
firstAddress: /*[[${srcPost?.firstAddress ?: ''}]]*/,
modifyLat: [[${srcPost?.modifyLat ?: 0.0}]],
modifyLon: [[${srcPost?.modifyLon ?: 0.0}]],
modifyAddress: /*[[${srcPost?.modifyAddress ?: ''}]]*/,
// --- Metadata (데이터 보존을 위해 필수) ---
writer: /*[[${srcPost?.writer ?: ''}]]*/,
posting: [[${srcPost?.posting ?: false}]],
readCount: [[${srcPost?.readCount ?: 0}]],
voteCount: [[${srcPost?.voteCount ?: 0}]],
unlikeCount: [[${srcPost?.unlikeCount ?: 0}]],
// --- Page-specific (모델 데이터 아님) ---
enc: /*[[${enc ?: ''}]]*/,
keyword: /*[[${keyword ?: ''}]]*/
}; };
</script> </script>
</th:block> </th:block>