...
This commit is contained in:
parent
26a0f14e54
commit
51b97e2422
@ -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")
|
||||
|
||||
@ -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()
|
||||
} else {
|
||||
logService.log("target.writeTime >>> ${target.writeTime}")
|
||||
target.modifyTime = System.currentTimeMillis()
|
||||
postManager.save(target)
|
||||
target = Gson().fromJson(fullData.joinToString(""), Post::class.java) ?: Post()
|
||||
target.originId = target.id
|
||||
target.id = null
|
||||
|
||||
// [버그 수정] 새 글 저장 시 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" // 인증 정보 가져오기 실패 시
|
||||
}
|
||||
var postMono = postManager.save(target)
|
||||
if (postMono != null) {
|
||||
lResultMsg = "save post"
|
||||
|
||||
// 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 = "not founding user[can't find same id,email.. ]"
|
||||
lResultCode = 7100
|
||||
lResultMsg = "Failed to save new post"
|
||||
lResultCode = 7200
|
||||
}
|
||||
|
||||
} else {
|
||||
// === B. 기존 게시물 수정 (새 버전 생성) 로직 ===
|
||||
// (글쓴이는 client-sent 'target' 객체에 이미 포함되어 있으므로 별도 설정 필요 없음)
|
||||
|
||||
// 3. (정상 로직) 이 객체에 "수정 시간"을 설정합니다.
|
||||
target.modifyTime = System.currentTimeMillis()
|
||||
|
||||
// 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,12 +338,44 @@ 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 {
|
||||
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)
|
||||
@ -299,8 +392,18 @@ class BlogController(private val commentService : CommentService) {
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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> {
|
||||
@ -126,14 +133,29 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
|
||||
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> {
|
||||
|
||||
@ -5,7 +5,7 @@ import lombok.Getter
|
||||
|
||||
@Getter
|
||||
open class ResponceResult : BaseResult() {
|
||||
|
||||
var data : HashMap<String, String> = hashMapOf()
|
||||
}
|
||||
|
||||
@Getter
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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; */
|
||||
}
|
||||
@ -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,32 +328,47 @@ 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');
|
||||
@ -283,21 +378,38 @@ function fetchCategoriesAndHashtags() {
|
||||
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) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
/* ============================================= */
|
||||
|
||||
@ -6,18 +6,18 @@
|
||||
layout:decorate="~{layout/default_layout}"
|
||||
>
|
||||
|
||||
|
||||
<body>
|
||||
<th:block layout:fragment="content" id="content">
|
||||
<head>
|
||||
<th:block id="head">
|
||||
<th:block layout:fragment="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@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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<th:block layout:fragment="content" id="content">
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
@ -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;">
|
||||
« 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 »
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div> </div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,53 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
<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>
|
||||
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(false)});</script>
|
||||
|
||||
</th:block>
|
||||
</head>
|
||||
|
||||
<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>
|
||||
@ -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)}"><< 처음</a>
|
||||
<span class="nav-link disabled" th:unless="${currentPage > 0}"><< 처음</span>
|
||||
|
||||
<a class="nav-link" th:if="${locationPage.hasPrevious()}" th:href="@{/bums/where.bs(page=${currentPage - 1})}">< 이전</a>
|
||||
<span class="nav-link disabled" th:unless="${locationPage.hasPrevious()}">< 이전</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})}">다음 ></a>
|
||||
<span class="nav-link disabled" th:unless="${locationPage.hasNext()}">다음 ></span>
|
||||
|
||||
<a class="nav-link" th:if="${currentPage < totalPages - 1}" th:href="@{/bums/where.bs(page=${totalPages - 1})}">끝 >></a>
|
||||
<span class="nav-link disabled" th:unless="${currentPage < totalPages - 1}">끝 >></span>
|
||||
|
||||
</th:block>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</th:block>
|
||||
</html>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user