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
fun webSecurityCustomizer(): WebSecurityCustomizer {
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. (권한 검사 불필요)
return WebSecurityCustomizer { web ->
web.ignoring().requestMatchers("/blog/post/images/**")
}
}
// RememberMeServices를 Bean으로 생성하고 필드에 할당하거나, 생성자 주입을 할 수 있음
@Bean
fun rememberMeServices(): RememberMeServices {
val key = "your-remember-me-key"
@ -68,15 +67,24 @@ class SecurityConfig(
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
http.csrf { csrf ->
// [수정]
// CSRF 보호 예외 목록에서 like/unlike 엔드포인트를 제거합니다.
// 이 엔드포인트들은 POST 요청이며 인증이 필요하므로, CSRF 보호를 받는 것이 올바릅니다.
// (common.js에서 X-CSRF-TOKEN 헤더를 정상적으로 보내고 있습니다.)
csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx","/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
"/blog/post/images/**","/puzzle/**","/puzzle/play/**",
// "/blog/post/images/**", // WebSecurityCustomizer에서 이미 ignoring 처리됨
"/puzzle/**","/puzzle/play/**",
"/rank/**","/spider/**",
"/sudoku/**",
) // 여기 예외 추가
)
}.authorizeHttpRequests { auth ->
auth
// [정상 유지] 이 두 엔드포인트는 인증(로그인)이 필요하며 CSRF 보호를 받아야 합니다.
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/like.bjx").permitAll()
.requestMatchers(HttpMethod.POST, "/blog/post/{postId}/unlike.bjx").permitAll()
// permitAll() 목록
.requestMatchers(
"/",
"/home.bs",
@ -84,12 +92,13 @@ class SecurityConfig(
"/tlg/repotToMe.bjx",
"/user/login.bs", "/user/signup.bs","/user/login.bjx",
"/blog/viewer/**" , "/blog/posts" , "/blog/rankOfViews.bjx","/blog/recentOfPost.bjx",
// "/blog/post/imageUpload.bjx",
"/blog/post/images/**",
// "/blog/post/images/**", // WebSecurityCustomizer에서 ignoring 처리되었으므로 여기서 제외해도 됩니다.
"/spider/new**",
"/rank/**","/sudoku/**","/spider/**",
"/puzzle/play","/puzzle/2048","/puzzle/play/**","/puzzle/sudoku","/puzzle/spider",
"/webfonts/**", "/css/**", "/js/**", "/images/**", "/webjars/**", "/assets/**").permitAll()
// 나머지 모든 요청은 인증이 필요합니다.
.anyRequest().authenticated()
}.formLogin { form ->
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.core.io.Resource
import org.springframework.core.io.UrlResource
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import org.springframework.data.web.PageableDefault
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.*
@ -36,6 +39,7 @@ import org.springframework.web.reactive.function.client.WebClient
import reactor.core.publisher.Mono
import java.io.*
import java.net.URLDecoder
import java.security.Principal
import java.text.SimpleDateFormat
import java.util.*
@ -77,6 +81,7 @@ class BlogController(private val commentService : CommentService) {
fun post(httpServletRequest: HttpServletRequest, @RequestBody jsonString: String) : ResponseEntity<ResponceResult> {
logService.log(httpServletRequest.requestURI)
logService.log(jsonString)
var postId = ""
var lResultCode = 0
var lResultMsg = "Suscces"
val decodedBytes: ByteArray = Base64.getDecoder().decode(jsonString)
@ -93,27 +98,79 @@ class BlogController(private val commentService : CommentService) {
var max = nb.size + na.size
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()) } } }
// 1. 클라이언트 JSON을 가져옵니다. (콘솔에서 정상 확인됨)
val jsonFromClient = 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) {
target.id = null
// === A. 신규 게시물 저장 로직 ===
target.id = null // 새 문서이므로 ID는 null
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 {
logService.log("target.writeTime >>> ${target.writeTime}")
// === B. 기존 게시물 수정 (새 버전 생성) 로직 ===
// (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음)
// 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다.
target.modifyTime = System.currentTimeMillis()
postManager.save(target)
target = Gson().fromJson(fullData.joinToString(""), Post::class.java) ?: Post()
target.originId = target.id
target.id = null
}
var postMono = postManager.save(target)
if (postMono != null) {
lResultMsg = "save post"
lResultCode = 0
} else {
lResultMsg = "not founding user[can't find same id,email.. ]"
lResultCode = 7100
// 4. 이 게시물이 "버전 기록용 사본"임을 설정합니다.
target.originId = target.id // 원본 마스터 ID를 originId 필드에 저장
target.id = null // Mongo가 새 ID를 생성하도록 ID를 null로 변경
// 5. 모든 데이터(새 카테고리, 태그, modifyTime 포함)가 담긴 "새 버전 문서"를 저장합니다.
val savedVersionPost = postManager.save(target).block() // 동기식으로 저장 완료 대기
if (savedVersionPost?.id != null) {
lResultCode = 0
lResultMsg = "New post version saved"
// 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) {
e.printStackTrace()
lResultMsg = "unknown exception"
@ -126,6 +183,7 @@ class BlogController(private val commentService : CommentService) {
}.contentType(MediaType.APPLICATION_JSON).body(ResponceResult().apply {
this.resultCode = lResultCode
this.resultMsg = lResultMsg
this.data.put("postId",postId)
})
return responce
}
@ -183,9 +241,8 @@ class BlogController(private val commentService : CommentService) {
} else {
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 {
if (this?.firstAddress?.length ?: 0 < 4){
@ -209,30 +266,30 @@ class BlogController(private val commentService : CommentService) {
}
@GetMapping("modify.bs")
fun modify(httpServletRequest: HttpServletRequest, @RequestParam("token") token : String?) : ResultMV{
logService.log("incoming modify")
val vm = ResultMV("content/blog/modify")
val authentication = SecurityContextHolder.getContext().authentication
val principal = authentication.principal
if (principal is UserDetails) {
val username = principal.username
// 추가 정보 사용 가능
postManager.find20()?.apply {
forEach {
it.title = URLDecoder.decode(it.title)
val content = URLDecoder.decode(it.content)
it.content = if (content.length > 50) content.substring(0,150) else content
}
vm.modelMap.put("chunkedPosts", this.chunked(3))
}
vm.modelMap.put(WRITE_PERMISSION_KEY,"OK")
vm.modelMap.put("path","editor/")
vm.modelMap.put("SK",token)
}
vm.modelMap.put("rowKey","chunkedPosts_")
return vm
}
// @GetMapping("modify.bs")
// fun modify(httpServletRequest: HttpServletRequest, @RequestParam("token") token : String?) : ResultMV{
// logService.log("incoming modify")
// val vm = ResultMV("content/blog/modify")
// val authentication = SecurityContextHolder.getContext().authentication
// val principal = authentication.principal
// if (principal is UserDetails) {
// val username = principal.username
// // 추가 정보 사용 가능
// postManager.find20()?.apply {
// forEach {
// it.title = URLDecoder.decode(it.title)
// val content = URLDecoder.decode(it.content)
// it.content = if (content.length > 50) content.substring(0,150) else content
// }
// vm.modelMap.put("chunkedPosts", this.chunked(3))
// }
// vm.modelMap.put(WRITE_PERMISSION_KEY,"OK")
// vm.modelMap.put("path","editor/")
// vm.modelMap.put("SK",token)
// }
// vm.modelMap.put("rowKey","chunkedPosts_")
// return vm
// }
// @GetMapping("editor/{postId}")
// fun editor(@PathVariable postId : String) : ResultMV{
@ -264,6 +321,10 @@ class BlogController(private val commentService : CommentService) {
postManager.getPost(postId).block()?.apply {
this.title = URLDecoder.decode(this.title)
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["pageTitle"] = "글 수정" // 페이지 제목을 동적으로 설정
}
@ -277,30 +338,72 @@ class BlogController(private val commentService : CommentService) {
return vm
}
/**
* [수정] /posts 엔드포인트 로직
* @PageableDefault(size = 8) 추가: 기본 페이지 크기를 8 설정
*/
@GetMapping("posts")
fun posts(pageable: Pageable) : ResultMV{
fun posts(@PageableDefault(size = 8) pageable: Pageable, authentication: Authentication?) : ResultMV { // @PageableDefault 추가
val vm = ResultMV("content/blog/posts")
try {
vm.modelMap.put("Posts", postManager.find20(pageable).apply {
this.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 ""
val postsList: List<Post>
val totalPosts: Long
// [수정] 사용자의 권한 중 'ROLE_ADMIN'이 있는지 확인합니다.
val isAdmin = authentication?.authorities?.any { it.authority == "ROLE_ADMIN" } ?: false
if (isAdmin) {
// [관리자]: 모든 버전의 글을 조회합니다.
logService.log("User is ADMIN. Loading all post versions.")
postsList = postManager.findAllVersionsPaginated(pageable)
totalPosts = postManager.countAllVersions().block() ?: 0L
} else {
// [모든 방문자 (비로그인 + 일반로그인)]: 고유한 최신 버전의 글만 조회합니다.
logService.log("User is ANONYMOUS or NON-ADMIN. Loading unique latest posts.")
postsList = postManager.findLatestUniquePaginated(pageable)
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()
}
})
}catch (ex: Exception){ex.printStackTrace()}
it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
}
// [수정] 결과를 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
}
@ -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 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.model.*
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.Page
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.servlet.ModelAndView
import java.util.Base64
// Spring Data Paging을 위한 Import 추가
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import reactor.core.publisher.Flux
@RestController
@RequestMapping("/bums")
@ -31,12 +30,20 @@ class BumsPrivate {
lateinit var locationService: LocationLogService
@GetMapping("where.bs")
fun where() : ResultMV {
fun where(@RequestParam(value = "page", defaultValue = "0") page: Int) : ResultMV { // (1) page 파라미터 받기
val m = ResultMV("content/private/where")
locationService.find10().apply {
m.modelMap.put("locations",this)
}
// (2) Pageable 객체 생성: 현재 페이지(page), 페이지당 20개, ID 역순 정렬 (최신순)
// 예시: 날짜 필드명이 "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("돼지 여기있다요~!!")
return m
}

View File

@ -153,7 +153,7 @@ class Home {
@GetMapping("/h2")
fun home2() : ResultMV {
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 {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
@ -166,7 +166,7 @@ class Home {
@GetMapping("/left-sidebar")
fun lside() : ResultMV {
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 {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
@ -178,7 +178,7 @@ class Home {
@GetMapping("/no-sidebar")
fun nside() : ResultMV {
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 {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
@ -191,7 +191,7 @@ class Home {
@GetMapping("/right-sidebar")
fun rside() : ResultMV {
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 {
it.title = URLDecoder.decode(it.title)
it.content = URLDecoder.decode(it.content)
@ -204,7 +204,7 @@ class Home {
@GetMapping("/two-sidebar")
fun bside() : ResultMV {
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 {
it.title = URLDecoder.decode(it.title)
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.springframework.beans.factory.annotation.Autowired
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.mongodb.core.mapping.Document
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
@ -22,9 +23,9 @@ import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.text.SimpleDateFormat
import java.time.Duration
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.*
import org.springframework.data.domain.PageImpl
import java.time.format.DateTimeFormatter
class BumsPrivate {
}
@ -56,6 +57,24 @@ class LocationLog {
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 {
val buffer = StringBuffer()
buffer.append(mFeatureName).append("|").append("\n")
@ -104,8 +123,28 @@ class LocationLogService : LocationService {
@Autowired
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> {
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 7)
val sinceMills = System.currentTimeMillis() - ((24 * 60 * 60 * 1000) * 100)
println("sinceMills >> $sinceMills")
val sort = Sort.by(Sort.Direction.DESC, "time") // 오름차순 정렬
// 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.BsonRepresentation
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
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.mapping.Document
import org.springframework.data.mongodb.core.query.Criteria
@ -94,6 +96,11 @@ class CommentsResult {
var comments: List<Comment>? = null
}
/**
* @Aggregation의 $count 단계에서 결과를 매핑하기 위한 헬퍼 데이터 클래스입니다.
*/
data class AggregationCount(val totalCount: Long)
@Repository
interface CommentRepository : ReactiveMongoRepository<Comment, String> {
@ -121,19 +128,34 @@ class CommentService(private val commentRepository: CommentRepository) {
@Repository
interface PostRepository : ReactiveMongoRepository<Post, String> {
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 countByOrderByModifyTimeDesc(): Mono<Long>
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
/**
* 익명 사용자를 위한 '고유 최신 ' 목록을 페이지네이션으로 조회합니다.
* [버그 수정] 2 정렬 경로를 "post.post.modifyTime" -> "post.modifyTime" 으로 변경
*/
@Aggregation(pipeline = [
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: \"\$originId\", post: { \$first: \"\$\$ROOT\" } } }",
"{ \$sort: { \"post.modifyTime\": -1 } }",
"{ \$limit: 8 }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$sort: { \"post.modifyTime\": -1 } }", // [수정됨]
"{ \$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
private lateinit var bCryptPasswordEncoder: PasswordEncoder
// fun getPost(id : String) : Mono<Post> = postRepository.findById(id)
fun getPost(id: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(id))
val update = Update().inc("readCount", 1)
// 이 메서드는 기본값(returnNew=false)를 사용하여, 증가되기 *전*의 문서를 반환합니다.
// (뷰어 로딩과 동시에 DB 카운트만 1 증가시킴)
return reactiveMongoTemplate.findAndModify(query, update, Post::class.java)
.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("pageNumber >>> ${pageable.pageNumber}")
return postRepository.findAllByOrderByModifyTimeDesc(pageable)
@ -167,6 +195,56 @@ class PostManager(
?: 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> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title)
@ -191,10 +269,15 @@ class PostManager(
}
/**
* [로직 수정]
* 화면은 이제 "익명 사용자용 최신 글" 0 페이지, 8 아이템을 명시적으로 요청합니다.
*/
fun find8() : List<Post> {
return postRepository.findLatestUniqueOrigin().collectList() // Mono<List<Post>>로 변환 // Flux<Post> → Mono<List<Post>>
.block(Duration.ofSeconds(30)) ?: emptyList()
// 홈 화면은 항상 0번 페이지의 8개 아이템을 요청합니다.
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
// 하드코딩된 쿼리 대신, 익명사용자용 페이지네이션 메서드를 호출합니다.
return this.findLatestUniquePaginated(pageRequest)
}
fun find20() : List<Post> {

View File

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

View File

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

View File

@ -140,24 +140,28 @@ a.btn_layerClose:hover {
margin-top: 2em;
padding: 0;
display: flex;
justify-content: space-between;
flex-direction: row; /* [수정] 아이템을 가로(row)로 정렬합니다. */
align-items: stretch; /* [수정] 모든 박스가 동일한 높이를 갖도록 stretch로 변경 */
list-style: none;
gap: 15px; /* Use gap for spacing */
}
.write_option {
display: flex; /* Use flexbox for centering */
justify-content: center;
align-items: center;
text-align: center;
width: 100%;
display: flex;
flex-direction: column; /* [수정] 자식(제목, 내용)을 세로로 쌓습니다. */
justify-content: flex-start; /* 내용을 상단부터 표시합니다. */
align-items: flex-start; /* 모든 내용을 왼쪽 정렬합니다. */
flex: 1 1 0%; /* [핵심] 1:1:1 비율로 동일한 너비를 갖도록 설정 */
min-width: 0; /* [추가] flex item이 내용 래핑을 위해 축소될 수 있도록 허용 */
padding: 0.75em;
margin: 0;
background: var(--pure-white, #fff);
border: solid 1px #e0e0e0;
border-radius: 5px;
color: inherit;
min-height: 48px; /* Set a minimum height */
min-height: 48px;
}
@ -178,11 +182,35 @@ a.btn_layerClose:hover {
}
.ql-container.ql-snow {
min-width: unset;
background: var(--pure-white, #fff);
border: 1px solid #e0e0e0;
border-radius: 0 0 5px 5px;
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 */
.ql-editor {
@ -191,6 +219,27 @@ a.btn_layerClose:hover {
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 ---
* Styled to fit the main theme.
@ -239,13 +288,20 @@ a.btn_layerClose:hover {
word-wrap: break-word;
}
/* 읽기 모드 컨트롤 박스 내부의 태그 스타일 */
/* 컨트롤 박스 내부 공통 제목 스타일 */
.write_option .tag-title {
font-weight: 600;
margin-right: 0.5em;
margin-bottom: 0.35em; /* [수정] 제목과 내용 사이에 하단 여백을 줍니다. */
color: #555;
font-size: 0.85em; /* [추가] 제목 라벨 폰트를 살짝 작게 */
text-transform: uppercase; /* [추가] 라벨처럼 보이도록 영문 대문자 처리 */
/* 태그 제목(HASHTAGS)이 태그 아이템과 동일한 스타일을 공유할 경우 대비 (선택 사항) */
display: block;
width: 100%;
}
/* 컨트롤 박스 내부 공통 태그 아이템 스타일 (뷰어 모드용) */
.write_option .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
@ -262,3 +318,148 @@ a.btn_layerClose:hover {
.write_option.controlbox-hashtag:not(.btn-example) {
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%;*/
/* place-content: space-between;*/
/* place-items: stretch;*/
/* display: grid;*/
/* gap: 10px;*/
/* grid-auto-rows: minmax(200px, auto);*/
/* grid-template-columns: repeat(auto-fill, minmax(200px, auto));*/
/* width: 100%;*/
/*}*/
/*!*.where_item {*!*/
/*!* justify-content: space-between;*!*/
/*!* flex-wrap: wrap;*!*/
/*!* flex-direction: row;*!*/
/*!* width: 100%;*!*/
/*!* border-radius: 10px;*!*/
/*!* background: #F0F0F524;*!*/
/*!*}*!*/
/* 기본 레이아웃 및 폰트 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f4f7f6;
color: #333;
line-height: 1.6;
}
#main_layer {
max-width: 900px;
margin: 30px auto;
background-color: #ffffff;
padding: 25px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.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 에디터 인스턴스와 게시물 기본 데이터를 저장합니다.
var quill = null;
var currentLat = 0.0;
@ -55,6 +58,83 @@ $(document).ready(function() {
$('.open-login-popup').on('click', function() {
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': [] }],
[{ 'header': 1 }, { 'header': 2 }, 'blockquote', 'code-block'],
[{ 'script': 'sub'}, { 'script': 'super' }], [{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'indent': '-1'}, { 'indent': '+1' }], ['link', 'image', 'video'],
['table-better'], [{ 'direction': 'rtl' }], [{ 'align': [] }], ['clean']
[{ 'indent': '-1'}, { 'indent': '+1' }], [{ 'align': [] }],
['table-better'], [{ 'direction': 'rtl' }], ['clean'], ['link', 'image', 'video'],
],
handlers: { image: function() { selectLocalImage(); }, video: function() { selectLocalVideo(); } }
},
@ -248,56 +328,88 @@ function setupControlBox(mode) {
if (mode === 'edit') {
categoryBox.setAttribute('onclick', 'openPopup(this)');
hashtagBox.setAttribute('onclick', 'openPopup(this)');
categoryBox.innerText = '카테고리 설정';
hashtagBox.innerText = '해시태그 편집';
fetchCategoriesAndHashtags();
// === 수정된 부분 ===
// 기존 "innerText" 설정 줄을 삭제하고,
// 페이지 로드 시 현재 데이터로 UI를 업데이트하는 함수를 호출합니다.
updateControlBoxDisplay();
// ==================
fetchCategoriesAndHashtags(); // 팝업 목록 채우는 로직은 그대로 실행
} else {
// (읽기 모드 'else' 블록 수정)
categoryBox.removeAttribute('onclick');
hashtagBox.removeAttribute('onclick');
categoryBox.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) {
baseData.tags.split(',').forEach(tag => {
hashtagBox.innerHTML += `<span class="tag-item">#${tag.trim()}</span>`;
});
hashtagContent = baseData.tags.split(',').map(tag => {
return `<span class="tag-item">#${tag.trim()}</span>`;
}).join(' '); // join으로 하나의 문자열로 만듭니다.
} 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를 호출하여 카테고리와 해시태그 목록을 가져와 팝업을 채웁니다.
* (수정됨: 클릭 baseData 대신 Staging 변수를 업데이트하도록 변경)
*/
function fetchCategoriesAndHashtags() {
// Fetch Categories
fetch(`${getMainPath()}/blog/categories.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#category-list');
if(list) {
if (list) {
list.innerHTML = '';
data.tags.forEach(tag => {
const el = document.createElement('span');
el.className = 'tag-item';
el.innerText = tag;
// (로직 변경) 클릭 시
el.onclick = function() {
stagedCategory = tag; // 1. 임시 변수(stagedCategory) 업데이트
renderStagedCategory(); // 2. 스테이징 UI만 새로고침 (팝업 안 닫음)
};
list.appendChild(el);
});
}
}
}).catch(err => console.error('Error fetching categories:', err));
// Fetch Hashtags
fetch(`${getMainPath()}/blog/hashtags.bjx`).then(res => res.json()).then(data => {
if (data.resultCode === 0 && data.tags) {
const list = document.querySelector('#hashtag-list');
if(list) {
if (list) {
list.innerHTML = '';
data.tags.forEach(tag => {
const rawTag = tag;
const el = document.createElement('span');
el.className = 'tag-item';
el.innerText = `#${tag}`;
el.innerText = `#${rawTag}`;
// (로직 변경) 클릭 시
el.onclick = function() {
// 1. 임시 배열(stagedHashtags)에 추가 (중복 방지 헬퍼 사용)
if (addTagToStaged(rawTag)) {
// 2. 추가 성공 시에만 스테이징 UI 새로고침
renderStagedHashtags();
}
};
list.appendChild(el);
});
}
@ -333,18 +445,66 @@ function loadEditor() {
*/
function save() {
const titleField = document.getElementById('title_field');
// --- (수정/신규 로직) ---
// 1. baseData의 복사본을 만들어 전송용 임시 객체(dataToSend)를 생성합니다.
// (원본 baseData를 직접 수정하면 팝업 UI가 인코딩된 문자로 깨집니다)
let dataToSend = JSON.parse(JSON.stringify(baseData));
// 2. dataToSend 객체의 모든 텍스트 필드를 encodeURIComponent로 인코딩합니다.
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()));
baseData.modifyLat = currentLat;
baseData.modifyLon = currentLon;
dataToSend.content = encodeURIComponent(JSON.stringify(quill.getContents()));
// (누락되었던 필드 추가)
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`;
if (confirm("해당 내용으로 저장하시겠습니까?")) {
post(uploadUrl, serverData.enc, JSON.stringify(baseData), serverData.keyword, function(resultData) {
alert("저장되었습니다.");
console.log("Data being sent to server:", dataToSend);
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() {
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 => {
currentLat = pos.coords.latitude;
currentLon = pos.coords.longitude;
@ -363,9 +548,18 @@ function getLocation() {
baseData.modifyLon = currentLon;
const locationField = document.getElementById('location_field');
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) {
const targetId = element.getAttribute('to');
const popup = document.querySelector(targetId);
const overlay = document.querySelector('.dim_layer');
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';
popup.style.display = 'block';
}
@ -461,7 +674,7 @@ function submitLoginForm() {
function gotoHome() { document.location.replace(`${getMainPath()}/home.bs`); }
function gotoWrite() { document.location.replace(`${getMainPath()}/blog/edit`); } // 수정된 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`); }
/**
@ -544,3 +757,171 @@ function unformat(type, data, key) {
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}"
>
<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>
<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" >
<th:block sec:authorize="isAuthenticated()">
<div class="container" >
@ -33,6 +33,14 @@
<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 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>
</th:block>
</section>
@ -46,44 +54,52 @@
<div class="container">
<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>
</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>

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>
</head>
<body>
<th:block layout:fragment="content" id="content">
<section class="wrapper style1">
<div class="container">
<div class="row justify-content-center">
<div class="col-8 col-12-narrower">
<div id="content">
<div id="content_inner">
<article>
<section th:each="post : ${Posts}">
<section th:each="post : ${postsPage.content}">
<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)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
</a>
<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>
</div>
<footer sec:authorize="isAuthenticated()" style="text-align: right; margin-top: 1em;">
@ -33,11 +40,34 @@
</div>
</section>
</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>
</section>
</th:block>
</body>
</html>

View File

@ -1,53 +1,81 @@
<!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}"
>
<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>
<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>
</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>
<body>
<th:block layout:fragment="content" id="content">
<section class="wrapper style2">
<div class="container" sec:authorize="isAuthenticated()" onclick="loadEditor()" style="cursor: pointer;" title="클릭하여 수정하기">
<header class="major">
<h2 id="title_layer" th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</h2>
<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>
<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>
<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">
</div>
<div class="write_option controlbox-location" id="location_field">
</div>
</div>
</div>
<div class="container open-login-popup" sec:authorize="isAnonymous()" to="#loginPopup" style="cursor: pointer;">
<header class="major">
<h2 id="title_layer" th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</h2>
<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>
<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>
</section>
<section class="wrapper style1">
<div class="container">
<div id="content">
<div id="content_inner">
<article>
<div id="editor"></div>
<div class="write_controllbox">
<div class="write_option controlbox-category">
</div>
<div style="width: 15px"></div>
<div class="write_option controlbox-hashtag" id="hashtag_field">
</div>
<div style="width: 15px"></div>
<div class="write_option" id="location_field">
</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}">
<button class="button" onclick="handleVote(this, 'like')">
👍 Like (<span class="like-count" th:text="${srcPost.voteCount}">0</span>)
</button>
<button class="button" onclick="handleVote(this, 'unlike')" style="margin-left: 1em;">
👎 Unlike (<span class="unlike-count" th:text="${srcPost.unlikeCount}">0</span>)
</button>
</div>
<h3 id="write" th:text="${srcPost.firstAddress}"></h3>
@ -67,5 +95,4 @@
</div>
</section>
</th:block>
</body>
</html>

View File

@ -10,17 +10,57 @@
<th:block layout:fragment="content" id="content">
<div id="main_layer">
<div class="layer">
<th:block id="where" th:each="location : ${locations}">
<div class="where_item" style="font-family: monospace; white-space: nowrap;">
<span th:text="${location.timeString}"></span> |
<span th:text="${location.mAddressLines}"></span> |
<span th:text="${location.bettween}"></span> |
<span th:text="${#numbers.formatDecimal(location.mLatitude, 1, 3)}"></span> |
<span th:text="${#numbers.formatDecimal(location.mLongitude, 1, 3)}"></span> |
<span th:text="${location.mCountryName}"></span>
<th:block th:each="location : ${locationPage.content}">
<div class="location-item"> <div class="location-header">
<span class="location-time" th:text="${location.displayTime}"></span>
<span class="location-between" th:text="${location.bettween}"></span>
</div>
<div class="location-body">
<p class="location-address" th:text="${location.mAddressLines}"></p>
<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>
</th:block>
</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>
</th:block>
</html>

View File

@ -17,16 +17,45 @@
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/>
<script th:inline="javascript">
/*
* [수정됨] 이 객체는 Post.kt 모델의 모든 필드를 포함해야 합니다.
* 여기서 누락된 필드는 편집 후 저장 시 서버에서 null 또는 0으로 초기화됩니다.
*/
var serverData = {
id: [[${srcPost != null and srcPost.id != null} ? ${srcPost.id} : 0]],
originId: [[${srcPost != null and srcPost.originId != null} ? ${srcPost.originId} : 0]],
title: /*[[${srcPost != null and srcPost.title != null} ? ${srcPost.title} : '']]*/,
content: /*[[${srcPost != null and srcPost.content != null} ? ${srcPost.content} : '']]*/,
firstPostLat: [[${srcPost != null and srcPost.firstPostLat != null} ? ${srcPost.firstPostLat} : 0]],
firstPostLon: [[${srcPost != null and srcPost.firstPostLon != null} ? ${srcPost.firstPostLon} : 0]],
writeTime: [[${srcPost != null and srcPost.writeTime != null} ? ${srcPost.writeTime} : 0]],
enc: /*[[${enc != null} ? ${enc} : '']]*/,
keyword: /*[[${keyword != null} ? ${keyword} : '']]*/
// --- Key IDs ---
id: [[${srcPost?.id}]], // (Thymeleaf 3.x의 안전 탐색 연산자 '?' 사용)
originId: [[${srcPost?.originId}]],
// --- Core Content (컨트롤러에서 이미 디코딩됨) ---
title: /*[[${srcPost?.title ?: ''}]]*/,
content: /*[[${srcPost?.content ?: ''}]]*/,
// === (수정) 치명적으로 누락되었던 필드들 ===
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>
</th:block>