diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt index 0c39d50..ea0de1f 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/configs/SecurityConfig.kt @@ -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") diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt index 3c148ed..cd09fa9 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BlogController.kt @@ -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 { 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() 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 + 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(), 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>> { + 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>> { + return postManager.incrementUnlike(postId) + .map { updatedPost -> + ResponseEntity.ok(mapOf("voteCount" to updatedPost.voteCount, "unlikeCount" to updatedPost.unlikeCount)) + } + .defaultIfEmpty(ResponseEntity.notFound().build()) + } } \ No newline at end of file diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BumsPrivate.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BumsPrivate.kt index 25518b5..5ba1069 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BumsPrivate.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/BumsPrivate.kt @@ -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 = locationService.findAll(pageable) + + // (4) 모델에 Page 객체 전체를 전달 + m.modelMap.put("locationPage", locationPage) + m.setTitle("돼지 여기있다요~!!") return m } diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt index 6f5238e..ef77c04 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/controllers/Home.kt @@ -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) diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt index dea9531..bd215c5 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/BumsPrivate.kt @@ -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 { + + // 1. 페이지 데이터 가져오기 (비동기 -> 동기 'block()') + // Flux 스트림에 정렬, 스킵, 제한을 적용한 뒤 List로 변환합니다. + val items: List = logRepository + .findAll(pageable.getSort()) + .skip(pageable.getOffset()) + .take(pageable.getPageSize().toLong()) + .collectList() // Flux를 Mono>로 변환 + .block() ?: emptyList() // Mono를 block()하여 실제 List를 추출 + + // 2. 전체 카운트 가져오기 (페이지네이션 계산을 위해 별도 쿼리 필요) + val totalCount: Long = logRepository + .count() // Flux (count) + .block() ?: 0L // Mono를 block()하여 실제 Long 값을 추출 + + // 3. Page 구현체(PageImpl)로 조합하여 반환 + return PageImpl(items, pageable, totalCount) + } + fun find10() : List { - 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") diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt index 1e490d6..d1e1588 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/Post.kt @@ -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? = null } +/** + * @Aggregation의 $count 단계에서 결과를 매핑하기 위한 헬퍼 데이터 클래스입니다. + */ +data class AggregationCount(val totalCount: Long) + @Repository interface CommentRepository : ReactiveMongoRepository { @@ -121,19 +128,34 @@ class CommentService(private val commentRepository: CommentRepository) { @Repository interface PostRepository : ReactiveMongoRepository { fun findAllByModifyTime(time : Long? = 0): Flux -// @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 fun countByOrderByModifyTimeDesc(): Mono fun findTop5ByOrderByReadCountDesc(): Flux fun findTop5ByOrderByModifyTimeDesc(): Flux + + + /** + * 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다. + * [버그 수정] 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 + fun findLatestUniqueOriginPaginated(pageable: Pageable): Flux + + /** + * '고유 최신 글'의 총 개수를 카운트합니다. (페이지네이션의 totalElements 계산용) + */ + @Aggregation(pipeline = [ + "{ \$sort: { modifyTime: -1 } }", + "{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }", // 고유 ID로 그룹화 + "{ \$count: \"totalCount\" }" // 고유 그룹의 개수를 셈 + ]) + fun countLatestUniqueOrigin(): Mono // 헬퍼 클래스로 매핑 } @@ -147,17 +169,23 @@ class PostManager( @Autowired private lateinit var bCryptPasswordEncoder: PasswordEncoder - // fun getPost(id : String) : Mono = postRepository.findById(id) + fun getPost(id: String): Mono { 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 { + /** + * [이름 변경] find20 -> findAllVersionsPaginated + * 인증된 사용자를 위한 메서드 (모든 버전 조회) + */ + fun findAllVersionsPaginated(pageable :Pageable) : List { println("pageSize >>> ${pageable.pageSize}") println("pageNumber >>> ${pageable.pageNumber}") return postRepository.findAllByOrderByModifyTimeDesc(pageable) @@ -167,6 +195,56 @@ class PostManager( ?: listOf() } + /** + * 익명 사용자를 위한 메서드 (고유 최신 글 페이지네이션 조회) + */ + fun findLatestUniquePaginated(pageable: Pageable) : List { + return postRepository.findLatestUniqueOriginPaginated(pageable) + .collectList() + .block(Duration.ofSeconds(30)) ?: listOf() + } + + /** + * 인증된 사용자가 보는 글의 총 개수 + */ + fun countAllVersions(): Mono { + return postRepository.countByOrderByModifyTimeDesc() + } + + /** + * 익명 사용자가 보는 글의 총 개수 + */ + fun countLatestUnique(): Mono { + // AggregationCount(totalCount=N) 객체에서 Long 값만 추출합니다. 결과가 없으면 0L을 반환합니다. + return postRepository.countLatestUniqueOrigin() + .map { it.totalCount } + .switchIfEmpty(Mono.just(0L)) + } + + /** + * [신규 추가] + * 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 수 있도록 *업데이트된* 문서를 반환합니다. + */ + fun incrementVote(postId: String): Mono { + 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 { + 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 { return postRepository.findTop5ByOrderByReadCountDesc().map { p -> p.title = URLDecoder.decode(p.title) @@ -191,10 +269,15 @@ class PostManager( } - + /** + * [로직 수정] + * 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다. + */ fun find8() : List { - return postRepository.findLatestUniqueOrigin().collectList() // Mono>로 변환 // Flux → Mono> - .block(Duration.ofSeconds(30)) ?: emptyList() + // 홈 화면은 항상 0번 페이지의 8개 아이템을 요청합니다. + val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8 + // 하드코딩된 쿼리 대신, 익명사용자용 페이지네이션 메서드를 호출합니다. + return this.findLatestUniquePaginated(pageRequest) } fun find20() : List { diff --git a/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt b/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt index a88c3cf..601f2e5 100644 --- a/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt +++ b/src/main/kotlin/kr/lunaticbum/back/lun/model/ResponceResult.kt @@ -5,7 +5,7 @@ import lombok.Getter @Getter open class ResponceResult : BaseResult() { - + var data : HashMap = hashMapOf() } @Getter diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 486b12f..6b56a5e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 diff --git a/src/main/resources/static/css/common.css b/src/main/resources/static/css/common.css index 108294b..247779e 100644 --- a/src/main/resources/static/css/common.css +++ b/src/main/resources/static/css/common.css @@ -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); @@ -261,4 +317,149 @@ a.btn_layerClose:hover { .write_option.controlbox-category:not(.btn-example), .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; } \ No newline at end of file diff --git a/src/main/resources/static/css/private.css b/src/main/resources/static/css/private.css index 0809fd1..012d9dd 100644 --- a/src/main/resources/static/css/private.css +++ b/src/main/resources/static/css/private.css @@ -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;*!*/ -/*!*}*!*/ \ No newline at end of file +/* 기본 레이아웃 및 폰트 */ +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; */ +} \ No newline at end of file diff --git a/src/main/resources/static/js/common.js b/src/main/resources/static/js/common.js index 621906e..017ec43 100644 --- a/src/main/resources/static/js/common.js +++ b/src/main/resources/static/js/common.js @@ -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 = `카테고리: ${baseData.category || '지정되지 않음'}`; + // [수정] 카테고리를 새 구조(제목 + 래퍼)로 변경 + const categoryContent = `${baseData.category || '지정되지 않음'}`; + categoryBox.innerHTML = `CATEGORY
${categoryContent}
`; - hashtagBox.innerHTML = '태그: '; + // [수정] 해시태그를 새 구조(제목 + 래퍼)로 변경 + let hashtagContent = ''; if (baseData.tags && baseData.tags.length > 0) { - baseData.tags.split(',').forEach(tag => { - hashtagBox.innerHTML += `#${tag.trim()}`; - }); + hashtagContent = baseData.tags.split(',').map(tag => { + return `#${tag.trim()}`; + }).join(' '); // join으로 하나의 문자열로 만듭니다. } else { - hashtagBox.innerHTML += '없음'; + hashtagContent = '없음'; } + hashtagBox.innerHTML = `HASHTAGS
${hashtagContent}
`; } } /** * 백엔드 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 = `LOCATION`; + inh = inh + `
`; + try{ + inh = inh + `
${result.features[0].properties.formatted}
`; + }catch(err) {} + inh = inh + `
Lat: ${baseData.firstPostLat.toFixed(2)}
`; + inh = inh + `
Lon: ${baseData.firstPostLon.toFixed(2)}
`; + locationField.innerHTML = inh; + } catch (e) { + locationField.innerHTML = `LOCATION` + + `
Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`; + } + }) + .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 = `LOCATION` + + `
Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`; } }); + } else { + const locationField = document.getElementById('location_field'); + if (locationField) { + // [수정] 제목과 내용 래퍼 구조로 변경 + locationField.innerHTML = `LOCATION` + + `
Lat: ${baseData.firstPostLat.toFixed(2)}
Lon: ${baseData.firstPostLon.toFixed(2)}
`; + } } } @@ -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`); } /** @@ -543,4 +756,172 @@ function unformat(type, data, key) { default: return [odd.reverse().join(""), dividerStr, even.reverse().join("")].join(""); } -} \ No newline at end of file +} + +/** + * (신규 추가) + * 중복을 방지하며 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') + ? `${baseData.category}` + : '카테고리 설정'; + categoryBox.innerHTML = `CATEGORY
${categoryContent}
`; + } + + if (hashtagBox) { + // [수정] 편집 모드도 읽기 모드와 동일한 HTML 구조(제목 + 래퍼)로 생성 + let hashtagContent = ''; + if (baseData.tags && baseData.tags.length > 0) { + hashtagContent = baseData.tags.split(',') + .map(t => `#${t.trim()}`) + .join(' '); // 각 태그를 span으로 감싸고 공백으로 연결 + } else { + hashtagContent = '해시태그 편집'; + } + hashtagBox.innerHTML = `HASHTAGS
${hashtagContent}
`; + } +} + +/* === (신규 추가) POPUP STAGING 헬퍼 함수들 === */ + +/** 1. Staging Category 렌더링: 선택된 카테고리(임시 변수)를 팝업 UI에 표시 */ +function renderStagedCategory() { + const area = document.getElementById('selected-category-area'); + if (area) { + if (stagedCategory && stagedCategory !== 'none') { + // 선택된 아이템에 삭제(X) 버튼을 포함하여 렌더링 + area.innerHTML = `${stagedCategory} + X + `; + } else { + area.innerHTML = 'No category selected.'; + } + } +} + +/** 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 += `#${tag} + X + `; + }); + } else { + area.innerHTML = 'No tags selected.'; + } + } +} + +/** 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); + }); +} +/* ============================================= */ diff --git a/src/main/resources/templates/content/blog/editor.html b/src/main/resources/templates/content/blog/editor.html index 152e947..b6e1cd9 100644 --- a/src/main/resources/templates/content/blog/editor.html +++ b/src/main/resources/templates/content/blog/editor.html @@ -6,18 +6,18 @@ layout:decorate="~{layout/default_layout}" > + + + + + + + - - - - - - - - - + +
@@ -33,6 +33,14 @@

+ +
+
+
+
+
+
+
@@ -46,44 +54,52 @@
-
-
-
-
-
-
-
-
-
-
-

Categories

+ +
Selected:
+
+
+ +
Pre-loaded Categories:
+ +
+

Hashtags

+ +
Selected:
+
+
+ +
Suggested Tags:
+ +
diff --git a/src/main/resources/templates/content/blog/modify.html b/src/main/resources/templates/content/blog/modify.html deleted file mode 100644 index f4c06da..0000000 --- a/src/main/resources/templates/content/blog/modify.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - -
- -

권한이 없는 뎁쇼?!

-
- -
-
-
- -
- - -
-
-
-
-
-
- diff --git a/src/main/resources/templates/content/blog/posts.html b/src/main/resources/templates/content/blog/posts.html index 15c5885..afce43c 100644 --- a/src/main/resources/templates/content/blog/posts.html +++ b/src/main/resources/templates/content/blog/posts.html @@ -9,22 +9,29 @@ -
-
+
-
+
Post Thumbnail
-

+

+ + + (읽음: 0) + +

+

+

+
@@ -33,11 +40,34 @@
-
-
+ + +
- \ No newline at end of file diff --git a/src/main/resources/templates/content/blog/viewer.html b/src/main/resources/templates/content/blog/viewer.html index 2712c15..71e7e1c 100644 --- a/src/main/resources/templates/content/blog/viewer.html +++ b/src/main/resources/templates/content/blog/viewer.html @@ -1,53 +1,81 @@ - - - - - - - - + - - + + + + + + + -
-

게시물 제목이 여기에 표시됩니다

-

+

+ 게시물 제목이 여기에 표시됩니다 + + (읽음: 0) + +

+

+ + +

+ +
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
+
+ +

@@ -67,5 +95,4 @@
- \ No newline at end of file diff --git a/src/main/resources/templates/content/private/where.html b/src/main/resources/templates/content/private/where.html index 978844f..c56f8e8 100644 --- a/src/main/resources/templates/content/private/where.html +++ b/src/main/resources/templates/content/private/where.html @@ -10,17 +10,57 @@
- -
- | - | - | - | - | - + +
+ + +
+
+

+

+ 위도: + 경도: + +

+
+ + +
\ No newline at end of file diff --git a/src/main/resources/templates/fragments/includes.html b/src/main/resources/templates/fragments/includes.html index 577990c..6b4de63 100644 --- a/src/main/resources/templates/fragments/includes.html +++ b/src/main/resources/templates/fragments/includes.html @@ -17,16 +17,45 @@