..
This commit is contained in:
parent
9b29b623c2
commit
e8355b3048
@ -112,8 +112,11 @@ class SecurityConfig(
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth
|
||||
.requestMatchers(HttpMethod.GET,"/api/feed").permitAll()
|
||||
.requestMatchers("/api/stock/**").permitAll()
|
||||
.requestMatchers("/api/ranks/**").permitAll()
|
||||
.requestMatchers("/api/stats/visitors").permitAll()
|
||||
.requestMatchers(HttpMethod.GET,"/api/stock/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/images/**").permitAll()
|
||||
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
|
||||
.anyRequest().authenticated() // 나머지 API는 인증 필요
|
||||
@ -164,9 +167,11 @@ class SecurityConfig(
|
||||
.requestMatchers(
|
||||
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
|
||||
).permitAll()
|
||||
.requestMatchers(HttpMethod.GET,"/stock/**").permitAll()
|
||||
// 2. 공개 GET API 및 페이지 = permitAll
|
||||
.requestMatchers(HttpMethod.GET,
|
||||
"/api/images/**",
|
||||
"/stock/**",
|
||||
"/", "/home.bs", "/bums/where.bs",
|
||||
"/user/login.bs", "/user/join.bs",
|
||||
"/blog/viewer/**", "/blog/posts",
|
||||
|
||||
@ -23,12 +23,20 @@ class GlobalControllerAdvice(
|
||||
}
|
||||
|
||||
// [추가] 로그인한 경우, User 엔티티(테마 정보 포함)를 모델에 "user"라는 이름으로 추가
|
||||
// @ModelAttribute("user")
|
||||
// fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono<User> {
|
||||
// return if (userDetails != null) {
|
||||
// userManager.findById(userDetails.username)
|
||||
// } else {
|
||||
// Mono.empty()
|
||||
// }
|
||||
// }
|
||||
|
||||
@ModelAttribute("user")
|
||||
fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono<User> {
|
||||
return if (userDetails != null) {
|
||||
userManager.findById(userDetails.username)
|
||||
} else {
|
||||
Mono.empty()
|
||||
}
|
||||
fun currentUser(@AuthenticationPrincipal userDetails: UserDetails?): User? {
|
||||
if (userDetails == null) return null
|
||||
|
||||
// [수정] Mono 객체를 그대로 반환하지 말고 .block()을 통해 실제 객체를 반환해야 합니다.
|
||||
return userManager.findById(userDetails.username).block()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
package kr.lunaticbum.back.lun.controllers
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpSession
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kr.lunaticbum.back.lun.model.KisAuthSession
|
||||
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
||||
import kr.lunaticbum.back.lun.model.ResponceResult
|
||||
import kr.lunaticbum.back.lun.model.ResultMV
|
||||
import kr.lunaticbum.back.lun.model.UserManager
|
||||
import kr.lunaticbum.back.lun.service.KisApiService
|
||||
import kr.lunaticbum.back.lun.service.KisMarketService
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
|
||||
// src/main/kotlin/kr/lunaticbum/back/lun/controllers/StockController.kt (예시)
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/stock")
|
||||
class StockViewController {
|
||||
@GetMapping("/config")
|
||||
fun configPage(): ResultMV = ResultMV("content/stock/config").apply { setTitle("Stock API 설정") }
|
||||
|
||||
|
||||
@GetMapping("/market")
|
||||
fun margketPage(): ResultMV = ResultMV("content/stock/market").apply { setTitle("Stock API 설정") }
|
||||
|
||||
}
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/stock")
|
||||
class StockApiController(
|
||||
private val webClient: WebClient, // WebClientConfig.kt 활용
|
||||
private val userManager: UserManager,
|
||||
private val kisApiService: KisApiService,
|
||||
private val kisMarketService: KisMarketService,
|
||||
private val logService: LogService
|
||||
) {
|
||||
@PostMapping("/config")
|
||||
suspend fun saveConfig(
|
||||
@RequestBody config: KisConfigRequest,
|
||||
session: HttpSession // 세션 주입
|
||||
): ResponseEntity<ResponceResult> {
|
||||
return try {
|
||||
// 1. KIS 서버로 토큰 발급 테스트 (유효성 검사)
|
||||
val token = kisApiService.verifyAndGetToken(config).awaitSingle()
|
||||
|
||||
// 2. DB 저장 없이 세션에만 저장 (브라우저 종료 시 삭제)
|
||||
val authInfo = KisAuthSession(
|
||||
appKey = config.appKey,
|
||||
appSecret = config.appSecret,
|
||||
accountNo = config.accountNo,
|
||||
accessToken = token
|
||||
)
|
||||
session.setAttribute("KIS_AUTH", authInfo)
|
||||
|
||||
ResponseEntity.ok(ResponceResult().apply { resultCode = 0; resultMsg = "연결 성공" })
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.ok(ResponceResult().apply { resultCode = 7001; resultMsg = "연결 실패: ${e.message}" })
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/market")
|
||||
suspend fun getMarketIndicators(): ResponseEntity<Map<String, Any>> {
|
||||
// 공용 혹은 세션에 저장된 키를 사용 (세션에 없다면 기본 시스템 키 사용 로직 필요)
|
||||
val token = "..." // 발급받은 토큰
|
||||
val appKey = "..."
|
||||
val appSecret = "..."
|
||||
|
||||
val kospi = kisMarketService.getDomesticIndex("0001", token, appKey, appSecret).awaitSingle()
|
||||
val kosdaq = kisMarketService.getDomesticIndex("1001", token, appKey, appSecret).awaitSingle()
|
||||
|
||||
// 응답 데이터 정리
|
||||
val result = mapOf(
|
||||
"kospi" to (kospi["output"] as Map<*, *>)["bstp_nmix_prpr"], // 현재가
|
||||
"kospi_change" to (kospi["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"], // 등락률
|
||||
"kosdaq" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prpr"],
|
||||
"kosdaq_change" to (kosdaq["output"] as Map<*, *>)["bstp_nmix_prdy_ctrt"]
|
||||
)
|
||||
|
||||
return ResponseEntity.ok(result) as ResponseEntity<Map<String, Any>>
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,10 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kr.lunaticbum.back.lun.model.Message
|
||||
import kr.lunaticbum.back.lun.service.CommentService
|
||||
import kr.lunaticbum.back.lun.service.PostHistoryManager
|
||||
import kr.lunaticbum.back.lun.service.PostManager
|
||||
import kr.lunaticbum.back.lun.service.WebBookmarkService
|
||||
import org.springframework.stereotype.Controller
|
||||
|
||||
@Controller
|
||||
|
||||
@ -7,13 +7,13 @@ import kr.lunaticbum.back.lun.model.BookmarkDataDto
|
||||
import kr.lunaticbum.back.lun.model.BookmarkImage
|
||||
import kr.lunaticbum.back.lun.model.BookmarkType
|
||||
import kr.lunaticbum.back.lun.model.BookmarkUpdateRequest
|
||||
import kr.lunaticbum.back.lun.model.CommentService
|
||||
import kr.lunaticbum.back.lun.model.ImageMetaService
|
||||
import kr.lunaticbum.back.lun.model.ImageUrlRequest
|
||||
import kr.lunaticbum.back.lun.model.ImageVisibilityRequest
|
||||
import kr.lunaticbum.back.lun.model.Visibility
|
||||
import kr.lunaticbum.back.lun.model.WebBookmark
|
||||
import kr.lunaticbum.back.lun.model.WebBookmarkService
|
||||
import kr.lunaticbum.back.lun.service.CommentService
|
||||
import kr.lunaticbum.back.lun.service.WebBookmarkService
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.domain.Page
|
||||
|
||||
@ -5,6 +5,9 @@ import kotlinx.coroutines.reactive.awaitFirstOrNull
|
||||
import kotlinx.coroutines.reactor.awaitSingle
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kr.lunaticbum.back.lun.model.*
|
||||
import kr.lunaticbum.back.lun.service.CommentService
|
||||
import kr.lunaticbum.back.lun.service.PostHistoryManager
|
||||
import kr.lunaticbum.back.lun.service.PostManager
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import kr.lunaticbum.back.lun.utils.PayloadDecoder
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
@ -5,11 +5,11 @@ import kotlinx.coroutines.reactive.awaitSingle
|
||||
import kr.lunaticbum.back.lun.model.BookmarkImage
|
||||
import kr.lunaticbum.back.lun.model.Comment
|
||||
import kr.lunaticbum.back.lun.model.CommentResponse
|
||||
import kr.lunaticbum.back.lun.model.CommentService
|
||||
import kr.lunaticbum.back.lun.model.ImageMetaService
|
||||
import kr.lunaticbum.back.lun.model.ResultMV
|
||||
import kr.lunaticbum.back.lun.model.VoteResponse
|
||||
import kr.lunaticbum.back.lun.model.WebBookmarkService
|
||||
import kr.lunaticbum.back.lun.service.CommentService
|
||||
import kr.lunaticbum.back.lun.service.WebBookmarkService
|
||||
import kr.lunaticbum.back.lun.utils.PayloadDecoder
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
|
||||
@ -8,9 +8,9 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
|
||||
import kr.lunaticbum.back.lun.model.LocationLog
|
||||
import kr.lunaticbum.back.lun.model.Post
|
||||
import kr.lunaticbum.back.lun.model.PostManager
|
||||
import kr.lunaticbum.back.lun.model.ResponceResult
|
||||
import kr.lunaticbum.back.lun.model.ResultMV
|
||||
import kr.lunaticbum.back.lun.service.PostManager
|
||||
import kr.lunaticbum.back.lun.services.LocationLogService
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import kr.lunaticbum.back.lun.utils.plainText
|
||||
|
||||
@ -8,6 +8,9 @@ import kotlinx.coroutines.reactive.awaitSingle
|
||||
import kotlinx.coroutines.reactive.awaitSingleOrNull
|
||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||
import kr.lunaticbum.back.lun.model.*
|
||||
import kr.lunaticbum.back.lun.model.dto.FeedResponse
|
||||
import kr.lunaticbum.back.lun.service.FeedService
|
||||
import kr.lunaticbum.back.lun.service.PostManager
|
||||
import kr.lunaticbum.back.lun.services.ImageService
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import net.coobird.thumbnailator.Thumbnails
|
||||
@ -32,6 +35,7 @@ class PostViewController(
|
||||
private val visitorLogService: VisitorLogService,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val logService: LogService,
|
||||
private val feedService: FeedService,
|
||||
private val imageService: ImageService // [주입 추가]
|
||||
) {
|
||||
@Value("\${image.upload.path}")
|
||||
@ -121,7 +125,11 @@ class PostViewController(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/api/feed")
|
||||
@ResponseBody
|
||||
suspend fun getFeedMore(@RequestParam cursor: Long): FeedResponse {
|
||||
return feedService.getGlobalFeed(cursor, 10).awaitSingle()
|
||||
}
|
||||
// --- View Endpoints ---
|
||||
|
||||
@GetMapping("/", "/home.bs")
|
||||
@ -148,7 +156,10 @@ class PostViewController(
|
||||
vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8")
|
||||
vm.modelMap["gibberishId"] = randomGibberish.id
|
||||
}
|
||||
val feedData = feedService.getGlobalFeed(null, 10).awaitSingle()
|
||||
|
||||
vm.modelMap["feedItems"] = feedData.items
|
||||
vm.modelMap["nextCursor"] = feedData.nextCursor // HTML에 hidden으로 숨겨둠
|
||||
val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
|
||||
vm.modelMap["Posts"] = postsList.map { processPostForView(it) }
|
||||
vm.modelMap["path"] = "/blog/viewer/"
|
||||
|
||||
18
src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt
Normal file
18
src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt
Normal file
@ -0,0 +1,18 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
// src/main/kotlin/kr/lunaticbum/back/lun/model/KisDtos.kt
|
||||
|
||||
// 클라이언트로부터 설정을 받을 때 사용
|
||||
data class KisConfigRequest(
|
||||
val appKey: String,
|
||||
val appSecret: String,
|
||||
val accountNo: String
|
||||
)
|
||||
|
||||
// 세션에 저장하거나 API 응답에 사용할 토큰 정보
|
||||
data class KisAuthSession(
|
||||
val appKey: String,
|
||||
val appSecret: String,
|
||||
val accountNo: String,
|
||||
var accessToken: String? = null,
|
||||
var tokenExpiredAt: Long = 0L
|
||||
)
|
||||
@ -1,5 +1,7 @@
|
||||
package kr.lunaticbum.back.lun.model
|
||||
|
||||
import kr.lunaticbum.back.lun.repository.PostHistoryRepository
|
||||
import kr.lunaticbum.back.lun.repository.PostRepository
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
|
||||
import org.springframework.data.mongodb.core.aggregation.Aggregation
|
||||
|
||||
@ -2,6 +2,7 @@ package kr.lunaticbum.back.lun.model
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
|
||||
import kr.lunaticbum.back.lun.repository.PostHistoryRepository
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import lombok.AllArgsConstructor
|
||||
import lombok.Data
|
||||
@ -75,25 +76,7 @@ data class PostHistory(
|
||||
var archivedAt: Long = System.currentTimeMillis() // 보관된 시간
|
||||
)
|
||||
|
||||
// 2. PostHistory를 위한 Repository 인터페이스
|
||||
@Repository
|
||||
interface PostHistoryRepository : ReactiveMongoRepository<PostHistory, String> {
|
||||
// [추가] postId로 모든 히스토리를 최신순으로 조회
|
||||
fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux<PostHistory>
|
||||
}
|
||||
|
||||
// 3. PostHistory를 위한 Service 클래스
|
||||
@Service
|
||||
class PostHistoryManager(private val repository: PostHistoryRepository) {
|
||||
fun save(postHistory: PostHistory): Mono<PostHistory> {
|
||||
return repository.save(postHistory)
|
||||
}
|
||||
|
||||
// [추가] postId로 모든 히스토리를 조회하는 함수
|
||||
fun findByPostId(postId: String): Flux<PostHistory> {
|
||||
return repository.findByPostIdOrderByArchivedAtDesc(postId)
|
||||
}
|
||||
}
|
||||
|
||||
enum class PostType {
|
||||
STANDARD, // 일반 블로그 글
|
||||
@ -178,487 +161,7 @@ class CommentsResult {
|
||||
data class AggregationCount(val totalCount: Long)
|
||||
|
||||
|
||||
@Repository
|
||||
interface CommentRepository : ReactiveMongoRepository<Comment, String> {
|
||||
fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux<Comment> // 최상위 댓글
|
||||
fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux<Comment>
|
||||
fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux<Comment> // [신규 추가]
|
||||
}
|
||||
|
||||
@Service
|
||||
class CommentService(private val commentRepository: CommentRepository) {
|
||||
|
||||
/**
|
||||
* [수정] 각 댓글의 content를 URL 디코딩하는 로직을 추가합니다.
|
||||
*/
|
||||
private fun decodeCommentContent(comment: Comment): Comment {
|
||||
comment.content = comment.content?.let { URLDecoder.decode(it, "UTF-8") }
|
||||
return comment
|
||||
}
|
||||
|
||||
fun getRepliesForComment(parentId: String): Flux<Comment> {
|
||||
return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
fun addComment(comment: Comment): Mono<Comment> {
|
||||
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리
|
||||
return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
|
||||
fun getCommentsForPost(postId: String): Flux<Comment> {
|
||||
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
|
||||
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface PostRepository : ReactiveMongoRepository<Post, String> {
|
||||
fun findAllByModifyTime(time : Long? = 0): Flux<Post>
|
||||
// @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }")
|
||||
fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
|
||||
fun countByOrderByModifyTimeDesc(): Mono<Long>
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
|
||||
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
|
||||
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
|
||||
|
||||
// [단순화] 공개된 글 목록 조회 (페이지네이션)
|
||||
fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
|
||||
|
||||
// [단순화] 공개된 글 개수 카운트
|
||||
fun countByPostingIsTrue(): Mono<Long>
|
||||
|
||||
// [단순화] 인기글 5개 조회 (공개된 글 대상)
|
||||
fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux<Post>
|
||||
|
||||
// [단순화] 최신글 5개 조회 (공개된 글 대상)
|
||||
fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux<Post>
|
||||
|
||||
// [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글)
|
||||
fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post>
|
||||
fun countByPostingIsTrueOrWriter(writer: String): Mono<Long>
|
||||
|
||||
|
||||
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
|
||||
@Aggregation(pipeline = [
|
||||
// 1. 모든 글을 최신순으로 정렬
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
// 3. 원래 Post 형태로 복원
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
|
||||
"{ \$match: { posting: true, isBlocked: false } }",
|
||||
// 5. 최종 목록을 조회수(readCount) 순으로 정렬
|
||||
"{ \$sort: { readCount: -1 } }",
|
||||
// 6. 상위 5개만 선택
|
||||
"{ \$limit: 5 }"
|
||||
])
|
||||
fun findTop5UniquePublishedByReadCountDesc(): Flux<Post>
|
||||
|
||||
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
|
||||
@Aggregation(pipeline = [
|
||||
// 1. 모든 글을 최신순으로 정렬
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
// 3. 원래 Post 형태로 복원
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
|
||||
"{ \$match: { posting: true, isBlocked: false } }",
|
||||
// 5. 최종 목록을 다시 최신순으로 정렬
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
// 6. 상위 5개만 선택
|
||||
"{ \$limit: 5 }"
|
||||
])
|
||||
fun findTop5UniquePublishedByModifyTimeDesc(): Flux<Post>
|
||||
|
||||
|
||||
/**
|
||||
* 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다.
|
||||
* [버그 수정] 2차 정렬 경로를 "post.post.modifyTime" -> "post.modifyTime" 으로 변경
|
||||
*/
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$sort: { \"post.modifyTime\": -1 } }", // [수정됨]
|
||||
"{ \$replaceRoot: { newRoot: \"\$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> // 헬퍼 클래스로 매핑
|
||||
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findLatestUniqueForWriterPaginated(username: String, pageable: Pageable): Flux<Post>
|
||||
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }",
|
||||
"{ \$count: \"totalCount\" }"
|
||||
])
|
||||
fun countLatestUniqueForWriter(username: String): Mono<AggregationCount>
|
||||
|
||||
|
||||
/**
|
||||
* [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
||||
*/
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux<Post>
|
||||
|
||||
/**
|
||||
* '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다.
|
||||
* [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
||||
*/
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$count: \"totalCount\" }"
|
||||
])
|
||||
fun countLatestUniquePublished(): Mono<AggregationCount>
|
||||
|
||||
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
|
||||
|
||||
// [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링
|
||||
"{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링
|
||||
])
|
||||
fun findRandomPublishedPostByType(postType: String): Mono<Post>
|
||||
|
||||
// --- [신규 추가] 필터링을 위한 Repository 메소드 ---
|
||||
fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux<Post>
|
||||
fun countByCategoryAndPostingIsTrue(category: String): Mono<Long>
|
||||
fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux<Post>
|
||||
fun countByTagsRegexAndPostingIsTrue(tag: String): Mono<Long>
|
||||
// [추가] MongoDB Aggregation을 사용해 고유 태그 목록을 효율적으로 조회
|
||||
@Aggregation(pipeline = [
|
||||
// 1. tags 필드가 null이면 빈 문자열로 만든 후 "," 기준으로 잘라 배열로 변환
|
||||
"{ \$project: { tags: { \$split: [ { \$ifNull: [ \"\$tags\", \"\" ] }, \",\" ] } } }",
|
||||
// 2. 생성된 tags 배열을 개별 문서로 분리 (예: ["a","b"] -> {tags:"a"}, {tags:"b"})
|
||||
"{ \$unwind: \"\$tags\" }",
|
||||
// 3. 각 태그의 앞뒤 공백 제거
|
||||
"{ \$project: { tag: { \$trim: { input: \"\$tags\" } } } }",
|
||||
// 4. 공백이 제거된 태그로 그룹화하여 고유한 값만 추출
|
||||
"{ \$group: { _id: \"\$tag\" } }",
|
||||
// 5. 그룹화 결과 중 빈 값("")은 제외
|
||||
"{ \$match: { _id: { \$ne: \"\" } } }"
|
||||
])
|
||||
fun findDistinctTags(): Flux<org.bson.Document> // 반환 타입을 Document로 변경
|
||||
|
||||
// [신규 추가] GIBBERISH 제외하고 조회
|
||||
fun findByPostTypeNotOrderByModifyTimeDesc(postType: String, pageable: Pageable): Flux<Post>
|
||||
fun countByPostTypeNot(postType: String): Mono<Long>
|
||||
}
|
||||
|
||||
|
||||
@Service
|
||||
class PostManager(
|
||||
private val postRepository: PostRepository,
|
||||
private val reactiveMongoTemplate: ReactiveMongoTemplate
|
||||
) {
|
||||
@Autowired
|
||||
private lateinit var logService: LogService
|
||||
|
||||
@Autowired
|
||||
private lateinit var bCryptPasswordEncoder: PasswordEncoder
|
||||
|
||||
fun deletePost(postId: String): Mono<Void> {
|
||||
return postRepository.deleteById(postId)
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 목록 조회 (Aggregation 사용)
|
||||
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> {
|
||||
return postRepository.findLatestUniquePublishedPaginated(pageable)
|
||||
.collectList()
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 글 개수 (Aggregation 사용)
|
||||
fun countLatestUnique(): Mono<Long> {
|
||||
return postRepository.countLatestUniquePublished()
|
||||
.map { it.totalCount }
|
||||
.switchIfEmpty(Mono.just(0L))
|
||||
}
|
||||
|
||||
// [수정] '글쓰기' 권한 사용자용 목록 조회 (Aggregation 사용)
|
||||
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
|
||||
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
|
||||
.collectList()
|
||||
}
|
||||
|
||||
// [수정] '글쓰기' 권한 사용자용 글 개수 (Aggregation 사용)
|
||||
fun countLatestUniqueForWriter(username: String): Mono<Long> {
|
||||
return postRepository.countLatestUniqueForWriter(username)
|
||||
.map { it.totalCount }
|
||||
.switchIfEmpty(Mono.just(0L))
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 인기글
|
||||
fun getTop5UniquePublishedByViews(): Flux<Post> {
|
||||
return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 최신글
|
||||
fun getRecent5UniquePublished(): Flux<Post> {
|
||||
return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map {
|
||||
p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
// --- [신규 추가] 카테고리/태그 관련 서비스 메소드 ---
|
||||
fun findAllDistinctCategories(): Flux<String> {
|
||||
// 'category' 필드가 null이 아니고 비어있지 않은 문서들을 대상으로 distinct 연산 수행
|
||||
val query = Query.query(Criteria.where("category").ne(null).ne(""))
|
||||
return reactiveMongoTemplate.findDistinct(query, "category", "Post", String::class.java)
|
||||
}
|
||||
|
||||
fun findAllDistinctTags(): Flux<String> {
|
||||
return postRepository.findDistinctTags()
|
||||
.mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출
|
||||
.filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링
|
||||
}
|
||||
// --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 ---
|
||||
fun findPostsByCategory(category: String, pageable: Pageable): Mono<List<Post>> {
|
||||
return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList()
|
||||
}
|
||||
fun countPostsByCategory(category: String): Mono<Long> {
|
||||
return postRepository.countByCategoryAndPostingIsTrue(category)
|
||||
}
|
||||
|
||||
fun findPostsByTag(tag: String, pageable: Pageable): Mono<List<Post>> {
|
||||
// [수정] 한글 및 다국어를 지원하는 정규식으로 변경
|
||||
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
|
||||
return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList()
|
||||
}
|
||||
|
||||
fun countPostsByTag(tag: String): Mono<Long> {
|
||||
// [수정] 위와 동일하게 정규식 변경
|
||||
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
|
||||
return postRepository.countByTagsRegexAndPostingIsTrue(regex)
|
||||
}
|
||||
|
||||
// [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드
|
||||
fun findRandomGibberish(): Mono<Post> {
|
||||
return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name)
|
||||
}
|
||||
|
||||
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
|
||||
fun findLatestAboutPost(): Mono<Post> {
|
||||
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
|
||||
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
|
||||
.next() // Flux에서 첫 번째 아이템(Mono)을 반환
|
||||
}
|
||||
|
||||
// [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드
|
||||
fun findAboutPostHistory(): Flux<Post> {
|
||||
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
|
||||
}
|
||||
|
||||
// [신규] 게시물 차단
|
||||
fun blockPost(postId: String): Mono<Post> {
|
||||
return postRepository.findById(postId).flatMap { post ->
|
||||
post.isBlocked = true
|
||||
postRepository.save(post)
|
||||
}
|
||||
}
|
||||
|
||||
// [신규] 게시물 차단 해제
|
||||
fun unblockPost(postId: String): Mono<Post> {
|
||||
return postRepository.findById(postId).flatMap { post ->
|
||||
post.isBlocked = false
|
||||
postRepository.save(post)
|
||||
}
|
||||
}
|
||||
|
||||
fun findById(id: String): Mono<Post> {
|
||||
return postRepository.findById(id)
|
||||
}
|
||||
|
||||
|
||||
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
|
||||
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
|
||||
.map { post ->
|
||||
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
|
||||
if (post.title.isNullOrBlank()) {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
|
||||
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
|
||||
}
|
||||
post
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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")))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 인증된 사용자를 위한 메서드 (모든 버전 조회, GIBBERISH 제외)
|
||||
*/
|
||||
fun findAllVersionsPaginated(pageable :Pageable) : Mono<List<Post>> {
|
||||
return postRepository.findByPostTypeNotOrderByModifyTimeDesc(PostType.GIBBERISH.name, pageable)
|
||||
.map { post ->
|
||||
// 1. 제목을 UTF-8로 디코딩합니다.
|
||||
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
|
||||
|
||||
// 2. 제목이 비어있으면 작성 시간을 기반으로 기본 제목을 설정합니다.
|
||||
if (post.title.isNullOrBlank()) {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
|
||||
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
|
||||
}
|
||||
post // 수정된 post 객체를 반환
|
||||
}
|
||||
.collectList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증된 사용자가 보는 글의 총 개수 (GIBBERISH 제외)
|
||||
*/
|
||||
fun countAllVersions(): Mono<Long> {
|
||||
return postRepository.countByPostTypeNot(PostType.GIBBERISH.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 좋아요 카운트를 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)
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
println(p.title)
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecent10Posts(): Flux<Post> {
|
||||
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title)
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
println(p.title)
|
||||
p
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다.
|
||||
*/
|
||||
fun find8() : Mono<List<Post>> {
|
||||
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
|
||||
return this.findLatestUniquePaginated(pageRequest)
|
||||
}
|
||||
|
||||
fun save(post: Post): Mono<Post> {
|
||||
println("saved user before ${post}")
|
||||
// user.hashPassword(bCryptPasswordEncoder)
|
||||
return postRepository.save(post)
|
||||
.doOnSuccess { savedPost ->
|
||||
// 저장이 완료되었을 때 실행될 로직 (로그 출력 등)
|
||||
println("saved post success: ${savedPost.id}")
|
||||
}
|
||||
}
|
||||
|
||||
// [기존] 로그인 사용자용 인기글 (메서드 이름 명확화: getTop5 -> getTop5AllVersions)
|
||||
fun getTop5AllVersionsByViews(): Flux<Post> {
|
||||
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
// [기존] 로그인 사용자용 최신글 (메서드 이름 명확화)
|
||||
fun getRecent5AllVersions(): Flux<Post> {
|
||||
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@ -839,118 +342,3 @@ data class WebBookmark(
|
||||
}
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
|
||||
fun findByUserIdOrderBySavedAtDesc(userId: String): Flux<WebBookmark>
|
||||
fun findByVisibilityInOrderBySavedAtDesc(visibilities: List<String>, pageable: Pageable): Flux<WebBookmark>
|
||||
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
|
||||
|
||||
fun findByMetadataStatus(status: String): Flux<WebBookmark>
|
||||
|
||||
// [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동)
|
||||
@Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }")
|
||||
fun findDistinctTags(): Flux<Map<String, Any>>
|
||||
|
||||
@Aggregation("{ \$group: { _id: '\$category' } }")
|
||||
fun findDistinctCategories(): Flux<Map<String, Any>>
|
||||
}
|
||||
|
||||
@Service
|
||||
class WebBookmarkService(private val repository: WebBookmarkRepository,
|
||||
private val reactiveMongoTemplate: ReactiveMongoTemplate
|
||||
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
|
||||
) {
|
||||
// [이 메소드를 추가하세요]
|
||||
fun findById(id: String): Mono<WebBookmark> {
|
||||
return repository.findById(id)
|
||||
}
|
||||
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
|
||||
fun findAllDistinctCategories(): Flux<String> {
|
||||
return repository.findDistinctCategories()
|
||||
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
|
||||
fun findAllDistinctTags(): Flux<String> {
|
||||
return repository.findDistinctTags()
|
||||
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
|
||||
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
|
||||
return repository.findByUserIdOrderBySavedAtDesc(userId)
|
||||
}
|
||||
|
||||
fun saveBookmark(bookmark: WebBookmark): Mono<WebBookmark> {
|
||||
// 여기에 중복 저장 방지 로직 등을 추가할 수 있음
|
||||
return repository.save(bookmark)
|
||||
}
|
||||
|
||||
// 필요하다면 삭제, 수정 기능 추가
|
||||
fun deleteBookmark(id: String): Mono<Void> {
|
||||
return repository.deleteById(id)
|
||||
}
|
||||
|
||||
// [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
|
||||
fun getVisibleBookmarks(
|
||||
userDetails: UserDetails?,
|
||||
pageable: Pageable,
|
||||
category: String?, // 카테고리 파라미터 추가
|
||||
tag: String? // 태그 파라미터 추가
|
||||
): Mono<Page<WebBookmark>> {
|
||||
val visibleScopes = when {
|
||||
userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name)
|
||||
else -> listOf(Visibility.PUBLIC.name)
|
||||
}
|
||||
|
||||
// 동적 쿼리 생성 시작
|
||||
val query = Query(Criteria.where("visibility").`in`(visibleScopes))
|
||||
.with(Sort.by(Sort.Direction.DESC, "savedAt")) // <-- 이 줄을 추가하세요.
|
||||
.with(pageable)
|
||||
|
||||
// 카테고리 조건 추가
|
||||
if (!category.isNullOrBlank()) {
|
||||
query.addCriteria(Criteria.where("category").`is`(category))
|
||||
}
|
||||
|
||||
// 태그 조건 추가 (tags 배열에 해당 태그가 포함되어 있는지 확인)
|
||||
if (!tag.isNullOrBlank()) {
|
||||
query.addCriteria(Criteria.where("tags").`in`(tag))
|
||||
}
|
||||
|
||||
// 데이터 조회 및 카운트
|
||||
val bookmarks = reactiveMongoTemplate.find(query, WebBookmark::class.java).collectList()
|
||||
val totalCount = reactiveMongoTemplate.count(Query.of(query).limit(-1).skip(-1), WebBookmark::class.java)
|
||||
|
||||
return Mono.zip(bookmarks, totalCount).map { tuple ->
|
||||
PageImpl(tuple.t1, pageable, tuple.t2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크의 좋아요 카운트를 1 증가시킵니다.
|
||||
* @param bookmarkId 대상 북마크의 ID
|
||||
* @return 업데이트된 WebBookmark 객체
|
||||
*/
|
||||
fun incrementVote(bookmarkId: String): Mono<WebBookmark> {
|
||||
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
|
||||
val update = Update().inc("voteCount", 1)
|
||||
val options = FindAndModifyOptions.options().returnNew(true)
|
||||
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크의 싫어요 카운트를 1 증가시킵니다.
|
||||
* @param bookmarkId 대상 북마크의 ID
|
||||
* @return 업데이트된 WebBookmark 객체
|
||||
*/
|
||||
fun incrementUnlike(bookmarkId: String): Mono<WebBookmark> {
|
||||
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
|
||||
val update = Update().inc("unlikeCount", 1)
|
||||
val options = FindAndModifyOptions.options().returnNew(true)
|
||||
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
|
||||
}
|
||||
|
||||
}
|
||||
36
src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt
Normal file
36
src/main/kotlin/kr/lunaticbum/back/lun/model/dto/FeedItem.kt
Normal file
@ -0,0 +1,36 @@
|
||||
package kr.lunaticbum.back.lun.model.dto
|
||||
|
||||
enum class ContentType { POST, GIBBERISH, BOOKMARK }
|
||||
|
||||
//interface FeedItem {
|
||||
// val id: String?
|
||||
// val type: ContentType
|
||||
// val title: String?
|
||||
// val content: String? // 요약 내용 또는 본문
|
||||
// val thumbnail: String? // 대표 이미지
|
||||
// val createdAt: Long // 정렬 기준 시간
|
||||
// val writer: String?
|
||||
// val url: String // 클릭 시 이동할 경로
|
||||
//}
|
||||
|
||||
// 피드 아이템 통합 DTO
|
||||
data class FeedItemDto(
|
||||
val id: String?, // 원본 게시물/북마크 ID
|
||||
val type: ContentType, // 콘텐츠 타입 (POST, GIBBERISH, BOOKMARK)
|
||||
val title: String?, // 제목 (Gibberish는 없을 수 있음)
|
||||
val content: String?, // 내용 요약 또는 전체 (HTML 제거된 텍스트 권장)
|
||||
val thumbnail: String?, // 썸네일 이미지 URL
|
||||
val createdAt: Long, // 작성일/저장일 (정렬 기준)
|
||||
val writer: String?, // 작성자
|
||||
val url: String, // 클릭 시 이동할 주소 (내부 글보기 또는 외부 링크)
|
||||
|
||||
// 필요하다면 추가할 필드들
|
||||
val voteCount: Long = 0, // 좋아요 수
|
||||
val commentCount: Int = 0, // 댓글 수 (나중에 추가 가능)
|
||||
val tags: List<String>? = null // 태그 목록
|
||||
)
|
||||
|
||||
data class FeedResponse(
|
||||
val items: List<FeedItemDto>,
|
||||
val nextCursor: Long? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
|
||||
)
|
||||
@ -0,0 +1,15 @@
|
||||
package kr.lunaticbum.back.lun.repository
|
||||
|
||||
import kr.lunaticbum.back.lun.model.Comment
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import reactor.core.publisher.Flux
|
||||
|
||||
@Repository
|
||||
interface CommentRepository : ReactiveMongoRepository<Comment, String> {
|
||||
fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux<Comment> // 최상위 댓글
|
||||
fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux<Comment>
|
||||
fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux<Comment> // [신규 추가]
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
package kr.lunaticbum.back.lun.repository
|
||||
|
||||
import kr.lunaticbum.back.lun.model.PostHistory
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import reactor.core.publisher.Flux
|
||||
|
||||
|
||||
// 2. PostHistory를 위한 Repository 인터페이스
|
||||
@Repository
|
||||
interface PostHistoryRepository : ReactiveMongoRepository<PostHistory, String> {
|
||||
// [추가] postId로 모든 히스토리를 최신순으로 조회
|
||||
fun findByPostIdOrderByArchivedAtDesc(postId: String): Flux<PostHistory>
|
||||
}
|
||||
@ -0,0 +1,197 @@
|
||||
package kr.lunaticbum.back.lun.repository
|
||||
|
||||
import kr.lunaticbum.back.lun.model.AggregationCount
|
||||
import kr.lunaticbum.back.lun.model.Post
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.mongodb.repository.Aggregation
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
@Repository
|
||||
interface PostRepository : ReactiveMongoRepository<Post, String> {
|
||||
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
// [핵심] GIBBERISH 포함, 공개글, 차단안됨, 그리고 입력받은 시간(?0)보다 과거의 글만 조회
|
||||
"{ \$match: { posting: true, isBlocked: false, modifyTime: { \$lt: ?0 } } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findFeedPostsBefore(timestamp: Long, pageable: Pageable): Flux<Post>
|
||||
|
||||
|
||||
fun findAllByModifyTime(time : Long? = 0): Flux<Post>
|
||||
// @org.springframework.data.mongodb.repository.Query("{ '\$and': [ { 'posting': true }, { '\$expr': { '\$gte': [ { '\$strLenCP': '\$id' }, 4 ] } } ] }")
|
||||
fun findAllByOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
|
||||
fun countByOrderByModifyTimeDesc(): Mono<Long>
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
|
||||
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
|
||||
fun findByPostTypeOrderByModifyTimeDesc(postType: String): Flux<Post>
|
||||
|
||||
// [단순화] 공개된 글 목록 조회 (페이지네이션)
|
||||
fun findByPostingIsTrueOrderByModifyTimeDesc(pageable: Pageable): Flux<Post>
|
||||
|
||||
// [단순화] 공개된 글 개수 카운트
|
||||
fun countByPostingIsTrue(): Mono<Long>
|
||||
|
||||
// [단순화] 인기글 5개 조회 (공개된 글 대상)
|
||||
fun findTop5ByPostingIsTrueOrderByReadCountDesc(): Flux<Post>
|
||||
|
||||
// [단순화] 최신글 5개 조회 (공개된 글 대상)
|
||||
fun findTop5ByPostingIsTrueOrderByModifyTimeDesc(): Flux<Post>
|
||||
|
||||
// [단순화] '글쓰기' 권한 사용자를 위한 조회 (공개된 글 + 내 비공개 글)
|
||||
fun findByPostingIsTrueOrWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post>
|
||||
fun countByPostingIsTrueOrWriter(writer: String): Mono<Long>
|
||||
|
||||
|
||||
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
|
||||
@Aggregation(pipeline = [
|
||||
// 1. 모든 글을 최신순으로 정렬
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
// 3. 원래 Post 형태로 복원
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
|
||||
"{ \$match: { posting: true, isBlocked: false } }",
|
||||
// 5. 최종 목록을 조회수(readCount) 순으로 정렬
|
||||
"{ \$sort: { readCount: -1 } }",
|
||||
// 6. 상위 5개만 선택
|
||||
"{ \$limit: 5 }"
|
||||
])
|
||||
fun findTop5UniquePublishedByReadCountDesc(): Flux<Post>
|
||||
|
||||
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
|
||||
@Aggregation(pipeline = [
|
||||
// 1. 모든 글을 최신순으로 정렬
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
// 2. 각 글의 '진짜 최신 버전'을 하나씩만 추출
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
// 3. 원래 Post 형태로 복원
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
// 4. 최신 버전들 중 '공개' 상태이고 '차단되지 않은' 글만 필터링
|
||||
"{ \$match: { posting: true, isBlocked: false } }",
|
||||
// 5. 최종 목록을 다시 최신순으로 정렬
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
// 6. 상위 5개만 선택
|
||||
"{ \$limit: 5 }"
|
||||
])
|
||||
fun findTop5UniquePublishedByModifyTimeDesc(): Flux<Post>
|
||||
|
||||
|
||||
/**
|
||||
* 익명 사용자를 위한 '고유 최신 글' 목록을 페이지네이션으로 조회합니다.
|
||||
* [버그 수정] 2차 정렬 경로를 "post.post.modifyTime" -> "post.modifyTime" 으로 변경
|
||||
*/
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$sort: { \"post.modifyTime\": -1 } }", // [수정됨]
|
||||
"{ \$replaceRoot: { newRoot: \"\$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> // 헬퍼 클래스로 매핑
|
||||
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findLatestUniqueForWriterPaginated(username: String, pageable: Pageable): Flux<Post>
|
||||
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { \$and: [ { \$or: [ { writer: ?0 }, { posting: true } ] }, { 'postType': { \$ne: 'GIBBERISH' } } ] } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }",
|
||||
"{ \$count: \"totalCount\" }"
|
||||
])
|
||||
fun countLatestUniqueForWriter(username: String): Mono<AggregationCount>
|
||||
|
||||
|
||||
/**
|
||||
* [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
||||
*/
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$sort: { \"modifyTime\": -1 } }"
|
||||
])
|
||||
fun findLatestUniquePublishedPaginated(pageable: Pageable): Flux<Post>
|
||||
|
||||
/**
|
||||
* '고유 최신 글' 중 공개된 글의 총 개수를 카운트합니다.
|
||||
* [수정] GIBBERISH 타입을 제외하고, posting이 true인 문서만 필터링하는 $match 단계를 추가합니다.
|
||||
*/
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$sort: { modifyTime: -1 } }",
|
||||
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
|
||||
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
|
||||
"{ \$match: { posting: true, postType: { \$ne: 'GIBBERISH' } } }",
|
||||
"{ \$count: \"totalCount\" }"
|
||||
])
|
||||
fun countLatestUniquePublished(): Mono<AggregationCount>
|
||||
|
||||
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
|
||||
|
||||
// [신규 추가] 특정 타입의 포스트 중 공개된 것을 무작위로 1개 조회
|
||||
@Aggregation(pipeline = [
|
||||
"{ \$match: { postType: ?0, posting: true, isBlocked: false } }", // 타입, 공개, 차단안됨 필터링
|
||||
"{ \$sample: { size: 1 } }" // 무작위로 1개 샘플링
|
||||
])
|
||||
fun findRandomPublishedPostByType(postType: String): Mono<Post>
|
||||
|
||||
// --- [신규 추가] 필터링을 위한 Repository 메소드 ---
|
||||
fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux<Post>
|
||||
fun countByCategoryAndPostingIsTrue(category: String): Mono<Long>
|
||||
fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux<Post>
|
||||
fun countByTagsRegexAndPostingIsTrue(tag: String): Mono<Long>
|
||||
// [추가] MongoDB Aggregation을 사용해 고유 태그 목록을 효율적으로 조회
|
||||
@Aggregation(pipeline = [
|
||||
// 1. tags 필드가 null이면 빈 문자열로 만든 후 "," 기준으로 잘라 배열로 변환
|
||||
"{ \$project: { tags: { \$split: [ { \$ifNull: [ \"\$tags\", \"\" ] }, \",\" ] } } }",
|
||||
// 2. 생성된 tags 배열을 개별 문서로 분리 (예: ["a","b"] -> {tags:"a"}, {tags:"b"})
|
||||
"{ \$unwind: \"\$tags\" }",
|
||||
// 3. 각 태그의 앞뒤 공백 제거
|
||||
"{ \$project: { tag: { \$trim: { input: \"\$tags\" } } } }",
|
||||
// 4. 공백이 제거된 태그로 그룹화하여 고유한 값만 추출
|
||||
"{ \$group: { _id: \"\$tag\" } }",
|
||||
// 5. 그룹화 결과 중 빈 값("")은 제외
|
||||
"{ \$match: { _id: { \$ne: \"\" } } }"
|
||||
])
|
||||
fun findDistinctTags(): Flux<org.bson.Document> // 반환 타입을 Document로 변경
|
||||
|
||||
// [신규 추가] GIBBERISH 제외하고 조회
|
||||
fun findByPostTypeNotOrderByModifyTimeDesc(postType: String, pageable: Pageable): Flux<Post>
|
||||
fun countByPostTypeNot(postType: String): Mono<Long>
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package kr.lunaticbum.back.lun.repository
|
||||
|
||||
import kr.lunaticbum.back.lun.model.WebBookmark
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.mongodb.repository.Aggregation
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
@Repository
|
||||
interface WebBookmarkRepository : ReactiveMongoRepository<WebBookmark, String> {
|
||||
|
||||
|
||||
// WebBookmarkRepository 인터페이스 내부에 추가
|
||||
// savedAt이 특정 시간(?1)보다 작은 것들 중 최신순 조회
|
||||
fun findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
|
||||
visibilities: List<String>,
|
||||
maxTime: Long,
|
||||
pageable: Pageable
|
||||
): Flux<WebBookmark>
|
||||
|
||||
|
||||
fun findByUserIdOrderBySavedAtDesc(userId: String): Flux<WebBookmark>
|
||||
fun findByVisibilityInOrderBySavedAtDesc(visibilities: List<String>, pageable: Pageable): Flux<WebBookmark>
|
||||
fun countByVisibilityIn(visibilities: List<String>): Mono<Long>
|
||||
|
||||
fun findByMetadataStatus(status: String): Flux<WebBookmark>
|
||||
|
||||
// [추가] 필터링을 위한 고유 카테고리 및 태그 목록 조회 (이 위치로 이동)
|
||||
@Aggregation("{ \$unwind: '\$tags' }", "{ \$group: { _id: '\$tags' } }")
|
||||
fun findDistinctTags(): Flux<Map<String, Any>>
|
||||
|
||||
@Aggregation("{ \$group: { _id: '\$category' } }")
|
||||
fun findDistinctCategories(): Flux<Map<String, Any>>
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ package kr.lunaticbum.back.lun.service
|
||||
import kr.lunaticbum.back.lun.model.BookmarkType
|
||||
import kr.lunaticbum.back.lun.model.MetadataStatus
|
||||
import kr.lunaticbum.back.lun.model.WebBookmark
|
||||
import kr.lunaticbum.back.lun.model.WebBookmarkRepository
|
||||
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import org.jsoup.Jsoup
|
||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package kr.lunaticbum.back.lun.service
|
||||
|
||||
import kr.lunaticbum.back.lun.model.Comment
|
||||
import kr.lunaticbum.back.lun.repository.CommentRepository
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import java.net.URLDecoder
|
||||
|
||||
|
||||
@Service
|
||||
class CommentService(private val commentRepository: CommentRepository) {
|
||||
|
||||
/**
|
||||
* [수정] 각 댓글의 content를 URL 디코딩하는 로직을 추가합니다.
|
||||
*/
|
||||
private fun decodeCommentContent(comment: Comment): Comment {
|
||||
comment.content = comment.content?.let { URLDecoder.decode(it, "UTF-8") }
|
||||
return comment
|
||||
}
|
||||
|
||||
fun getRepliesForComment(parentId: String): Flux<Comment> {
|
||||
return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
fun addComment(comment: Comment): Mono<Comment> {
|
||||
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리
|
||||
return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
|
||||
fun getCommentsForPost(postId: String): Flux<Comment> {
|
||||
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
|
||||
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||
}
|
||||
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package kr.lunaticbum.back.lun.service
|
||||
|
||||
import kr.lunaticbum.back.lun.model.dto.ContentType
|
||||
import kr.lunaticbum.back.lun.model.dto.FeedItemDto
|
||||
import kr.lunaticbum.back.lun.model.dto.FeedResponse
|
||||
import kr.lunaticbum.back.lun.repository.PostRepository
|
||||
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
|
||||
import org.springframework.data.domain.PageRequest
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
// service/FeedService.kt
|
||||
|
||||
@Service
|
||||
class FeedService(
|
||||
private val postRepository: PostRepository,
|
||||
private val bookmarkRepository: WebBookmarkRepository
|
||||
) {
|
||||
/**
|
||||
* @param cursorTime 클라이언트가 가지고 있는 마지막 글의 시간 (첫 요청시엔 현재시간 or 아주 큰 값)
|
||||
* @param size 한 번에 불러올 개수 (예: 10개)
|
||||
*/
|
||||
fun getGlobalFeed(cursorTime: Long?, size: Int): Mono<FeedResponse> {
|
||||
// 커서가 없으면 현재 시간으로 설정 (첫 로딩)
|
||||
val lastTime = cursorTime ?: System.currentTimeMillis()
|
||||
|
||||
// 각 저장소에서 'size' 만큼만 가져옴 (부하 최소화)
|
||||
val pageable = PageRequest.of(0, size)
|
||||
|
||||
// 1. Post 조회 (lastTime 이전 글)
|
||||
val postsFlux = postRepository.findFeedPostsBefore(lastTime, pageable)
|
||||
.map { post ->
|
||||
val type = if (post.postType == "GIBBERISH") ContentType.GIBBERISH else ContentType.POST
|
||||
val rawContent = post.content?.replace(Regex("<.*?>"), "") ?: "" // 태그 제거
|
||||
|
||||
FeedItemDto(
|
||||
id = post.id,
|
||||
type = type,
|
||||
title = post.title,
|
||||
content = if (type == ContentType.GIBBERISH) post.content else rawContent,
|
||||
thumbnail = post.thumb,
|
||||
createdAt = post.modifyTime,
|
||||
writer = post.writer,
|
||||
url = "/blog/viewer/${post.id}"
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Bookmark 조회 (lastTime 이전 글)
|
||||
val bookmarksFlux = bookmarkRepository.findByVisibilityInAndSavedAtLessThanOrderBySavedAtDesc(
|
||||
listOf("PUBLIC"), lastTime, pageable
|
||||
).map { bookmark ->
|
||||
FeedItemDto(
|
||||
id = bookmark.id,
|
||||
type = ContentType.BOOKMARK,
|
||||
title = bookmark.title ?: bookmark.url,
|
||||
content = bookmark.userComment ?: bookmark.description,
|
||||
thumbnail = bookmark.displayImageUrl,
|
||||
createdAt = bookmark.savedAt,
|
||||
writer = bookmark.userId,
|
||||
url = bookmark.url ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
// 3. 병합 후 다시 정렬하고 size 만큼 자르기
|
||||
return Flux.merge(postsFlux, bookmarksFlux)
|
||||
.sort(Comparator.comparing(FeedItemDto::createdAt).reversed()) // 최신순 정렬
|
||||
.take(size.toLong()) // 전체 중 상위 size 개만 선택
|
||||
.collectList()
|
||||
.map { items ->
|
||||
// 마지막 아이템의 시간을 다음 커서로 설정
|
||||
val nextCursor = if (items.isNotEmpty()) items.last().createdAt else null
|
||||
FeedResponse(items, nextCursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package kr.lunaticbum.back.lun.service
|
||||
|
||||
import kr.lunaticbum.back.lun.model.KisConfigRequest
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
@Service
|
||||
class KisApiService() {
|
||||
private val baseUrl = "https://openapi.koreainvestment.com:9443" // 실전투자용
|
||||
private val webClient: WebClient = WebClient.create(baseUrl)
|
||||
|
||||
fun verifyAndGetToken(config: KisConfigRequest): Mono<String> {
|
||||
val body = mapOf(
|
||||
"grant_type" to "client_credentials",
|
||||
"appkey" to config.appKey,
|
||||
"appsecret" to config.appSecret
|
||||
)
|
||||
|
||||
return webClient.post()
|
||||
.uri("$baseUrl/oauth2/tokenP")
|
||||
.bodyValue(body)
|
||||
.retrieve()
|
||||
.bodyToMono(Map::class.java)
|
||||
.map { it["access_token"]?.toString() ?: throw Exception("토큰 발급 실패") }
|
||||
}
|
||||
}
|
||||
|
||||
@Service
|
||||
class KisMarketService() {
|
||||
private val baseUrl = "https://openapi.koreainvestment.com:9443"
|
||||
private val webClient: WebClient = WebClient.create(baseUrl)
|
||||
// 1. 국내 지수 조회 (KOSPI: "0001", KOSDAQ: "1001")
|
||||
fun getDomesticIndex(indexCode: String, token: String, appKey: String, appSecret: String): Mono<Map<*, *>> {
|
||||
return WebClient.create(baseUrl).get()
|
||||
.uri { it.path("/uapi/domestic-stock/v1/quotations/inquire-index-price")
|
||||
.queryParam("FID_COND_MRKT_DIV_CODE", "U") // 업종
|
||||
.queryParam("FID_INPUT_ISCD", indexCode)
|
||||
.build()
|
||||
}
|
||||
.header("authorization", "Bearer $token")
|
||||
.header("appkey", appKey)
|
||||
.header("appsecret", appSecret)
|
||||
.header("tr_id", "FHPST01010000") // 업종 현재가 조회 TR
|
||||
.retrieve()
|
||||
.bodyToMono(Map::class.java)
|
||||
}
|
||||
|
||||
// 2. 환율 및 해외 지수 조회 (환율: "FX@KRW", 나스닥: "NAS@IXIC")
|
||||
// ※ 해외 지수는 '해외주식 현재가 상세' API 등을 활용합니다.
|
||||
fun getMarketIndicator(symbol: String, token: String, appKey: String, appSecret: String): Mono<Map<*, *>> {
|
||||
return webClient.get()
|
||||
.uri { it.path("/uapi/overseas-stock/v1/quotations/price")
|
||||
.queryParam("AUTH", "")
|
||||
.queryParam("EXCD", symbol.split("@")[0]) // 거래소 코드
|
||||
.queryParam("SYMB", symbol.split("@")[1]) // 심볼
|
||||
.build()
|
||||
}
|
||||
.header("authorization", "Bearer $token")
|
||||
.header("appkey", appKey)
|
||||
.header("appsecret", appSecret)
|
||||
.header("tr_id", "HHDFS00000300") // 해외주식 현재가 상세 TR
|
||||
.retrieve()
|
||||
.bodyToMono(Map::class.java)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package kr.lunaticbum.back.lun.service
|
||||
|
||||
import kr.lunaticbum.back.lun.model.PostHistory
|
||||
import kr.lunaticbum.back.lun.repository.PostHistoryRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
|
||||
// 3. PostHistory를 위한 Service 클래스
|
||||
@Service
|
||||
class PostHistoryManager(private val repository: PostHistoryRepository) {
|
||||
fun save(postHistory: PostHistory): Mono<PostHistory> {
|
||||
return repository.save(postHistory)
|
||||
}
|
||||
|
||||
// [추가] postId로 모든 히스토리를 조회하는 함수
|
||||
fun findByPostId(postId: String): Flux<PostHistory> {
|
||||
return repository.findByPostIdOrderByArchivedAtDesc(postId)
|
||||
}
|
||||
}
|
||||
293
src/main/kotlin/kr/lunaticbum/back/lun/service/PostManager.kt
Normal file
293
src/main/kotlin/kr/lunaticbum/back/lun/service/PostManager.kt
Normal file
@ -0,0 +1,293 @@
|
||||
package kr.lunaticbum.back.lun.service
|
||||
|
||||
import kr.lunaticbum.back.lun.model.Post
|
||||
import kr.lunaticbum.back.lun.model.PostType
|
||||
import kr.lunaticbum.back.lun.repository.PostRepository
|
||||
import kr.lunaticbum.back.lun.utils.LogService
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
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.query.Criteria
|
||||
import org.springframework.data.mongodb.core.query.Query
|
||||
import org.springframework.data.mongodb.core.query.Update
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import java.net.URLDecoder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
||||
|
||||
@Service
|
||||
class PostManager(
|
||||
private val postRepository: PostRepository,
|
||||
private val reactiveMongoTemplate: ReactiveMongoTemplate
|
||||
) {
|
||||
@Autowired
|
||||
private lateinit var logService: LogService
|
||||
|
||||
@Autowired
|
||||
private lateinit var bCryptPasswordEncoder: PasswordEncoder
|
||||
|
||||
fun deletePost(postId: String): Mono<Void> {
|
||||
return postRepository.deleteById(postId)
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 목록 조회 (Aggregation 사용)
|
||||
fun findLatestUniquePaginated(pageable: Pageable) : Mono<List<Post>> {
|
||||
return postRepository.findLatestUniquePublishedPaginated(pageable)
|
||||
.collectList()
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 글 개수 (Aggregation 사용)
|
||||
fun countLatestUnique(): Mono<Long> {
|
||||
return postRepository.countLatestUniquePublished()
|
||||
.map { it.totalCount }
|
||||
.switchIfEmpty(Mono.just(0L))
|
||||
}
|
||||
|
||||
// [수정] '글쓰기' 권한 사용자용 목록 조회 (Aggregation 사용)
|
||||
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
|
||||
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
|
||||
.collectList()
|
||||
}
|
||||
|
||||
// [수정] '글쓰기' 권한 사용자용 글 개수 (Aggregation 사용)
|
||||
fun countLatestUniqueForWriter(username: String): Mono<Long> {
|
||||
return postRepository.countLatestUniqueForWriter(username)
|
||||
.map { it.totalCount }
|
||||
.switchIfEmpty(Mono.just(0L))
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 인기글
|
||||
fun getTop5UniquePublishedByViews(): Flux<Post> {
|
||||
return postRepository.findTop5ByPostingIsTrueOrderByReadCountDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
// [수정] 익명 사용자용 최신글
|
||||
fun getRecent5UniquePublished(): Flux<Post> {
|
||||
return postRepository.findTop5ByPostingIsTrueOrderByModifyTimeDesc().map {
|
||||
p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
// --- [신규 추가] 카테고리/태그 관련 서비스 메소드 ---
|
||||
fun findAllDistinctCategories(): Flux<String> {
|
||||
// 'category' 필드가 null이 아니고 비어있지 않은 문서들을 대상으로 distinct 연산 수행
|
||||
val query = Query.query(Criteria.where("category").ne(null).ne(""))
|
||||
return reactiveMongoTemplate.findDistinct(query, "category", "Post", String::class.java)
|
||||
}
|
||||
|
||||
fun findAllDistinctTags(): Flux<String> {
|
||||
return postRepository.findDistinctTags()
|
||||
.mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출
|
||||
.filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링
|
||||
}
|
||||
// --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 ---
|
||||
fun findPostsByCategory(category: String, pageable: Pageable): Mono<List<Post>> {
|
||||
return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList()
|
||||
}
|
||||
fun countPostsByCategory(category: String): Mono<Long> {
|
||||
return postRepository.countByCategoryAndPostingIsTrue(category)
|
||||
}
|
||||
|
||||
fun findPostsByTag(tag: String, pageable: Pageable): Mono<List<Post>> {
|
||||
// [수정] 한글 및 다국어를 지원하는 정규식으로 변경
|
||||
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
|
||||
return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList()
|
||||
}
|
||||
|
||||
fun countPostsByTag(tag: String): Mono<Long> {
|
||||
// [수정] 위와 동일하게 정규식 변경
|
||||
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
|
||||
return postRepository.countByTagsRegexAndPostingIsTrue(regex)
|
||||
}
|
||||
|
||||
// [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드
|
||||
fun findRandomGibberish(): Mono<Post> {
|
||||
return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name)
|
||||
}
|
||||
|
||||
// [신규 추가] 가장 최신 '사이트 소개' 글을 찾는 메소드
|
||||
fun findLatestAboutPost(): Mono<Post> {
|
||||
// 'ABOUT_SITE' 타입의 글들을 최신순으로 정렬하여 첫 번째 것만 가져옴
|
||||
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
|
||||
.next() // Flux에서 첫 번째 아이템(Mono)을 반환
|
||||
}
|
||||
|
||||
// [신규 추가] '사이트 소개' 글의 모든 버전(히스토리)을 찾는 메소드
|
||||
fun findAboutPostHistory(): Flux<Post> {
|
||||
return postRepository.findByPostTypeOrderByModifyTimeDesc(PostType.ABOUT_SITE.name)
|
||||
}
|
||||
|
||||
// [신규] 게시물 차단
|
||||
fun blockPost(postId: String): Mono<Post> {
|
||||
return postRepository.findById(postId).flatMap { post ->
|
||||
post.isBlocked = true
|
||||
postRepository.save(post)
|
||||
}
|
||||
}
|
||||
|
||||
// [신규] 게시물 차단 해제
|
||||
fun unblockPost(postId: String): Mono<Post> {
|
||||
return postRepository.findById(postId).flatMap { post ->
|
||||
post.isBlocked = false
|
||||
postRepository.save(post)
|
||||
}
|
||||
}
|
||||
|
||||
fun findById(id: String): Mono<Post> {
|
||||
return postRepository.findById(id)
|
||||
}
|
||||
|
||||
|
||||
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
|
||||
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
|
||||
.map { post ->
|
||||
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
|
||||
if (post.title.isNullOrBlank()) {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
|
||||
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
|
||||
}
|
||||
post
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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")))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 인증된 사용자를 위한 메서드 (모든 버전 조회, GIBBERISH 제외)
|
||||
*/
|
||||
fun findAllVersionsPaginated(pageable :Pageable) : Mono<List<Post>> {
|
||||
return postRepository.findByPostTypeNotOrderByModifyTimeDesc(PostType.GIBBERISH.name, pageable)
|
||||
.map { post ->
|
||||
// 1. 제목을 UTF-8로 디코딩합니다.
|
||||
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
|
||||
|
||||
// 2. 제목이 비어있으면 작성 시간을 기반으로 기본 제목을 설정합니다.
|
||||
if (post.title.isNullOrBlank()) {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
|
||||
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
|
||||
}
|
||||
post // 수정된 post 객체를 반환
|
||||
}
|
||||
.collectList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증된 사용자가 보는 글의 총 개수 (GIBBERISH 제외)
|
||||
*/
|
||||
fun countAllVersions(): Mono<Long> {
|
||||
return postRepository.countByPostTypeNot(PostType.GIBBERISH.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* 좋아요 카운트를 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)
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
println(p.title)
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecent10Posts(): Flux<Post> {
|
||||
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title)
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
println(p.title)
|
||||
p
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 홈 화면은 이제 "익명 사용자용 최신 글"의 0번 페이지, 8개 아이템을 명시적으로 요청합니다.
|
||||
*/
|
||||
fun find8() : Mono<List<Post>> {
|
||||
val pageRequest = PageRequest.of(0, 8) // Page 0, Size 8
|
||||
return this.findLatestUniquePaginated(pageRequest)
|
||||
}
|
||||
|
||||
fun save(post: Post): Mono<Post> {
|
||||
println("saved user before ${post}")
|
||||
// user.hashPassword(bCryptPasswordEncoder)
|
||||
return postRepository.save(post)
|
||||
.doOnSuccess { savedPost ->
|
||||
// 저장이 완료되었을 때 실행될 로직 (로그 출력 등)
|
||||
println("saved post success: ${savedPost.id}")
|
||||
}
|
||||
}
|
||||
|
||||
// [기존] 로그인 사용자용 인기글 (메서드 이름 명확화: getTop5 -> getTop5AllVersions)
|
||||
fun getTop5AllVersionsByViews(): Flux<Post> {
|
||||
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
// [기존] 로그인 사용자용 최신글 (메서드 이름 명확화)
|
||||
fun getRecent5AllVersions(): Flux<Post> {
|
||||
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
|
||||
p.title = URLDecoder.decode(p.title, "UTF-8")
|
||||
if (p.title?.isEmpty() == true) {
|
||||
p.title = "무제(無題)"
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package kr.lunaticbum.back.lun.service
|
||||
|
||||
import kr.lunaticbum.back.lun.model.Visibility
|
||||
import kr.lunaticbum.back.lun.model.WebBookmark
|
||||
import kr.lunaticbum.back.lun.repository.WebBookmarkRepository
|
||||
import org.springframework.data.domain.Page
|
||||
import org.springframework.data.domain.PageImpl
|
||||
import org.springframework.data.domain.Pageable
|
||||
import org.springframework.data.domain.Sort
|
||||
import org.springframework.data.mongodb.core.FindAndModifyOptions
|
||||
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
|
||||
import org.springframework.data.mongodb.core.query.Criteria
|
||||
import org.springframework.data.mongodb.core.query.Query
|
||||
import org.springframework.data.mongodb.core.query.Update
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
@Service
|
||||
class WebBookmarkService(private val repository: WebBookmarkRepository,
|
||||
private val reactiveMongoTemplate: ReactiveMongoTemplate
|
||||
// [수정] 생성자에 ReactiveMongoTemplate를 추가하여 스프링이 주입하도록 합니다.
|
||||
) {
|
||||
|
||||
|
||||
|
||||
// [이 메소드를 추가하세요]
|
||||
fun findById(id: String): Mono<WebBookmark> {
|
||||
return repository.findById(id)
|
||||
}
|
||||
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
|
||||
fun findAllDistinctCategories(): Flux<String> {
|
||||
return repository.findDistinctCategories()
|
||||
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
// [수정] map 대신 flatMap과 Mono.justOrEmpty를 사용하여 NullPointerException 방지
|
||||
fun findAllDistinctTags(): Flux<String> {
|
||||
return repository.findDistinctTags()
|
||||
.flatMap { map -> Mono.justOrEmpty(map["_id"]?.toString()) }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
|
||||
|
||||
fun getBookmarksForUser(userId: String): Flux<WebBookmark> {
|
||||
return repository.findByUserIdOrderBySavedAtDesc(userId)
|
||||
}
|
||||
|
||||
fun saveBookmark(bookmark: WebBookmark): Mono<WebBookmark> {
|
||||
// 여기에 중복 저장 방지 로직 등을 추가할 수 있음
|
||||
return repository.save(bookmark)
|
||||
}
|
||||
|
||||
// 필요하다면 삭제, 수정 기능 추가
|
||||
fun deleteBookmark(id: String): Mono<Void> {
|
||||
return repository.deleteById(id)
|
||||
}
|
||||
|
||||
// [수정] getVisibleBookmarks 메소드에 필터링 기능 추가
|
||||
fun getVisibleBookmarks(
|
||||
userDetails: UserDetails?,
|
||||
pageable: Pageable,
|
||||
category: String?, // 카테고리 파라미터 추가
|
||||
tag: String? // 태그 파라미터 추가
|
||||
): Mono<Page<WebBookmark>> {
|
||||
val visibleScopes = when {
|
||||
userDetails != null -> listOf(Visibility.PUBLIC.name, Visibility.MEMBERS.name)
|
||||
else -> listOf(Visibility.PUBLIC.name)
|
||||
}
|
||||
|
||||
// 동적 쿼리 생성 시작
|
||||
val query = Query(Criteria.where("visibility").`in`(visibleScopes))
|
||||
.with(Sort.by(Sort.Direction.DESC, "savedAt")) // <-- 이 줄을 추가하세요.
|
||||
.with(pageable)
|
||||
|
||||
// 카테고리 조건 추가
|
||||
if (!category.isNullOrBlank()) {
|
||||
query.addCriteria(Criteria.where("category").`is`(category))
|
||||
}
|
||||
|
||||
// 태그 조건 추가 (tags 배열에 해당 태그가 포함되어 있는지 확인)
|
||||
if (!tag.isNullOrBlank()) {
|
||||
query.addCriteria(Criteria.where("tags").`in`(tag))
|
||||
}
|
||||
|
||||
// 데이터 조회 및 카운트
|
||||
val bookmarks = reactiveMongoTemplate.find(query, WebBookmark::class.java).collectList()
|
||||
val totalCount = reactiveMongoTemplate.count(Query.of(query).limit(-1).skip(-1), WebBookmark::class.java)
|
||||
|
||||
return Mono.zip(bookmarks, totalCount).map { tuple ->
|
||||
PageImpl(tuple.t1, pageable, tuple.t2)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크의 좋아요 카운트를 1 증가시킵니다.
|
||||
* @param bookmarkId 대상 북마크의 ID
|
||||
* @return 업데이트된 WebBookmark 객체
|
||||
*/
|
||||
fun incrementVote(bookmarkId: String): Mono<WebBookmark> {
|
||||
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
|
||||
val update = Update().inc("voteCount", 1)
|
||||
val options = FindAndModifyOptions.options().returnNew(true)
|
||||
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* 북마크의 싫어요 카운트를 1 증가시킵니다.
|
||||
* @param bookmarkId 대상 북마크의 ID
|
||||
* @return 업데이트된 WebBookmark 객체
|
||||
*/
|
||||
fun incrementUnlike(bookmarkId: String): Mono<WebBookmark> {
|
||||
val query = Query.query(Criteria.where("id").`is`(bookmarkId))
|
||||
val update = Update().inc("unlikeCount", 1)
|
||||
val options = FindAndModifyOptions.options().returnNew(true)
|
||||
return reactiveMongoTemplate.findAndModify(query, update, options, WebBookmark::class.java)
|
||||
}
|
||||
|
||||
}
|
||||
@ -57,27 +57,40 @@ import java.util.Base64
|
||||
//}
|
||||
fun String.plainText() = String(Base64.getMimeDecoder().decode(this))
|
||||
|
||||
fun String.extractModelData(calback : (Exception?,String)->Unit) {
|
||||
//fun String.extractModelData(calback : (Exception?,String)->Unit) {
|
||||
// try {
|
||||
// val decodedBytes: ByteArray = Base64.getDecoder().decode(this)
|
||||
// String(decodedBytes).let { resultString ->
|
||||
// try {
|
||||
// Gson().fromJson<RequestModel>(resultString, RequestModel::class.java).let { model ->
|
||||
// model.data?.let { jsonString ->
|
||||
// try {
|
||||
// println("RequestModel ${jsonString}")
|
||||
// calback.invoke(null,model.extractData())
|
||||
// } catch (e: Exception) {
|
||||
// calback.invoke(ExtractDataRequestModelException("Exception on extractData with ${Gson().toJson(model)}", e.cause), jsonString)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } catch (e: Exception) {
|
||||
// calback.invoke(MakeRequestModelException("Exception on make RequestModel with $resultString", e.cause),this@extractModelData)
|
||||
// }
|
||||
// }
|
||||
// } catch (e: Exception) {
|
||||
// calback.invoke(Base64DecodeException("Exception on Base64 decode", e.cause),this@extractModelData)
|
||||
// }
|
||||
//}
|
||||
fun String.extractModelData(completion: (Exception?, String) -> Unit) {
|
||||
try {
|
||||
val decodedBytes: ByteArray = Base64.getDecoder().decode(this)
|
||||
String(decodedBytes).let { resultString ->
|
||||
try {
|
||||
Gson().fromJson<RequestModel>(resultString, RequestModel::class.java).let { model ->
|
||||
model.data?.let { jsonString ->
|
||||
try {
|
||||
println("RequestModel ${jsonString}")
|
||||
calback.invoke(null,model.extractData())
|
||||
} catch (e: Exception) {
|
||||
calback.invoke(ExtractDataRequestModelException("Exception on extractData with ${Gson().toJson(model)}", e.cause), jsonString)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
calback.invoke(MakeRequestModelException("Exception on make RequestModel with $resultString", e.cause),this@extractModelData)
|
||||
}
|
||||
}
|
||||
// 1. Base64 디코딩 (클라이언트가 btoa로 보낸 경우)
|
||||
val decodedBytes = java.util.Base64.getDecoder().decode(this)
|
||||
val jsonString = String(decodedBytes, Charsets.UTF_8)
|
||||
|
||||
// 2. 만약 페이로드가 { "data": "...", "key": "..." } 구조라면 필요한 부분만 추출
|
||||
// 여기서는 단순화를 위해 전체 스트링을 그대로 넘깁니다.
|
||||
completion(null, jsonString)
|
||||
} catch (e: Exception) {
|
||||
calback.invoke(Base64DecodeException("Exception on Base64 decode", e.cause),this@extractModelData)
|
||||
completion(e, "")
|
||||
}
|
||||
}
|
||||
class Base64DecodeException(message: String, cause : Throwable? = null) : Exception(message, cause)
|
||||
|
||||
@ -1,70 +1,24 @@
|
||||
/* game.css - 게임 공통 테마 및 레이아웃 */
|
||||
|
||||
:root {
|
||||
/* Game Specific Colors */
|
||||
--color-felt-green: #008000;
|
||||
--color-felt-border: #004d00;
|
||||
--color-grid-bg-2048: #b0bec5;
|
||||
--color-tile-empty: #eceff1;
|
||||
--color-incorrect-bg: #ffdddd;
|
||||
--color-incorrect-text: #d8000c;
|
||||
}
|
||||
|
||||
/* 게임 페이지 전체 래퍼 */
|
||||
/* src/main/resources/static/css/pages/game.css */
|
||||
.game-body-wrapper {
|
||||
text-align: center;
|
||||
padding: 20px 10px; /* 모바일 여백 확보 */
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 60vh; /* 최소 높이 확보 */
|
||||
}
|
||||
|
||||
/* [핵심] 게임 공통 컨테이너 (카드 UI) */
|
||||
.game-play-box {
|
||||
background: var(--bg-element);
|
||||
padding: clamp(15px, 4vw, 30px);
|
||||
border-radius: var(--border-radius-main);
|
||||
box-shadow: var(--shadow-default);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
width: 100%;
|
||||
max-width: 500px; /* 기본 너비 (2048, 스도쿠용) */
|
||||
margin: 0 auto 30px auto;
|
||||
|
||||
/* 내부 요소 중앙 정렬 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 넓은 화면이 필요한 게임용 (스파이더, 노노그램) */
|
||||
.game-play-box.wide {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* 게임 제목 */
|
||||
h1 {
|
||||
font-size: clamp(2.0em, 5vw, 2.5em);
|
||||
margin: 0 0 1.5em 0;
|
||||
color: var(--text-main);
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* 점수판 공통 스타일 */
|
||||
.score-board {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
.game-body-wrapper h1 {
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
background: var(--bg-element-alt);
|
||||
padding: 10px 20px;
|
||||
border-radius: 50px; /* 둥근 알약 모양 */
|
||||
font-size: 1.8rem;
|
||||
color: #333;
|
||||
}
|
||||
.score-board span {
|
||||
color: var(--color-primary);
|
||||
|
||||
#game-container {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #ddd;
|
||||
transition: width 0.3s ease; /* 반응형 리사이즈 시 부드럽게 */
|
||||
}
|
||||
|
||||
.ads-container {
|
||||
min-height: 100px; /* 광고 로딩 전 영역 확보 */
|
||||
background: #fafafa;
|
||||
}
|
||||
@ -23,6 +23,7 @@
|
||||
#game-board { grid-gap: 10px; padding: 10px; }
|
||||
}
|
||||
|
||||
|
||||
.tile {
|
||||
width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;
|
||||
font-weight: bold; border-radius: 3px;
|
||||
|
||||
@ -384,4 +384,34 @@ window.openBookmarkEditPopup = function(btn) {
|
||||
UI.showAlert("알림", "북마크 수정 기능 준비 중");
|
||||
};
|
||||
window.openBookmarkCategoryPopup = () => UI.showAlert("알림", "카테고리 기능 준비 중");
|
||||
window.openBookmarkTagPopup = () => UI.showAlert("알림", "태그 기능 준비 중");
|
||||
window.openBookmarkTagPopup = () => UI.showAlert("알림", "태그 기능 준비 중");
|
||||
|
||||
async function submitStockConfig() {
|
||||
const data = {
|
||||
appKey: document.getElementById('kis_app_key').value,
|
||||
appSecret: document.getElementById('kis_app_secret').value,
|
||||
accountNo: document.getElementById('kis_account_no').value
|
||||
};
|
||||
|
||||
try {
|
||||
// CSRF 토큰을 포함한 POST 요청
|
||||
const res = await Api.request('/api/stock/config', 'POST', data);
|
||||
if (res.resultCode === 0) {
|
||||
UI.showAlert("연결 성공", "세션이 유지되는 동안 API를 사용할 수 있습니다.");
|
||||
location.href = "/stock/dashboard.bs";
|
||||
} else {
|
||||
UI.showAlert("실패", res.resultMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
UI.showAlert("오류", "서버 통신 중 에러가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
async function checkKisSession() {
|
||||
// 세션에 KIS_AUTH가 있는지 확인하는 간단한 API 호출
|
||||
const res = await Api.request('/api/stock/session-check');
|
||||
if (res.resultCode !== 0) {
|
||||
UI.showAlert("알림", "API 키 설정이 필요합니다.");
|
||||
location.href = "/stock/config.bs";
|
||||
}
|
||||
}
|
||||
@ -14,65 +14,47 @@ export let Api = {
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
},
|
||||
|
||||
/**
|
||||
* 공통 Fetch Wrapper (GET/POST/PUT/DELETE)
|
||||
*/
|
||||
async request(url, method = 'GET', body = null, headers = {}) {
|
||||
const defaultHeaders = {
|
||||
'X-CSRF-TOKEN': this.getCsrfToken()
|
||||
};
|
||||
// [수정] URL이 /로 시작하지 않으면 자동으로 붙여줌
|
||||
const targetUrl = url.startsWith('/') ? url : '/' + url;
|
||||
|
||||
const config = {
|
||||
method: method,
|
||||
headers: { ...defaultHeaders, ...headers }
|
||||
};
|
||||
const defaultHeaders = { 'X-CSRF-TOKEN': this.getCsrfToken() };
|
||||
const config = { method, headers: { ...defaultHeaders, ...headers } };
|
||||
|
||||
if (body) {
|
||||
// FormData는 Content-Type을 설정하지 않음 (브라우저 자동 설정)
|
||||
if (body instanceof FormData) {
|
||||
config.body = body;
|
||||
} else {
|
||||
if (body instanceof FormData) config.body = body;
|
||||
else {
|
||||
config.headers['Content-Type'] = 'application/json';
|
||||
config.body = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP Error: ${response.status}`);
|
||||
const response = await fetch(targetUrl, config);
|
||||
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
return await response.json();
|
||||
}
|
||||
// 응답이 없는 경우(204 No Content 등) 대비
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
console.error(`API Request Failed [${method} ${url}]:`, error);
|
||||
console.error(`API Fail [${method} ${targetUrl}]:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* [Legacy 호환] 암호화된 POST 요청
|
||||
* (기존 post() 함수 대체 - fetch 사용)
|
||||
*/
|
||||
async postEncrypted(url, type, dataObj, key) {
|
||||
// 데이터 암호화 (unformat 로직)
|
||||
// [수정] 암호화(encrypt)를 호출하지 않고 바로 JSON을 Base64로 인코딩만 합니다.
|
||||
const dataStr = JSON.stringify(dataObj);
|
||||
const encryptedData = this.encrypt(type, dataStr, key);
|
||||
|
||||
const payload = {
|
||||
data: encryptedData,
|
||||
key: key,
|
||||
type: type
|
||||
};
|
||||
|
||||
// Base64 인코딩
|
||||
const base64Payload = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
|
||||
// 백엔드 extractModelData 구조와 맞추기 위해 Base64 인코딩만 수행
|
||||
const base64Payload = btoa(unescape(encodeURIComponent(dataStr)));
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Content-Type': 'text/plain', // 기존 규격 유지
|
||||
'X-CSRF-TOKEN': this.getCsrfToken()
|
||||
},
|
||||
body: base64Payload
|
||||
@ -80,19 +62,5 @@ export let Api = {
|
||||
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// 암호화(난독화) 로직 (기존 unformat 함수)
|
||||
encrypt(type, data, key) {
|
||||
let even = [], odd = [];
|
||||
data.split("").forEach((v, idx) => (idx % 2 === 0 ? even.push(v) : odd.push(v)));
|
||||
const dividerStr = ["|*-*|", key, "|*-*|"].join("");
|
||||
|
||||
switch (type) {
|
||||
case "T0": return [odd.join(""), dividerStr, even.join("")].join("");
|
||||
case "T1": return [odd.reverse().join(""), dividerStr, even.join("")].join("");
|
||||
case "T2": return [odd.join(""), dividerStr, even.reverse().join("")].join("");
|
||||
default: return [odd.reverse().join(""), dividerStr, even.reverse().join("")].join("");
|
||||
}
|
||||
}
|
||||
};
|
||||
164
src/main/resources/static/js/modules/canvas_utils.js
Normal file
164
src/main/resources/static/js/modules/canvas_utils.js
Normal file
@ -0,0 +1,164 @@
|
||||
/**
|
||||
* CommonCanvas.js
|
||||
* - 고해상도(DPR) 대응, 모바일 터치 최적화
|
||||
* - 성공 오버레이 & 폭죽(Particles) 효과 통합
|
||||
*/
|
||||
export const CommonCanvas = {
|
||||
init(canvas, logicalWidth, logicalHeight) {
|
||||
if (!canvas) return null;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const container = canvas.parentElement;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
let particles = [];
|
||||
let animationId = null;
|
||||
|
||||
// 모바일 스크롤 방지
|
||||
canvas.style.touchAction = 'none';
|
||||
|
||||
// 1. 리사이징 함수
|
||||
const resize = () => {
|
||||
const style = getComputedStyle(container);
|
||||
const availableWidth = container.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
|
||||
// 비율 유지 계산
|
||||
const scale = availableWidth / logicalWidth;
|
||||
const displayHeight = logicalHeight * scale;
|
||||
|
||||
canvas.style.width = `${availableWidth}px`;
|
||||
canvas.style.height = `${displayHeight}px`;
|
||||
|
||||
// 실제 픽셀 해상도 (선명하게)
|
||||
canvas.width = availableWidth * dpr;
|
||||
canvas.height = displayHeight * dpr;
|
||||
|
||||
// 컨텍스트 스케일링
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0); // 초기화
|
||||
ctx.scale(dpr * scale, dpr * scale);
|
||||
|
||||
return { scale };
|
||||
};
|
||||
|
||||
// 2. 좌표 계산 (마우스/터치 통합)
|
||||
const getCoords = (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
// 현재 스케일 역산
|
||||
const scaleX = logicalWidth / rect.width;
|
||||
const scaleY = logicalHeight / rect.height;
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY
|
||||
};
|
||||
};
|
||||
|
||||
// 3. UI 유틸리티
|
||||
const isInside = (pos, rect) => {
|
||||
return pos.x >= rect.x && pos.x <= rect.x + rect.w &&
|
||||
pos.y >= rect.y && pos.y <= rect.y + rect.h;
|
||||
};
|
||||
|
||||
const fillRoundRect = (x, y, w, h, r, color) => {
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
if (ctx.roundRect) {
|
||||
ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill();
|
||||
} else {
|
||||
// 구형 브라우저 호환
|
||||
ctx.fillRect(x, y, w, h);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// 4. 폭죽(Particles) 효과 로직
|
||||
const createParticles = () => {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
particles.push({
|
||||
x: logicalWidth / 2,
|
||||
y: logicalHeight / 2,
|
||||
vx: (Math.random() - 0.5) * 15,
|
||||
vy: (Math.random() - 0.5) * 15 - 5,
|
||||
size: Math.random() * 5 + 3,
|
||||
color: `hsl(${Math.random() * 360}, 80%, 60%)`,
|
||||
life: 1.0,
|
||||
decay: 0.01 + Math.random() * 0.02
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const showSuccessOverlay = (data) => {
|
||||
const { title = "SUCCESS!", scoreLabel = "SCORE", scoreValue, timeValue } = data;
|
||||
createParticles();
|
||||
|
||||
const renderFrame = () => {
|
||||
// 배경 (약간 투명한 검정)
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
|
||||
ctx.fillRect(0, 0, logicalWidth, logicalHeight);
|
||||
|
||||
// 폭죽 그리기
|
||||
particles.forEach((p, i) => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy += 0.2; // 중력
|
||||
p.life -= p.decay;
|
||||
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = p.life;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// 메시지 박스
|
||||
const boxW = Math.min(logicalWidth * 0.8, 500);
|
||||
const boxH = 300;
|
||||
const boxX = (logicalWidth - boxW) / 2;
|
||||
const boxY = (logicalHeight - boxH) / 2;
|
||||
|
||||
fillRoundRect(boxX, boxY, boxW, boxH, 20, "#ffffff");
|
||||
|
||||
// 텍스트
|
||||
ctx.fillStyle = "#4CAF50";
|
||||
ctx.font = "bold 50px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(title, logicalWidth / 2, boxY + 80);
|
||||
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.font = "bold 24px Arial";
|
||||
let textY = boxY + 150;
|
||||
|
||||
if (timeValue !== undefined) {
|
||||
const m = String(Math.floor(timeValue / 60)).padStart(2, '0');
|
||||
const s = String(timeValue % 60).padStart(2, '0');
|
||||
ctx.fillText(`TIME: ${m}:${s}`, logicalWidth / 2, textY);
|
||||
textY += 40;
|
||||
}
|
||||
|
||||
if (scoreValue !== undefined) {
|
||||
ctx.fillText(`${scoreLabel}: ${scoreValue}`, logicalWidth / 2, textY);
|
||||
textY += 40;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#666";
|
||||
ctx.font = "16px Arial";
|
||||
ctx.fillText("잠시 후 결과가 저장됩니다...", logicalWidth / 2, boxY + 260);
|
||||
|
||||
if (particles.length > 0) {
|
||||
animationId = requestAnimationFrame(renderFrame);
|
||||
}
|
||||
};
|
||||
|
||||
if (animationId) cancelAnimationFrame(animationId);
|
||||
renderFrame();
|
||||
};
|
||||
|
||||
return { ctx, resize, getCoords, isInside, fillRoundRect, showSuccessOverlay };
|
||||
}
|
||||
};
|
||||
168
src/main/resources/static/js/pages/game_2048_canvas.js
Normal file
168
src/main/resources/static/js/pages/game_2048_canvas.js
Normal file
@ -0,0 +1,168 @@
|
||||
import { Game } from '../modules/game.js';
|
||||
import { CommonCanvas } from '../modules/canvas_utils.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canvas = document.getElementById('game2048Canvas');
|
||||
// 공통 템플릿 사용 시 ID 확인 필요, 없을 시 HTML에서 ID 수정 필요
|
||||
// 여기서는 파일명에 맞게 game_2048.js 로 작성하지만, HTML의 canvas id="game2048Canvas" 여야 함.
|
||||
if (!canvas) return;
|
||||
|
||||
const V = 600, PAD = 15, CELL = (600 - PAD*5)/4;
|
||||
const common = CommonCanvas.init(canvas, V, V+100);
|
||||
const { ctx, resize, showSuccessOverlay, fillRoundRect } = common;
|
||||
|
||||
let grid = Array(16).fill(0);
|
||||
let score = 0, isGameOver = false;
|
||||
|
||||
// 타일 색상
|
||||
const colors = {
|
||||
0: "#cdc1b4", 2: "#eee4da", 4: "#ede0c8", 8: "#f2b179", 16: "#f59563",
|
||||
32: "#f67c5f", 64: "#f65e3b", 128: "#edcf72", 256: "#edcc61", 1024: "#edc53f", 2048: "#edc22e"
|
||||
};
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, V, V+100);
|
||||
|
||||
// 보드 배경
|
||||
fillRoundRect(0, 0, V, V, 10, "#bbada0");
|
||||
|
||||
// 타일
|
||||
grid.forEach((v, i) => {
|
||||
const x = PAD + (i%4)*(CELL+PAD);
|
||||
const y = PAD + Math.floor(i/4)*(CELL+PAD);
|
||||
const bgColor = colors[v] || "#3c3a32";
|
||||
fillRoundRect(x, y, CELL, CELL, 5, bgColor);
|
||||
|
||||
if (v > 0) {
|
||||
ctx.fillStyle = (v <= 4) ? "#776e65" : "#f9f6f2";
|
||||
ctx.font = `bold ${v < 100 ? 50 : v < 1000 ? 40 : 30}px Arial`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(v, x+CELL/2, y+CELL/2+15);
|
||||
}
|
||||
});
|
||||
|
||||
// 점수
|
||||
ctx.fillStyle = "#333";
|
||||
ctx.font = "bold 30px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(`SCORE: ${score}`, V/2, V + 60);
|
||||
}
|
||||
|
||||
function addRandom() {
|
||||
const avail = grid.map((v, i) => v === 0 ? i : -1).filter(i => i !== -1);
|
||||
if (avail.length > 0) grid[avail[Math.floor(Math.random()*avail.length)]] = Math.random()<0.9 ? 2 : 4;
|
||||
}
|
||||
|
||||
function init() {
|
||||
grid.fill(0); score = 0; isGameOver = false;
|
||||
addRandom(); addRandom();
|
||||
draw();
|
||||
}
|
||||
|
||||
// 게임 로직 (이동 및 병합)
|
||||
function move(dir) {
|
||||
if (isGameOver) return;
|
||||
let rotated = [...grid]; // 복사
|
||||
|
||||
// 방향에 따라 회전하여 '왼쪽으로 이동' 문제로 변환
|
||||
// (간소화를 위해 로직 직접 구현)
|
||||
const getVal = (r, c) => rotated[r*4 + c];
|
||||
const setVal = (r, c, v) => rotated[r*4 + c] = v;
|
||||
|
||||
let moved = false;
|
||||
let scoreAdd = 0;
|
||||
|
||||
// 각 행/열 처리 로직은 복잡하므로, 가장 직관적인 '벡터' 방식 사용
|
||||
// 여기서는 기존 DOM 버전의 로직을 Canvas 데이터 구조(1차원 배열)에 맞게 변환
|
||||
const vector = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -4, ArrowDown: 4 }[dir];
|
||||
if (!vector) return;
|
||||
|
||||
// ... (전통적인 2048 알고리즘 구현 생략 - 너무 길어짐.
|
||||
// 대신 기존 DOM 버전의 moveRow 로직을 차용하여 구현)
|
||||
|
||||
// 행 단위 처리 (좌우)
|
||||
if (dir === 'ArrowLeft' || dir === 'ArrowRight') {
|
||||
for (let r=0; r<4; r++) {
|
||||
let row = [grid[r*4], grid[r*4+1], grid[r*4+2], grid[r*4+3]];
|
||||
if (dir === 'ArrowRight') row.reverse();
|
||||
|
||||
// 병합 로직
|
||||
let filtered = row.filter(v => v);
|
||||
for(let i=0; i<filtered.length-1; i++) {
|
||||
if (filtered[i] === filtered[i+1]) {
|
||||
filtered[i] *= 2; score += filtered[i];
|
||||
filtered[i+1] = 0;
|
||||
}
|
||||
}
|
||||
filtered = filtered.filter(v => v); // 0 제거
|
||||
while(filtered.length < 4) filtered.push(0);
|
||||
|
||||
if (dir === 'ArrowRight') filtered.reverse();
|
||||
|
||||
for(let c=0; c<4; c++) {
|
||||
if (grid[r*4+c] !== filtered[c]) moved = true;
|
||||
grid[r*4+c] = filtered[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 열 단위 처리 (상하)
|
||||
else {
|
||||
for (let c=0; c<4; c++) {
|
||||
let col = [grid[c], grid[c+4], grid[c+8], grid[c+12]];
|
||||
if (dir === 'ArrowDown') col.reverse();
|
||||
|
||||
let filtered = col.filter(v => v);
|
||||
for(let i=0; i<filtered.length-1; i++) {
|
||||
if (filtered[i] === filtered[i+1]) {
|
||||
filtered[i] *= 2; score += filtered[i];
|
||||
filtered[i+1] = 0;
|
||||
}
|
||||
}
|
||||
filtered = filtered.filter(v => v);
|
||||
while(filtered.length < 4) filtered.push(0);
|
||||
|
||||
if (dir === 'ArrowDown') filtered.reverse();
|
||||
|
||||
for(let r=0; r<4; r++) {
|
||||
if (grid[r*4+c] !== filtered[r]) moved = true;
|
||||
grid[r*4+c] = filtered[r];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
addRandom();
|
||||
draw();
|
||||
if (grid.every(v => v !== 0) && !canMove()) {
|
||||
isGameOver = true;
|
||||
showSuccessOverlay({ title: "GAME OVER", scoreValue: score });
|
||||
// 실패 처리는 모달 없이 오버레이만 띄움
|
||||
} else if (grid.includes(2048)) { // 2048 도달 시 승리
|
||||
showSuccessOverlay({ title: "2048 CLEAR!", scoreValue: score });
|
||||
setTimeout(() => Game.showSuccessModal({ gameType: 'GAME_2048', primaryScore: score }), 2500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canMove() {
|
||||
for(let i=0; i<16; i++) {
|
||||
if (i%4 < 3 && grid[i] === grid[i+1]) return true; // 가로 인접
|
||||
if (i < 12 && grid[i] === grid[i+4]) return true; // 세로 인접
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', e => move(e.key));
|
||||
|
||||
// 터치 지원
|
||||
let tsx, tsy;
|
||||
canvas.addEventListener('touchstart', e => { tsx=e.touches[0].clientX; tsy=e.touches[0].clientY; });
|
||||
canvas.addEventListener('touchend', e => {
|
||||
let dx = e.changedTouches[0].clientX - tsx;
|
||||
let dy = e.changedTouches[0].clientY - tsy;
|
||||
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx)>30) move(dx>0 ? 'ArrowRight' : 'ArrowLeft');
|
||||
else if (Math.abs(dy)>30) move(dy>0 ? 'ArrowDown' : 'ArrowUp');
|
||||
});
|
||||
|
||||
resize(); init();
|
||||
});
|
||||
117
src/main/resources/static/js/pages/game_nonogram_canvas.js
Normal file
117
src/main/resources/static/js/pages/game_nonogram_canvas.js
Normal file
@ -0,0 +1,117 @@
|
||||
import { UI } from '../modules/ui.js';
|
||||
import { Api } from '../modules/api.js';
|
||||
import { CommonCanvas } from '../modules/canvas_utils.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canvas = document.getElementById('nonogramCanvas');
|
||||
if (!canvas || !window.puzzleData) return;
|
||||
const p = window.puzzleData;
|
||||
const V = 800, CELL = 40, H = 220;
|
||||
const common = CommonCanvas.init(canvas, V, V+100);
|
||||
const { ctx, resize, getCoords, fillRoundRect, showSuccessOverlay } = common;
|
||||
|
||||
let board = Array.from({length: p.rowClues.length}, () => Array(p.colClues.length).fill(0));
|
||||
let lives = 5, isWin = false, playMode = 'fill';
|
||||
|
||||
// 배경 이미지 로드
|
||||
const revealImg = new Image();
|
||||
if (p.originalImageFile) revealImg.src = '/uploads/' + p.originalImageFile;
|
||||
revealImg.onload = () => draw();
|
||||
|
||||
const ui = {
|
||||
fill: { x: 20, y: V+20, w: 100, h: 50, label: "■ 채우기" },
|
||||
mark: { x: 140, y: V+20, w: 100, h: 50, label: "X 표시" }
|
||||
};
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0,0,V,V+100); ctx.fillStyle = "#fff"; ctx.fillRect(0,0,V,V+100);
|
||||
|
||||
// 배경 이미지 (투명도 조절)
|
||||
if (revealImg.complete && revealImg.naturalWidth) {
|
||||
ctx.globalAlpha = isWin ? 1.0 : 0.15;
|
||||
ctx.drawImage(revealImg, H, H, V-H, V-H);
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
// 힌트 및 그리드
|
||||
drawHints();
|
||||
drawBoard();
|
||||
|
||||
// 하단 UI
|
||||
fillRoundRect(ui.fill.x, ui.fill.y, ui.fill.w, ui.fill.h, 10, playMode==='fill'?"#4a90e2":"#eee");
|
||||
fillRoundRect(ui.mark.x, ui.mark.y, ui.mark.w, ui.mark.h, 10, playMode==='mark'?"#4a90e2":"#eee");
|
||||
ctx.fillStyle = "#333"; ctx.font = "18px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText(ui.fill.label, ui.fill.x+50, ui.fill.y+32);
|
||||
ctx.fillText(ui.mark.label, ui.mark.x+50, ui.mark.y+32);
|
||||
|
||||
ctx.textAlign = "right"; ctx.fillText(`LIFE: ${lives}`, V-30, V+50);
|
||||
}
|
||||
|
||||
function drawHints() {
|
||||
ctx.fillStyle = "#333"; ctx.font = "bold 14px monospace"; ctx.textAlign = "right";
|
||||
p.rowClues.forEach((hints, r) => {
|
||||
hints.forEach((n, i) => ctx.fillText(n, H-10 - i*20, H + r*CELL + 25));
|
||||
});
|
||||
ctx.textAlign = "center";
|
||||
p.colClues.forEach((hints, c) => {
|
||||
hints.forEach((n, i) => ctx.fillText(n, H + c*CELL + 20, H - 10 - i*20));
|
||||
});
|
||||
}
|
||||
|
||||
function drawBoard() {
|
||||
board.forEach((row, r) => row.forEach((val, c) => {
|
||||
const x = H + c*CELL, y = H + r*CELL;
|
||||
ctx.strokeStyle = "#bbb"; ctx.strokeRect(x, y, CELL, CELL);
|
||||
if (val === 1) { ctx.fillStyle = "#333"; ctx.fillRect(x+2, y+2, CELL-4, CELL-4); }
|
||||
else if (val === 2) {
|
||||
ctx.strokeStyle = "red"; ctx.lineWidth = 2;
|
||||
ctx.beginPath(); ctx.moveTo(x+5,y+5); ctx.lineTo(x+35,y+35); ctx.moveTo(x+35,y+5); ctx.lineTo(x+5,y+35); ctx.stroke();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
if (isWin) return;
|
||||
const pos = getCoords(e);
|
||||
|
||||
// UI 클릭
|
||||
if (common.isInside(pos, ui.fill)) { playMode = 'fill'; draw(); return; }
|
||||
if (common.isInside(pos, ui.mark)) { playMode = 'mark'; draw(); return; }
|
||||
|
||||
// 보드 클릭 (우클릭 마크 지원)
|
||||
const c = Math.floor((pos.x - H)/CELL), r = Math.floor((pos.y - H)/CELL);
|
||||
if (r >= 0 && c >= 0) {
|
||||
const mode = (e.button === 2) ? 'mark' : playMode;
|
||||
processMove(r, c, mode);
|
||||
}
|
||||
draw();
|
||||
});
|
||||
|
||||
// 우클릭 메뉴 방지
|
||||
canvas.addEventListener('contextmenu', e => e.preventDefault());
|
||||
|
||||
function processMove(r, c, mode) {
|
||||
if (board[r][c] !== 0) return;
|
||||
if (mode === 'fill') {
|
||||
if (p.solutionGrid[r][c] === 1) {
|
||||
board[r][c] = 1;
|
||||
if (checkClear()) {
|
||||
isWin = true;
|
||||
showSuccessOverlay({ title: "NONOGRAM CLEAR!", scoreValue: lives });
|
||||
setTimeout(() => Api.request('/api/puzzle/clear', 'POST', { puzzleId: p.id }), 2500);
|
||||
}
|
||||
} else {
|
||||
board[r][c] = 2; lives--;
|
||||
if(lives <= 0) location.reload();
|
||||
}
|
||||
} else {
|
||||
board[r][c] = 2;
|
||||
}
|
||||
}
|
||||
|
||||
function checkClear() {
|
||||
return p.solutionGrid.every((row, r) => row.every((val, c) => (val === 1 ? board[r][c] === 1 : true)));
|
||||
}
|
||||
|
||||
resize(); draw();
|
||||
});
|
||||
@ -1,610 +1,257 @@
|
||||
import { Api } from '../modules/api.js';
|
||||
import { Game } from '../modules/game.js';
|
||||
import { CommonCanvas } from '../modules/canvas_utils.js';
|
||||
import { UI } from '../modules/ui.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 1. 상수 및 변수 선언
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const SAVED_GAME_ID_KEY = 'spider_saved_game_id';
|
||||
const canvas = document.getElementById('spiderCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// 1000x1000 논리 크기
|
||||
const V = 1000, CARD_W = 80, CARD_H = 112, GAP_X = 90, OVER_Y = 30;
|
||||
const common = CommonCanvas.init(canvas, V, V);
|
||||
const { ctx, resize, getCoords, isInside, showSuccessOverlay, fillRoundRect } = common;
|
||||
|
||||
let currentGame = null, draggedCards = [], dragOff = {x:0, y:0};
|
||||
let isProcessing = false;
|
||||
const UI_ELEMENTS = {};
|
||||
const CARD_WIDTH_RATIO = 1 / 11.5, CARD_HEIGHT_RATIO = 1.4, CARD_GAP_X_RATIO = 0.15, CARD_OVERLAP_Y_RATIO = 0.3;
|
||||
const CARD_RANK_LEFT_PADDING = 0.1, CARD_RANK_TOP_PADDING = 0.1, CARD_SYMBOL_TOP_PADDING = 0.25, CARD_SYMBOL_BOTTOM_PADDING = 0.15;
|
||||
const FOUNDATION_CARD_SPACING = 0.2, FOUNDATION_WIDTH_RATIO = 0.45;
|
||||
const cardBack = new Image(); cardBack.src = '/css/images/card-back.png';
|
||||
|
||||
let currentGame = null;
|
||||
let isGameCompleted = false;
|
||||
let gameStartTime = 0, completionTimeSeconds = 0;
|
||||
const currentGameType = 'SPIDER';
|
||||
let currentContextId = '';
|
||||
|
||||
let cardWidth = 0, cardHeight = 0, cardGapX = 0, cardOverlapY = 0, totalTableauWidth = 0, tableauStartX = 0;
|
||||
let isDragging = false, draggedCards = [], dragOffsetX = 0, dragOffsetY = 0;
|
||||
let completedStackCards = [], isAnimatingCompletion = false;
|
||||
|
||||
const BOTTOM_ROW_Y_RATIO = 0.9;
|
||||
let dpr = 1;
|
||||
const MAX_UNDO_COUNT = 5;
|
||||
|
||||
const cardBackImage = new Image();
|
||||
cardBackImage.src = '/css/images/card-back.png'; // 경로 확인
|
||||
let assetsLoaded = false;
|
||||
cardBackImage.onload = () => { assetsLoaded = true; resizeCanvas(); };
|
||||
|
||||
const cardDistributionOptions = {
|
||||
'1': [{ value: '4,3', text: '쉬움' }, { value: '5,4', text: '보통' }, { value: '6,5', text: '어려움' }],
|
||||
'2': [{ value: '5,4', text: '쉬움' }, { value: '6,5', text: '보통' }, { value: '7,6', text: '어려움' }],
|
||||
'4': [{ value: '6,5', text: '쉬움' }, { value: '7,6', text: '보통' }, { value: '8,7', text: '어려움' }]
|
||||
const ui = {
|
||||
start: { x: 400, y: 480, w: 200, h: 60, label: "새 게임 시작" },
|
||||
stock: { x: 880, y: 850, w: CARD_W, h: CARD_H },
|
||||
undo: { x: 50, y: 900, w: 120, h: 50, label: "실행 취소" }
|
||||
};
|
||||
let selectedSuit = 1;
|
||||
let selectedCardCount = '4,3';
|
||||
|
||||
// 2. 렌더링 함수들
|
||||
function getCssVar(varName) { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); }
|
||||
|
||||
function resizeCanvas() {
|
||||
// [수정] 윈도우가 아닌 '부모 컨테이너'를 기준으로 크기 계산
|
||||
const container = document.getElementById('game-container');
|
||||
if (!container) return;
|
||||
|
||||
// 컨테이너의 내부 너비 (패딩 제외)
|
||||
const style = getComputedStyle(container);
|
||||
const availableWidth = container.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
|
||||
// 높이는 화면 높이의 70% 정도 혹은 너비와 1:1 비율 중 작은 값 선택 (모바일/PC 대응)
|
||||
const availableHeight = window.innerHeight * 0.75;
|
||||
|
||||
const size = Math.min(availableWidth, availableHeight);
|
||||
|
||||
canvas.style.width = `${size}px`;
|
||||
canvas.style.height = `${size}px`;
|
||||
|
||||
dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = size * dpr;
|
||||
canvas.height = size * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const logicalWidth = size, logicalHeight = size;
|
||||
cardWidth = logicalWidth * CARD_WIDTH_RATIO; cardHeight = cardWidth * CARD_HEIGHT_RATIO;
|
||||
cardGapX = cardWidth * CARD_GAP_X_RATIO; cardOverlapY = cardHeight * CARD_OVERLAP_Y_RATIO;
|
||||
totalTableauWidth = cardWidth * 10 + cardGapX * 9; tableauStartX = (logicalWidth - totalTableauWidth) / 2;
|
||||
|
||||
const buttonWidth = logicalWidth * 0.2, buttonHeight = logicalHeight * 0.05, buttonGap = 10;
|
||||
const startX = (logicalWidth - (buttonWidth * 3 + buttonGap * 2)) / 2;
|
||||
const startY = logicalHeight * 0.05;
|
||||
|
||||
UI_ELEMENTS.suitSelect = { x: startX, y: startY, width: buttonWidth, height: buttonHeight };
|
||||
UI_ELEMENTS.cardCountSelect = { x: startX + buttonWidth + buttonGap, y: startY, width: buttonWidth, height: buttonHeight };
|
||||
UI_ELEMENTS.startButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY, width: buttonWidth, height: buttonHeight };
|
||||
UI_ELEMENTS.loadButton = { x: startX + buttonWidth * 2 + buttonGap * 2, y: startY + buttonHeight + buttonGap, width: buttonWidth, height: buttonHeight };
|
||||
|
||||
const bottomY = logicalHeight * BOTTOM_ROW_Y_RATIO;
|
||||
const itemSpacing = 20;
|
||||
const foundationX = logicalWidth * 0.05;
|
||||
const foundationAreaWidth = logicalWidth * FOUNDATION_WIDTH_RATIO;
|
||||
UI_ELEMENTS.foundationArea = { x: foundationX, y: bottomY, width: foundationAreaWidth, height: cardHeight };
|
||||
|
||||
const undoButtonWidth = cardWidth * 0.8, undoButtonHeight = cardHeight * 0.5;
|
||||
const undoCountDisplayWidth = cardWidth * 0.5;
|
||||
const saveButtonWidth = cardWidth * 0.8;
|
||||
const bottomControlsTotalWidth = undoButtonWidth + undoCountDisplayWidth + saveButtonWidth + (itemSpacing * 2);
|
||||
const undoButtonX = logicalWidth * 0.5 - bottomControlsTotalWidth / 2;
|
||||
|
||||
UI_ELEMENTS.undoButton = { x: undoButtonX, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoButtonWidth, height: undoButtonHeight };
|
||||
UI_ELEMENTS.undoCountDisplay = { x: undoButtonX + undoButtonWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: undoCountDisplayWidth, height: undoButtonHeight };
|
||||
UI_ELEMENTS.saveButton = { x: UI_ELEMENTS.undoCountDisplay.x + undoCountDisplayWidth + itemSpacing, y: bottomY + (cardHeight - undoButtonHeight) / 2, width: saveButtonWidth, height: undoButtonHeight };
|
||||
|
||||
const stockX = logicalWidth * 0.95 - cardWidth;
|
||||
UI_ELEMENTS.stockArea = { x: stockX, y: bottomY, width: cardWidth, height: cardHeight };
|
||||
UI_ELEMENTS.restartButton = { x: logicalWidth / 2 - buttonWidth / 2, y: logicalHeight / 2 + 50, width: buttonWidth, height: buttonHeight };
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
function draw() {
|
||||
if (!assetsLoaded) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (currentGame) drawGame(currentGame);
|
||||
drawUI();
|
||||
if (isProcessing) {
|
||||
const logicalWidth = canvas.width / dpr, logicalHeight = canvas.height / dpr;
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(0, 0, logicalWidth, logicalHeight);
|
||||
ctx.fillStyle = '#ffffff'; ctx.font = '24px Arial'; ctx.textAlign = 'center';
|
||||
ctx.fillText('처리 중...', logicalWidth / 2, logicalHeight / 2);
|
||||
}
|
||||
ctx.clearRect(0, 0, V, V);
|
||||
ctx.fillStyle = "#006633"; ctx.fillRect(0, 0, V, V); // 펠트색
|
||||
if (!currentGame) drawMenu(); else drawGame();
|
||||
}
|
||||
|
||||
function drawUI() {
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
if (!currentGame) {
|
||||
const { suitSelect, cardCountSelect, startButton, loadButton } = UI_ELEMENTS;
|
||||
|
||||
// Draw Suit Select
|
||||
ctx.fillStyle = '#f0f0f0'; ctx.fillRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height);
|
||||
ctx.strokeStyle = '#333'; ctx.strokeRect(suitSelect.x, suitSelect.y, suitSelect.width, suitSelect.height);
|
||||
ctx.fillStyle = '#000'; ctx.font = '16px Arial';
|
||||
ctx.fillText(`무늬: ${selectedSuit}개`, suitSelect.x + suitSelect.width / 2, suitSelect.y + suitSelect.height / 2);
|
||||
|
||||
// Draw Card Count Select
|
||||
ctx.fillStyle = '#f0f0f0'; ctx.fillRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height);
|
||||
ctx.strokeRect(cardCountSelect.x, cardCountSelect.y, cardCountSelect.width, cardCountSelect.height);
|
||||
ctx.fillStyle = '#000';
|
||||
const countText = cardDistributionOptions[selectedSuit.toString()].find(opt => opt.value === selectedCardCount)?.text || selectedCardCount;
|
||||
ctx.fillText(`카드: ${countText}`, cardCountSelect.x + cardCountSelect.width / 2, cardCountSelect.y + cardCountSelect.height / 2);
|
||||
|
||||
// Draw Start Button
|
||||
ctx.fillStyle = getCssVar('--color-primary') || '#4CAF50';
|
||||
ctx.fillRect(startButton.x, startButton.y, startButton.width, startButton.height);
|
||||
ctx.strokeRect(startButton.x, startButton.y, startButton.width, startButton.height);
|
||||
ctx.fillStyle = '#fff'; ctx.fillText('새 게임 시작', startButton.x + startButton.width / 2, startButton.y + startButton.height / 2);
|
||||
|
||||
// Draw Load Button
|
||||
if (localStorage.getItem(SAVED_GAME_ID_KEY)) {
|
||||
ctx.fillStyle = '#2196F3';
|
||||
ctx.fillRect(loadButton.x, loadButton.y, loadButton.width, loadButton.height);
|
||||
ctx.strokeRect(loadButton.x, loadButton.y, loadButton.width, loadButton.height);
|
||||
ctx.fillStyle = '#fff'; ctx.fillText('이어하기', loadButton.x + loadButton.width / 2, loadButton.y + loadButton.height / 2);
|
||||
}
|
||||
} else {
|
||||
const { undoButton, undoCountDisplay, saveButton } = UI_ELEMENTS;
|
||||
const isUndoPossible = currentGame.undoHistory.length > 0;
|
||||
const isUndoEnabled = currentGame.undoCount < MAX_UNDO_COUNT && isUndoPossible;
|
||||
const isSurrender = currentGame.undoCount >= MAX_UNDO_COUNT;
|
||||
|
||||
if (isUndoEnabled) {
|
||||
ctx.fillStyle = '#ff9800';
|
||||
ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height);
|
||||
ctx.strokeStyle = '#333'; ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height);
|
||||
ctx.fillStyle = '#fff'; ctx.font = '14px Arial';
|
||||
ctx.fillText('실행 취소', undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2);
|
||||
ctx.fillText(`${MAX_UNDO_COUNT - currentGame.undoCount}`, undoCountDisplay.x + undoCountDisplay.width / 2, undoCountDisplay.y + undoCountDisplay.height / 2);
|
||||
} else if (isSurrender) {
|
||||
ctx.fillStyle = '#f44336';
|
||||
ctx.fillRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height);
|
||||
ctx.strokeStyle = '#333'; ctx.strokeRect(undoButton.x, undoButton.y, undoButton.width, undoButton.height);
|
||||
ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.fillText('게임 포기', undoButton.x + undoButton.width / 2, undoButton.y + undoButton.height / 2);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#007bff';
|
||||
ctx.fillRect(saveButton.x, saveButton.y, saveButton.width, saveButton.height);
|
||||
ctx.strokeStyle = '#333'; ctx.strokeRect(saveButton.x, saveButton.y, saveButton.width, saveButton.height);
|
||||
ctx.fillStyle = '#fff'; ctx.font = '14px Arial'; ctx.fillText('게임 저장', saveButton.x + saveButton.width / 2, saveButton.y + saveButton.height / 2);
|
||||
}
|
||||
function drawMenu() {
|
||||
ctx.fillStyle = "#fff"; ctx.font = "bold 60px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText("SPIDER SOLITAIRE", V/2, 250);
|
||||
fillRoundRect(ui.start.x, ui.start.y, ui.start.w, ui.start.h, 10, "#4CAF50");
|
||||
ctx.fillStyle = "#fff"; ctx.font = "bold 24px Arial";
|
||||
ctx.fillText(ui.start.label, ui.start.x + 100, ui.start.y + 38);
|
||||
}
|
||||
|
||||
function drawGame(game) {
|
||||
drawBackground();
|
||||
drawTableau(game.tableau);
|
||||
drawStockAndFoundation(game.stock, game.foundation);
|
||||
drawDraggedCards(draggedCards);
|
||||
drawCompletionAnimation();
|
||||
}
|
||||
|
||||
function drawBackground() {
|
||||
ctx.fillStyle = getCssVar('--color-felt-green') || '#008000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function drawTableau(tableau) {
|
||||
const startY = cardHeight * 0.5;
|
||||
const draggingCards = isDragging ? new Set(draggedCards) : null;
|
||||
tableau.forEach((stack, stackIndex) => {
|
||||
stack.forEach((card, cardIndex) => {
|
||||
if (draggingCards && draggingCards.has(card)) return;
|
||||
const x = tableauStartX + stackIndex * (cardWidth + cardGapX);
|
||||
const y = startY + cardIndex * cardOverlapY;
|
||||
card.touchHeight = (cardIndex === stack.length - 1) ? cardHeight : cardOverlapY;
|
||||
drawSingleCard(card, x, y);
|
||||
function drawGame() {
|
||||
// Tableau (테이블 카드)
|
||||
currentGame.tableau.forEach((stack, sIdx) => {
|
||||
stack.forEach((card, cIdx) => {
|
||||
if (draggedCards.includes(card)) return; // 드래그 중인 카드는 나중에
|
||||
const x = 50 + sIdx*GAP_X, y = 120 + cIdx*OVER_Y;
|
||||
card.currentX = x; card.currentY = y; // 좌표 저장
|
||||
drawCard(card, x, y);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function drawDraggedCards(cards) {
|
||||
if (!isDragging || !Array.isArray(cards) || cards.length === 0) return;
|
||||
cards.forEach((card, index) => {
|
||||
const x = cards[0].x, y = cards[0].y + index * cardOverlapY;
|
||||
drawSingleCard(card, x, y);
|
||||
// Stock (덱)
|
||||
if (currentGame.stock.length > 0) ctx.drawImage(cardBack, ui.stock.x, ui.stock.y, CARD_W, CARD_H);
|
||||
|
||||
// Foundation (완성된 세트)
|
||||
currentGame.foundation.forEach((set, i) => {
|
||||
drawCard(set[set.length-1], 20 + i*35, 850);
|
||||
});
|
||||
}
|
||||
|
||||
function drawCompletionAnimation() {
|
||||
if (isAnimatingCompletion) {
|
||||
const now = Date.now();
|
||||
completedStackCards = completedStackCards.filter(card => {
|
||||
if (now < card.animEndTime) {
|
||||
const progress = (now - (card.animEndTime - 500)) / 500;
|
||||
const currentX = card.animStartX + (card.animTargetX - card.animStartX) * progress;
|
||||
const currentY = card.animStartY + (card.animTargetY - card.animStartY) * progress;
|
||||
drawSingleCard(card, currentX, currentY);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (completedStackCards.length === 0) isAnimatingCompletion = false;
|
||||
// UI 버튼
|
||||
fillRoundRect(ui.undo.x, ui.undo.y, ui.undo.w, ui.undo.h, 5, "#ff9800");
|
||||
ctx.fillStyle = "#fff"; ctx.font = "16px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText(ui.undo.label, ui.undo.x + 60, ui.undo.y + 30);
|
||||
|
||||
// 드래그 중인 카드 (최상단)
|
||||
if (draggedCards.length > 0) {
|
||||
draggedCards.forEach((c, i) => drawCard(c, c.drawX, c.drawY + i*OVER_Y));
|
||||
}
|
||||
}
|
||||
|
||||
function drawSingleCard(card, x, y) {
|
||||
card.x = x; card.y = y; card.width = cardWidth; card.height = cardHeight;
|
||||
if (card.isFaceUp) {
|
||||
ctx.fillStyle = '#ffffff'; ctx.fillRect(x, y, cardWidth, cardHeight);
|
||||
ctx.strokeStyle = '#333333'; ctx.strokeRect(x, y, cardWidth, cardHeight);
|
||||
const isRed = (card.suit === 'heart' || card.suit === 'diamond');
|
||||
ctx.fillStyle = isRed ? '#ff0000' : '#000000';
|
||||
ctx.font = `${cardWidth * 0.25}px Arial`; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
|
||||
ctx.fillText(getRankText(card.rank), x + cardWidth * CARD_RANK_LEFT_PADDING, y + cardHeight * CARD_RANK_TOP_PADDING);
|
||||
drawSuitSymbols(card, x, y);
|
||||
} else {
|
||||
ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight);
|
||||
}
|
||||
function drawCard(card, x, y) {
|
||||
if (!card.isFaceUp) { ctx.drawImage(cardBack, x, y, CARD_W, CARD_H); return; }
|
||||
fillRoundRect(x, y, CARD_W, CARD_H, 5, "#fff");
|
||||
ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.strokeRect(x, y, CARD_W, CARD_H);
|
||||
|
||||
const isRed = (card.suit === 'heart' || card.suit === 'diamond');
|
||||
ctx.fillStyle = isRed ? "#d32f2f" : "#000";
|
||||
ctx.font = "bold 18px Arial"; ctx.textAlign = "left";
|
||||
ctx.fillText(getRankStr(card.rank), x+6, y+22);
|
||||
|
||||
ctx.font = "24px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText(getSuitStr(card.suit), x+CARD_W/2, y+CARD_H/2+5);
|
||||
}
|
||||
|
||||
function drawSuitSymbols(card, x, y) {
|
||||
const symbol = getSuitSymbol(card.suit);
|
||||
// (심볼 그리기 로직은 너무 길어서 간략화함 - 기존 로직 유지)
|
||||
ctx.font = `${cardWidth * 0.6}px Arial`;
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(symbol, x + cardWidth / 2, y + cardHeight / 2);
|
||||
function getRankStr(r) { return r==1?'A':r==11?'J':r==12?'Q':r==13?'K':r; }
|
||||
function getSuitStr(s) { return {spade:'♠',heart:'♥',club:'♣',diamond:'♦'}[s]||''; }
|
||||
|
||||
// --- 게임 로직 ---
|
||||
async function start() {
|
||||
isProcessing = true;
|
||||
try {
|
||||
// 필수 파라미터 포함 요청
|
||||
currentGame = await Api.request('/puzzle/spider/new?numSuits=1&numCards=4,3');
|
||||
if(!currentGame.foundation) currentGame.foundation = [];
|
||||
if(!currentGame.moves) currentGame.moves = 0;
|
||||
draw();
|
||||
} catch(e) { console.error(e); }
|
||||
isProcessing = false;
|
||||
}
|
||||
|
||||
function drawStockAndFoundation(stock, foundation) {
|
||||
const stockArea = UI_ELEMENTS.stockArea;
|
||||
const foundationArea = UI_ELEMENTS.foundationArea;
|
||||
ctx.strokeStyle = getCssVar('--color-felt-border') || '#004d00';
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
|
||||
ctx.fillRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height);
|
||||
ctx.strokeRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height);
|
||||
foundation.forEach((stack, index) => {
|
||||
const foundationX = foundationArea.x + index * (cardWidth * FOUNDATION_CARD_SPACING);
|
||||
if (stack.length > 0) drawSingleCard(stack[stack.length - 1], foundationX, UI_ELEMENTS.foundationArea.y);
|
||||
});
|
||||
if (stock.length > 0) {
|
||||
ctx.drawImage(cardBackImage, stockArea.x, stockArea.y, cardWidth, cardHeight);
|
||||
ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight);
|
||||
const remainingDeals = Math.floor(stock.length / 10);
|
||||
ctx.fillStyle = '#fff'; ctx.font = '20px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`${remainingDeals}`, stockArea.x + cardWidth / 2, stockArea.y + cardHeight / 2);
|
||||
} else {
|
||||
ctx.strokeRect(stockArea.x, stockArea.y, cardWidth, cardHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 이벤트 핸들러
|
||||
canvas.addEventListener('mousedown', handlePointerDown);
|
||||
canvas.addEventListener('mousemove', handlePointerMove);
|
||||
canvas.addEventListener('mouseup', handlePointerUp);
|
||||
canvas.addEventListener('dblclick', handleDoubleClick);
|
||||
canvas.addEventListener('touchstart', handlePointerDown);
|
||||
canvas.addEventListener('touchmove', e => { e.preventDefault(); handlePointerMove(e); });
|
||||
canvas.addEventListener('touchend', handlePointerUp);
|
||||
|
||||
function getCanvasCoordinates(event) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height;
|
||||
let clientX, clientY;
|
||||
if (event.touches && event.touches.length > 0) { clientX = event.touches[0].clientX; clientY = event.touches[0].clientY; }
|
||||
else if (event.changedTouches && event.changedTouches.length > 0) { clientX = event.changedTouches[0].clientX; clientY = event.changedTouches[0].clientY; }
|
||||
else { clientX = event.clientX; clientY = event.clientY; }
|
||||
if (typeof clientX === 'undefined') return null;
|
||||
return { x: (clientX - rect.left) * scaleX / dpr, y: (clientY - rect.top) * scaleY / dpr };
|
||||
}
|
||||
|
||||
function findElementAt(x, y) {
|
||||
if (isGameCompleted) {
|
||||
if (isInside(x, y, UI_ELEMENTS.restartButton)) return { type: 'ui', name: 'submitButton' }; // 이름은 그대로 둠
|
||||
}
|
||||
if (currentGame) {
|
||||
if (isInside(x, y, UI_ELEMENTS.stockArea)) return { type: 'stock' };
|
||||
if (isInside(x, y, UI_ELEMENTS.undoButton)) return { type: 'ui', name: 'undoButton' };
|
||||
if (isInside(x, y, UI_ELEMENTS.saveButton)) return { type: 'ui', name: 'saveButton' };
|
||||
}
|
||||
if (!currentGame) {
|
||||
if (isInside(x, y, UI_ELEMENTS.suitSelect)) return { type: 'ui', name: 'suitSelect' };
|
||||
if (isInside(x, y, UI_ELEMENTS.cardCountSelect)) return { type: 'ui', name: 'cardCountSelect' };
|
||||
if (isInside(x, y, UI_ELEMENTS.startButton)) return { type: 'ui', name: 'startButton' };
|
||||
if (isInside(x, y, UI_ELEMENTS.loadButton) && localStorage.getItem(SAVED_GAME_ID_KEY)) return { type: 'ui', name: 'loadButton' };
|
||||
}
|
||||
if (currentGame) {
|
||||
for (let stackIndex = 9; stackIndex >= 0; stackIndex--) {
|
||||
const stackCards = currentGame.tableau[stackIndex];
|
||||
for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) {
|
||||
const card = stackCards[cardIndex];
|
||||
if (!card.isFaceUp) continue;
|
||||
if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) {
|
||||
return { type: 'card', card, stackIndex, cardIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isInside(x, y, rect) {
|
||||
return rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
||||
}
|
||||
|
||||
// 4. 게임 로직
|
||||
async function handlePointerDown(event) {
|
||||
if (isProcessing || isAnimatingCompletion) return;
|
||||
if (event.type.startsWith('touch')) event.preventDefault();
|
||||
const coords = getCanvasCoordinates(event);
|
||||
const element = findElementAt(coords.x, coords.y);
|
||||
if (!element) return;
|
||||
|
||||
if (element.type === 'ui') {
|
||||
switch (element.name) {
|
||||
case 'startButton': startNewGame(false); break;
|
||||
case 'loadButton': startNewGame(true); break;
|
||||
case 'saveButton': saveGameToServer(); break;
|
||||
case 'undoButton': await handleUndo(); break; // await 추가
|
||||
case 'submitButton': startNewGame(false); break; // 완료 후 클릭 시 새 게임
|
||||
case 'suitSelect':
|
||||
selectedSuit = (selectedSuit === 1) ? 2 : (selectedSuit === 2) ? 4 : 1;
|
||||
selectedCardCount = cardDistributionOptions[selectedSuit.toString()][0].value;
|
||||
break;
|
||||
case 'cardCountSelect':
|
||||
const opts = cardDistributionOptions[selectedSuit.toString()];
|
||||
const curIdx = opts.findIndex(o => o.value === selectedCardCount);
|
||||
selectedCardCount = opts[(curIdx + 1) % opts.length].value;
|
||||
break;
|
||||
}
|
||||
} else if (element.type === 'card' && !isGameCompleted) {
|
||||
const { card, stackIndex, cardIndex } = element;
|
||||
const movableStack = getCardStackForMove(card, stackIndex, cardIndex);
|
||||
if (movableStack && movableStack.length > 0) {
|
||||
draggedCards = movableStack;
|
||||
draggedCards.sourceStackIndex = stackIndex;
|
||||
dragOffsetX = coords.x - card.x; dragOffsetY = coords.y - card.y;
|
||||
}
|
||||
} else if (element.type === 'stock') {
|
||||
dealFromStock();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove(event) {
|
||||
if (!isDragging && draggedCards.length > 0) isDragging = true;
|
||||
if (isDragging) {
|
||||
event.preventDefault();
|
||||
const coords = getCanvasCoordinates(event);
|
||||
draggedCards[0].x = coords.x - dragOffsetX;
|
||||
draggedCards[0].y = coords.y - dragOffsetY;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(event) {
|
||||
if (!isDragging) { draggedCards = []; return; }
|
||||
const coords = getCanvasCoordinates(event);
|
||||
if (!coords) { isDragging = false; draggedCards = []; return; }
|
||||
const dropTargetStackId = findStackAt(coords.x, coords.y);
|
||||
const sourceStackIndex = draggedCards.sourceStackIndex;
|
||||
|
||||
if (dropTargetStackId) {
|
||||
const destIndex = parseInt(dropTargetStackId.split('-')[1]) - 1;
|
||||
if (isValidMove(draggedCards, destIndex)) {
|
||||
addUndoState();
|
||||
moveCardLocally(draggedCards, sourceStackIndex, destIndex);
|
||||
checkCompletedStacks();
|
||||
}
|
||||
}
|
||||
isDragging = false; draggedCards = [];
|
||||
}
|
||||
|
||||
function handleDoubleClick(event) {
|
||||
if (isProcessing || isGameCompleted) return;
|
||||
const coords = getCanvasCoordinates(event);
|
||||
const clicked = findCardAt(coords.x, coords.y);
|
||||
if (clicked) {
|
||||
const movable = getCardStackForMove(clicked.card, clicked.stackIndex, clicked.cardIndex);
|
||||
if (movable) {
|
||||
const destId = getBestMoveForStack(movable);
|
||||
if (destId) {
|
||||
addUndoState();
|
||||
moveCardLocally(movable, clicked.stackIndex, parseInt(destId.split('-')[1])-1);
|
||||
checkCompletedStacks();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
if (currentGame.undoCount >= MAX_UNDO_COUNT || currentGame.undoHistory.length === 0) {
|
||||
if (currentGame.undoCount >= MAX_UNDO_COUNT) {
|
||||
if (await UI.showConfirm("확인", '실행 취소 횟수를 모두 사용했습니다. 게임을 포기하시겠습니까?')) {
|
||||
currentGame = null;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const prevState = currentGame.undoHistory.pop();
|
||||
currentGame.tableau = prevState.tableau;
|
||||
currentGame.stock = prevState.stock;
|
||||
currentGame.foundation = prevState.foundation;
|
||||
currentGame.moves = prevState.moves;
|
||||
currentGame.undoCount++;
|
||||
}
|
||||
|
||||
// ... (dealFromStock, addUndoState, moveCardLocally, isValidMove, getCardStackForMove, findStackAt, findCardAt, getRankText, getSuitSymbol, getBestMoveForStack 함수들은 기존 로직과 동일하므로 생략하지 않고 그대로 사용) ...
|
||||
// (분량 관계상 핵심 부분만 작성합니다. 실제 파일에는 기존 spider.html의 해당 함수들을 그대로 복사해 넣으세요.)
|
||||
function dealFromStock() {
|
||||
if (currentGame.stock.length === 0 || isGameCompleted) return;
|
||||
addUndoState();
|
||||
const cardsToDeal = currentGame.stock.splice(0, 10);
|
||||
cardsToDeal.forEach((card, index) => { card.isFaceUp = true; currentGame.tableau[index].push(card); });
|
||||
// 카드 딜링
|
||||
function deal() {
|
||||
if (currentGame.stock.length === 0) return;
|
||||
saveUndoState();
|
||||
const dealCards = currentGame.stock.splice(0, 10);
|
||||
dealCards.forEach((c, i) => { c.isFaceUp = true; currentGame.tableau[i].push(c); });
|
||||
currentGame.moves++;
|
||||
checkCompletedStacks();
|
||||
checkFoundation();
|
||||
draw();
|
||||
}
|
||||
function addUndoState() {
|
||||
const stateToSave = {
|
||||
tableau: JSON.parse(JSON.stringify(currentGame.tableau)),
|
||||
stock: JSON.parse(JSON.stringify(currentGame.stock)),
|
||||
foundation: JSON.parse(JSON.stringify(currentGame.foundation)),
|
||||
moves: currentGame.moves
|
||||
};
|
||||
currentGame.undoHistory.push(stateToSave);
|
||||
if(currentGame.undoHistory.length > 10) currentGame.undoHistory.shift();
|
||||
}
|
||||
function moveCardLocally(cards, fromIndex, toIndex) {
|
||||
const sourceStack = currentGame.tableau[fromIndex];
|
||||
sourceStack.splice(sourceStack.length - cards.length, cards.length);
|
||||
currentGame.tableau[toIndex].push(...cards);
|
||||
if (sourceStack.length > 0) sourceStack[sourceStack.length-1].isFaceUp = true;
|
||||
currentGame.moves++;
|
||||
}
|
||||
function isValidMove(cardsToMove, destIndex) {
|
||||
if (cardsToMove.length === 0) return false;
|
||||
const firstCard = cardsToMove[0];
|
||||
const destStack = currentGame.tableau[destIndex];
|
||||
if (destStack.length === 0) return true;
|
||||
const destTopCard = destStack[destStack.length - 1];
|
||||
return firstCard.rank === destTopCard.rank - 1;
|
||||
}
|
||||
function getCardStackForMove(card, stackIndex, cardIndex) {
|
||||
const stack = currentGame.tableau[stackIndex];
|
||||
if (cardIndex === -1 || !card.isFaceUp) return null;
|
||||
const movableStack = [];
|
||||
for (let i = cardIndex; i < stack.length; i++) {
|
||||
if (stack[i].isFaceUp) movableStack.push(stack[i]); else break;
|
||||
}
|
||||
if (movableStack.length === 0) return null;
|
||||
for (let i = 0; i < movableStack.length - 1; i++) {
|
||||
if (movableStack[i].rank !== movableStack[i + 1].rank + 1 || movableStack[i].suit !== movableStack[i + 1].suit) return null;
|
||||
}
|
||||
return movableStack;
|
||||
}
|
||||
function findStackAt(x, y) {
|
||||
const startY = cardHeight * 0.5;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const stackX = tableauStartX + i * (cardWidth + cardGapX);
|
||||
const stackCards = currentGame.tableau[i];
|
||||
if (stackCards.length === 0) {
|
||||
if (x >= stackX && x <= stackX + cardWidth && y >= startY) return `tableau-${i + 1}`;
|
||||
} else {
|
||||
const lastCardIndex = stackCards.length - 1;
|
||||
const lastCardY = startY + lastCardIndex * cardOverlapY;
|
||||
if (x >= stackX && x <= stackX + cardWidth && y >= lastCardY) return `tableau-${i + 1}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findCardAt(x, y) {
|
||||
if (!currentGame) return null;
|
||||
for (let stackIndex = 9; stackIndex >= 0; stackIndex--) {
|
||||
const stackCards = currentGame.tableau[stackIndex];
|
||||
for (let cardIndex = stackCards.length - 1; cardIndex >= 0; cardIndex--) {
|
||||
const card = stackCards[cardIndex];
|
||||
|
||||
// 드래그 시작 판정
|
||||
function findDrag(p) {
|
||||
// 역순 탐색 (위쪽 카드부터)
|
||||
for (let sIdx=9; sIdx>=0; sIdx--) {
|
||||
const stack = currentGame.tableau[sIdx];
|
||||
for (let cIdx=stack.length-1; cIdx>=0; cIdx--) {
|
||||
const card = stack[cIdx];
|
||||
if (!card.isFaceUp) continue;
|
||||
if (x >= card.x && x <= card.x + card.width && y >= card.y && y <= card.y + card.touchHeight) return { card, stackIndex, cardIndex };
|
||||
// 카드 영역 확인
|
||||
if (p.x >= card.currentX && p.x <= card.currentX+CARD_W &&
|
||||
p.y >= card.currentY && p.y <= card.currentY+CARD_H) { // 하단 겹침 고려 단순화
|
||||
|
||||
// 이동 가능 여부 체크
|
||||
const moving = getMovableStack(stack, cIdx);
|
||||
if (moving) {
|
||||
draggedCards = moving;
|
||||
draggedCards.sIdx = sIdx;
|
||||
dragOff.x = p.x - card.currentX;
|
||||
dragOff.y = p.y - card.currentY;
|
||||
moving.forEach(c => { c.drawX = c.currentX; c.drawY = c.currentY; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function getRankText(rank) {
|
||||
if (rank === 1) return 'A'; if (rank === 11) return 'J'; if (rank === 12) return 'Q'; if (rank === 13) return 'K'; return String(rank);
|
||||
|
||||
// 규칙: 같은 무늬, 연속된 숫자만 묶음 이동 가능
|
||||
function getMovableStack(stack, cIdx) {
|
||||
const sub = stack.slice(cIdx);
|
||||
for(let i=0; i<sub.length-1; i++) {
|
||||
if (sub[i].suit !== sub[i+1].suit || sub[i].rank !== sub[i+1].rank + 1) return null;
|
||||
}
|
||||
return sub;
|
||||
}
|
||||
function getSuitSymbol(suit) {
|
||||
if (suit === 'spade') return '♠️'; if (suit === 'heart') return '♥️'; if (suit === 'club') return '♣️'; if (suit === 'diamond') return '♦️';
|
||||
}
|
||||
function getBestMoveForStack(cardsToMove) {
|
||||
if (cardsToMove.length === 0) return null;
|
||||
const firstCardToMove = cardsToMove[0];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const destStackCards = currentGame.tableau[i];
|
||||
if (destStackCards.length === 0) return `tableau-${i + 1}`;
|
||||
|
||||
// 드롭 처리
|
||||
function handleDrop(p) {
|
||||
let bestDist = 9999, targetIdx = -1;
|
||||
|
||||
// 가장 가까운 컬럼 찾기 (X축 기준)
|
||||
for (let i=0; i<10; i++) {
|
||||
const cx = 50 + i*GAP_X + CARD_W/2;
|
||||
const dist = Math.abs(p.x - cx);
|
||||
if (dist < CARD_W && dist < bestDist) { bestDist = dist; targetIdx = i; }
|
||||
}
|
||||
|
||||
if (targetIdx !== -1) {
|
||||
const destStack = currentGame.tableau[targetIdx];
|
||||
// 이동 규칙: 비어있거나, 타겟 카드가 이동할 카드보다 1 커야 함
|
||||
let valid = false;
|
||||
if (destStack.length === 0) valid = true;
|
||||
else {
|
||||
const destTopCard = destStackCards[destStackCards.length - 1];
|
||||
if (firstCardToMove.rank === destTopCard.rank - 1) return `tableau-${i + 1}`;
|
||||
const top = destStack[destStack.length-1];
|
||||
const moving = draggedCards[0];
|
||||
if (top.rank === moving.rank + 1) valid = true;
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
saveUndoState();
|
||||
const srcStack = currentGame.tableau[draggedCards.sIdx];
|
||||
srcStack.splice(srcStack.length - draggedCards.length, draggedCards.length);
|
||||
if (srcStack.length > 0) srcStack[srcStack.length-1].isFaceUp = true;
|
||||
|
||||
destStack.push(...draggedCards);
|
||||
currentGame.moves++;
|
||||
checkFoundation();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
draggedCards = [];
|
||||
}
|
||||
|
||||
function checkCompletedStacks() {
|
||||
for (let stackIndex = 0; stackIndex < currentGame.tableau.length; stackIndex++) {
|
||||
const stack = currentGame.tableau[stackIndex];
|
||||
if (stack.length < 13) continue;
|
||||
const last13Cards = stack.slice(stack.length - 13);
|
||||
let isCompleted = true;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (last13Cards[i].rank !== last13Cards[i+1].rank + 1 || last13Cards[i].suit !== last13Cards[i+1].suit) { isCompleted = false; break; }
|
||||
// 세트 완성 체크 (K...A)
|
||||
function checkFoundation() {
|
||||
currentGame.tableau.forEach(stack => {
|
||||
if (stack.length < 13) return;
|
||||
// 끝에서 13장 검사
|
||||
const suffix = stack.slice(stack.length-13);
|
||||
let isSeq = true;
|
||||
for(let i=0; i<12; i++) {
|
||||
if (!suffix[i].isFaceUp || suffix[i].suit !== suffix[i+1].suit || suffix[i].rank !== suffix[i+1].rank+1) {
|
||||
isSeq = false; break;
|
||||
}
|
||||
}
|
||||
if (isCompleted) {
|
||||
isAnimatingCompletion = true;
|
||||
const cardsToRemove = stack.slice(stack.length - 13);
|
||||
const originalStackLength = stack.length;
|
||||
cardsToRemove.forEach((card, index) => {
|
||||
const cardIndexInStack = originalStackLength - 13 + index;
|
||||
card.animStartX = tableauStartX + stackIndex * (cardWidth + cardGapX);
|
||||
card.animStartY = (cardHeight * 0.5) + cardIndexInStack * cardOverlapY;
|
||||
card.animEndTime = Date.now() + 500;
|
||||
card.animTargetX = (UI_ELEMENTS.foundationArea.x + currentGame.foundation.length * (cardWidth * FOUNDATION_CARD_SPACING));
|
||||
card.animTargetY = UI_ELEMENTS.foundationArea.y;
|
||||
completedStackCards.push(card);
|
||||
});
|
||||
stack.splice(stack.length - 13, 13);
|
||||
if (stack.length > 0) stack[stack.length - 1].isFaceUp = true;
|
||||
currentGame.foundation.push(cardsToRemove);
|
||||
if (isSeq) { // 완성!
|
||||
stack.splice(stack.length-13, 13);
|
||||
if (stack.length>0) stack[stack.length-1].isFaceUp = true;
|
||||
currentGame.foundation.push(suffix);
|
||||
|
||||
// 게임 클리어 체크
|
||||
if (currentGame.foundation.length === 8) {
|
||||
showSuccessOverlay({
|
||||
title: "SPIDER CLEAR!", scoreLabel: "MOVES", scoreValue: currentGame.moves
|
||||
});
|
||||
setTimeout(() => Game.showSuccessModal({ gameType: 'SPIDER', primaryScore: currentGame.moves }), 2500);
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalFoundationCards = currentGame.foundation.reduce((sum, stack) => sum + stack.length, 0);
|
||||
if (totalFoundationCards === 104 && !isGameCompleted) {
|
||||
isGameCompleted = true;
|
||||
completionTimeSeconds = Math.floor((Date.now() - gameStartTime) / 1000);
|
||||
|
||||
// [수정] Game 모듈 사용
|
||||
Game.showSuccessModal({
|
||||
gameType: currentGameType, contextId: currentContextId,
|
||||
successMessage: `게임 완료! (이동: ${currentGame.moves}회, 시간: ${Math.floor(completionTimeSeconds/60)}분 ${completionTimeSeconds%60}초)`,
|
||||
primaryScore: currentGame.moves, secondaryScore: completionTimeSeconds
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. 서버 통신 (Api 모듈 사용)
|
||||
async function startNewGame(loadFromSaved) {
|
||||
isProcessing = true;
|
||||
try {
|
||||
let gameData;
|
||||
if (loadFromSaved) {
|
||||
const savedId = localStorage.getItem(SAVED_GAME_ID_KEY);
|
||||
if (!savedId) throw new Error("저장된 게임이 없습니다.");
|
||||
gameData = await Api.request(`/puzzle/spider/${savedId}`);
|
||||
} else {
|
||||
const numSuits = selectedSuit, numCards = selectedCardCount;
|
||||
currentContextId = `${numSuits}_SUITS_${numCards.replace(',', '-')}`;
|
||||
// updateGameRanking('SPIDER', currentContextId); // 필요시 추가
|
||||
gameData = await Api.request(`/puzzle/spider/new?numSuits=${numSuits}&numCards=${numCards}`);
|
||||
}
|
||||
currentGame = gameData;
|
||||
if (!currentGame.undoHistory) currentGame.undoHistory = [];
|
||||
if (typeof currentGame.undoCount !== 'number') currentGame.undoCount = 0;
|
||||
isGameCompleted = false;
|
||||
gameStartTime = Date.now();
|
||||
} catch (error) {
|
||||
UI.showAlert("알림", error.message);
|
||||
currentGame = null;
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
// Undo 관련
|
||||
function saveUndoState() {
|
||||
if (!currentGame.history) currentGame.history = [];
|
||||
const state = JSON.parse(JSON.stringify({
|
||||
t: currentGame.tableau, s: currentGame.stock, f: currentGame.foundation, m: currentGame.moves
|
||||
}));
|
||||
currentGame.history.push(state);
|
||||
if (currentGame.history.length > 10) currentGame.history.shift();
|
||||
}
|
||||
|
||||
async function saveGameToServer() {
|
||||
if (!currentGame || isProcessing) return;
|
||||
isProcessing = true;
|
||||
try {
|
||||
const savedGame = await Api.request(`/puzzle/spider/update`, 'POST', currentGame);
|
||||
currentGame.id = savedGame.id;
|
||||
localStorage.setItem(SAVED_GAME_ID_KEY, currentGame.id);
|
||||
UI.showAlert("알림", "게임이 저장되었습니다.");
|
||||
} catch (error) {
|
||||
UI.showAlert("알림", "게임 저장 실패");
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
function handleUndo() {
|
||||
if (!currentGame || !currentGame.history || currentGame.history.length===0) return;
|
||||
const prev = currentGame.history.pop();
|
||||
currentGame.tableau = prev.t;
|
||||
currentGame.stock = prev.s;
|
||||
currentGame.foundation = prev.f;
|
||||
currentGame.moves = prev.m;
|
||||
}
|
||||
|
||||
resizeCanvas();
|
||||
function gameLoop() { draw(); requestAnimationFrame(gameLoop); }
|
||||
gameLoop();
|
||||
// 입력 이벤트
|
||||
canvas.addEventListener('mousedown', e => {
|
||||
const p = getCoords(e);
|
||||
if (!currentGame) { if(isInside(p, ui.start)) start(); }
|
||||
else {
|
||||
if (isInside(p, ui.stock)) deal();
|
||||
else if (isInside(p, ui.undo)) handleUndo();
|
||||
else findDrag(p);
|
||||
}
|
||||
draw();
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', e => {
|
||||
if (draggedCards.length > 0) {
|
||||
const p = getCoords(e);
|
||||
draggedCards.forEach(c => { c.drawX = p.x - dragOff.x; c.drawY = p.y - dragOff.y; });
|
||||
draw();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', e => {
|
||||
if (draggedCards.length > 0) {
|
||||
handleDrop(getCoords(e));
|
||||
draw();
|
||||
}
|
||||
});
|
||||
|
||||
cardBack.onload = () => { resize(); draw(); };
|
||||
});
|
||||
@ -1,319 +1,40 @@
|
||||
import { Api } from '../modules/api.js';
|
||||
import { Game } from '../modules/game.js';
|
||||
import { UI } from '../modules/ui.js';
|
||||
import { CommonCanvas } from '../modules/canvas_utils.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const currentGameType = 'SUDOKU';
|
||||
const V_SIZE = 600; // 논리적 고정 크기
|
||||
const CELL_SIZE = V_SIZE / 9;
|
||||
|
||||
// DOM 요소 참조
|
||||
const setupContainer = document.getElementById('setup-container');
|
||||
const gameControls = document.getElementById('game-controls-container');
|
||||
const boardEl = document.getElementById('sudoku-board');
|
||||
const timerEl = document.getElementById('timer');
|
||||
const scoreEl = document.getElementById('score');
|
||||
const numberInputButtons = document.getElementById('number-input-buttons');
|
||||
const undoBtn = document.getElementById('undo-btn');
|
||||
const hintBtn = document.getElementById('hint-btn');
|
||||
const completeBtn = document.getElementById('complete-btn');
|
||||
function drawSudoku(ctx, boardData, state) {
|
||||
ctx.clearRect(0, 0, V_SIZE, V_SIZE);
|
||||
|
||||
// 게임 상태 변수
|
||||
let currentPuzzleId, solvedPuzzle, timerInterval, secondsElapsed = 0;
|
||||
let selectedNumber = null, focusedCell = null, score = 5, history = [];
|
||||
// 1. 그리드 그리기
|
||||
for (let i = 0; i <= 9; i++) {
|
||||
ctx.lineWidth = (i % 3 === 0) ? 4 : 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i * CELL_SIZE, 0); ctx.lineTo(i * CELL_SIZE, V_SIZE);
|
||||
ctx.moveTo(0, i * CELL_SIZE); ctx.lineTo(V_SIZE, i * CELL_SIZE);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 1. 게임 시작 버튼 핸들러
|
||||
document.getElementById('start-btn').addEventListener('click', async () => {
|
||||
const diff = document.getElementById('difficulty-select').value;
|
||||
try {
|
||||
const data = await Api.request(`/puzzle/sudoku/start?difficulty=${diff}`);
|
||||
currentPuzzleId = data.puzzleId;
|
||||
solvedPuzzle = data.solution;
|
||||
history = [];
|
||||
score = 5;
|
||||
// 2. 숫자 및 하이라이트 그리기
|
||||
boardData.forEach((val, i) => {
|
||||
const r = Math.floor(i / 9);
|
||||
const c = i % 9;
|
||||
const x = c * CELL_SIZE;
|
||||
const y = r * CELL_SIZE;
|
||||
|
||||
renderBoard(data.question);
|
||||
startTimer();
|
||||
updateScore();
|
||||
updateButtonStates(); // [복구됨]
|
||||
// 선택된 셀 하이라이트
|
||||
if (state.focusedIndex === i) {
|
||||
ctx.fillStyle = 'rgba(0, 123, 255, 0.2)';
|
||||
ctx.fillRect(x, y, CELL_SIZE, CELL_SIZE);
|
||||
}
|
||||
|
||||
setupContainer.classList.add('hidden');
|
||||
boardEl.classList.remove('hidden');
|
||||
gameControls.classList.remove('hidden');
|
||||
} catch (e) {
|
||||
UI.showAlert("오류", "게임 로딩 실패: " + e.message);
|
||||
// 숫자 렌더링
|
||||
if (val !== 0) {
|
||||
ctx.fillStyle = state.isEditable[i] ? '#007bff' : '#000';
|
||||
ctx.font = `bold ${CELL_SIZE * 0.6}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(val, x + CELL_SIZE/2, y + CELL_SIZE/2);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 보드 렌더링 함수
|
||||
function renderBoard(str) {
|
||||
boardEl.innerHTML = '';
|
||||
for (let i = 0; i < 81; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cell';
|
||||
cell.dataset.index = i;
|
||||
if (str[i] !== '0') {
|
||||
cell.textContent = str[i];
|
||||
} else {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 타이머 함수
|
||||
function startTimer() {
|
||||
secondsElapsed = 0;
|
||||
timerEl.textContent = '00:00';
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = setInterval(() => {
|
||||
secondsElapsed++;
|
||||
const m = Math.floor(secondsElapsed / 60).toString().padStart(2,'0');
|
||||
const s = (secondsElapsed % 60).toString().padStart(2,'0');
|
||||
timerEl.textContent = `${m}:${s}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 4. 점수 업데이트 함수
|
||||
function updateScore() {
|
||||
scoreEl.textContent = `SCORE: ${score}`;
|
||||
if (score <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
UI.showAlert("게임 오버", "포인트가 소진되었습니다.");
|
||||
resetGame();
|
||||
}
|
||||
}
|
||||
|
||||
// 5. [복구됨] 숫자 버튼 상태 업데이트 (9개 다 채우면 비활성화)
|
||||
function updateButtonStates() {
|
||||
const counts = {};
|
||||
for (let i = 1; i <= 9; i++) counts[i] = 0;
|
||||
|
||||
// 현재 보드에 있는 숫자 카운트
|
||||
boardEl.querySelectorAll('.cell').forEach(cell => {
|
||||
const num = cell.textContent;
|
||||
if (num && counts[num] !== undefined) counts[num]++;
|
||||
});
|
||||
|
||||
// 버튼 스타일 적용
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const btn = numberInputButtons.querySelector(`.num-btn[data-number="${i}"]`);
|
||||
if (btn) {
|
||||
if (counts[i] >= 9) {
|
||||
btn.classList.add('completed');
|
||||
// 만약 현재 선택된 숫자가 완료된 숫자라면 선택 해제
|
||||
if (selectedNumber == i) {
|
||||
selectedNumber = null;
|
||||
btn.classList.remove('selected');
|
||||
}
|
||||
} else {
|
||||
btn.classList.remove('completed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. [복구됨] 숫자 버튼 클릭 핸들러
|
||||
numberInputButtons.addEventListener('click', (event) => {
|
||||
const target = event.target.closest('button');
|
||||
if (!target) return;
|
||||
|
||||
if (target === undoBtn) {
|
||||
undoAction();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.classList.contains('completed')) return;
|
||||
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
|
||||
|
||||
if (target.classList.contains('num-btn')) {
|
||||
const num = target.dataset.number;
|
||||
// 이미 선택된 숫자면 해제, 아니면 선택
|
||||
selectedNumber = (selectedNumber === num) ? null : num;
|
||||
if (selectedNumber) target.classList.add('selected');
|
||||
}
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
// 7. [복구됨] 보드 셀 클릭 핸들러
|
||||
boardEl.addEventListener('click', (event) => {
|
||||
const targetCell = event.target.closest('.cell.editable');
|
||||
|
||||
// 빈 곳이나 편집 불가능한 셀 클릭 시 포커스 해제
|
||||
if (!targetCell) {
|
||||
if (focusedCell) focusedCell = null;
|
||||
highlightCells();
|
||||
return;
|
||||
}
|
||||
|
||||
focusedCell = targetCell;
|
||||
|
||||
// 숫자가 선택된 상태라면 해당 숫자를 입력
|
||||
if (selectedNumber) {
|
||||
const previousValue = targetCell.textContent;
|
||||
// 같은 숫자를 다시 누르면 지우기(toggle)
|
||||
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
|
||||
|
||||
targetCell.textContent = newValue;
|
||||
recordAction(targetCell, previousValue, newValue);
|
||||
validateCell(targetCell);
|
||||
updateButtonStates();
|
||||
checkIfBoardIsFull();
|
||||
}
|
||||
highlightCells();
|
||||
});
|
||||
|
||||
// 8. [복구됨] 힌트 버튼 핸들러
|
||||
hintBtn.addEventListener('click', () => {
|
||||
if (score <= 0) return;
|
||||
|
||||
const emptyCells = Array.from(boardEl.querySelectorAll('.cell.editable'))
|
||||
.filter(cell => !cell.textContent);
|
||||
|
||||
if (emptyCells.length === 0) {
|
||||
return UI.showAlert("알림", '빈 칸이 없습니다.');
|
||||
}
|
||||
|
||||
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
||||
const cellIndex = parseInt(randomCell.dataset.index);
|
||||
const correctAnswer = solvedPuzzle[cellIndex];
|
||||
const previousValue = randomCell.textContent;
|
||||
|
||||
score--;
|
||||
updateScore();
|
||||
|
||||
recordAction(randomCell, previousValue, correctAnswer, true);
|
||||
randomCell.textContent = correctAnswer;
|
||||
// 힌트로 채워진 셀은 더 이상 수정 불가 및 정답 처리
|
||||
randomCell.classList.remove('editable', 'incorrect');
|
||||
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
checkIfBoardIsFull();
|
||||
});
|
||||
|
||||
// 9. [복구됨] 되돌리기 (Undo)
|
||||
function undoAction() {
|
||||
if (history.length === 0) return;
|
||||
const lastAction = history.pop();
|
||||
const cell = boardEl.querySelector(`.cell[data-index="${lastAction.index}"]`);
|
||||
|
||||
if (cell) {
|
||||
cell.textContent = lastAction.previousValue;
|
||||
if (lastAction.wasHint) {
|
||||
cell.classList.add('editable');
|
||||
}
|
||||
validateCell(cell, false); // 되돌리기 시에는 점수 차감 안 함
|
||||
updateButtonStates();
|
||||
highlightCells();
|
||||
}
|
||||
}
|
||||
|
||||
// 10. [복구됨] 셀 검증 (오답 체크)
|
||||
function validateCell(cell, deductPoint = true) {
|
||||
if (!cell.textContent) {
|
||||
cell.classList.remove('incorrect');
|
||||
return;
|
||||
}
|
||||
const cellIndex = parseInt(cell.dataset.index);
|
||||
const isCorrect = (cell.textContent === solvedPuzzle[cellIndex]);
|
||||
|
||||
if (!isCorrect) {
|
||||
cell.classList.add('incorrect');
|
||||
if (deductPoint && score > 0) {
|
||||
score--;
|
||||
updateScore();
|
||||
}
|
||||
} else {
|
||||
cell.classList.remove('incorrect');
|
||||
}
|
||||
}
|
||||
|
||||
// 11. [복구됨] 하이라이트 (포커스, 같은 숫자 등)
|
||||
function highlightCells() {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
|
||||
// 포커스된 셀 하이라이트
|
||||
if (focusedCell) {
|
||||
focusedCell.classList.add('highlight-focused');
|
||||
const focusedValue = focusedCell.textContent;
|
||||
if (focusedValue) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === focusedValue) cell.classList.add('highlight-same-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 선택된 숫자 하이라이트
|
||||
if (selectedNumber) {
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
if (cell.textContent === selectedNumber) cell.classList.add('highlight-selected-number');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 12. [복구됨] 모든 칸이 찼는지 확인
|
||||
function checkIfBoardIsFull() {
|
||||
const emptyEditableCells = boardEl.querySelector('.cell.editable:empty');
|
||||
if (!emptyEditableCells) {
|
||||
// 빈 칸이 없으면 자동으로 정답 확인
|
||||
checkSolution();
|
||||
}
|
||||
}
|
||||
|
||||
// 13. 정답 확인 및 게임 완료 처리
|
||||
async function checkSolution() {
|
||||
let answer = "";
|
||||
boardEl.childNodes.forEach(c => answer += c.textContent || '0');
|
||||
|
||||
if (answer.includes('0')) {
|
||||
return UI.showAlert("알림", "모든 칸을 채워주세요.");
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await Api.request('/puzzle/sudoku/validate', 'POST', {
|
||||
puzzleId: currentPuzzleId,
|
||||
answer: answer
|
||||
});
|
||||
|
||||
if (res.correct) {
|
||||
clearInterval(timerInterval);
|
||||
Game.showSuccessModal({
|
||||
gameType: currentGameType,
|
||||
contextId: currentPuzzleId,
|
||||
successMessage: `성공! 기록: ${Math.floor(secondsElapsed/60)}분 ${secondsElapsed%60}초`,
|
||||
primaryScore: secondsElapsed
|
||||
});
|
||||
resetGame();
|
||||
} else {
|
||||
UI.showAlert("실패", "틀린 부분이 있습니다.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 정답 확인 버튼
|
||||
completeBtn.addEventListener('click', checkSolution);
|
||||
|
||||
// 14. 유틸리티: 액션 기록
|
||||
function recordAction(cell, previousValue, newValue, wasHint = false) {
|
||||
history.push({ index: cell.dataset.index, previousValue, newValue, wasHint });
|
||||
}
|
||||
|
||||
// 15. 게임 리셋 (초기 화면으로)
|
||||
function resetGame() {
|
||||
setupContainer.classList.remove('hidden');
|
||||
boardEl.classList.add('hidden');
|
||||
gameControls.classList.add('hidden');
|
||||
clearInterval(timerInterval);
|
||||
selectedNumber = null;
|
||||
focusedCell = null;
|
||||
|
||||
document.querySelectorAll('.cell').forEach(cell => {
|
||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||
});
|
||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected', 'completed'));
|
||||
}
|
||||
});
|
||||
}
|
||||
203
src/main/resources/static/js/pages/game_sudoku_canvas.js
Normal file
203
src/main/resources/static/js/pages/game_sudoku_canvas.js
Normal file
@ -0,0 +1,203 @@
|
||||
import { Api } from '../modules/api.js';
|
||||
import { Game } from '../modules/game.js';
|
||||
import { UI } from '../modules/ui.js';
|
||||
import { CommonCanvas } from '../modules/canvas_utils.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canvas = document.getElementById('sudokuCanvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// 설정: 600x820 (보드 600 + 하단 UI 220)
|
||||
const V_W = 600, V_H = 820, CELL = 600/9;
|
||||
const common = CommonCanvas.init(canvas, V_W, V_H);
|
||||
const { ctx, resize, getCoords, isInside, showSuccessOverlay, fillRoundRect } = common;
|
||||
|
||||
let gameState = 'START';
|
||||
let board = [], solution = [], isEditable = [], isIncorrect = [];
|
||||
let history = []; // 실행 취소 스택
|
||||
let selectedNumber = null;
|
||||
let lives = 5, seconds = 0, timerInterval = null, currentPuzzleId = null;
|
||||
|
||||
const layout = {
|
||||
menu: {
|
||||
easy: { x: 100, y: 320, w: 400, h: 60, label: "쉬움 (Easy)" },
|
||||
medium: { x: 100, y: 400, w: 400, h: 60, label: "보통 (Medium)" },
|
||||
hard: { x: 100, y: 480, w: 400, h: 60, label: "어려움 (Hard)" }
|
||||
},
|
||||
controls: {
|
||||
undo: { x: 20, y: 610, w: 100, h: 40, label: "↺ 취소" },
|
||||
hint: { x: 140, y: 610, w: 100, h: 40, label: "💡 힌트" },
|
||||
memo: { x: 480, y: 610, w: 100, h: 40, label: "SCORE" } // 점수 표시용
|
||||
},
|
||||
numPadY: 680, numBtnW: V_W / 9
|
||||
};
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, V_W, V_H);
|
||||
ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, V_W, V_H);
|
||||
|
||||
if (gameState === 'START') drawMenu();
|
||||
else drawGame();
|
||||
}
|
||||
|
||||
function drawMenu() {
|
||||
ctx.fillStyle = "#333"; ctx.font = "bold 50px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText("SUDOKU", V_W/2, 220);
|
||||
Object.values(layout.menu).forEach(btn => {
|
||||
fillRoundRect(btn.x, btn.y, btn.w, btn.h, 10, "#4a90e2");
|
||||
ctx.fillStyle = "#fff"; ctx.font = "bold 24px Arial";
|
||||
ctx.fillText(btn.label, btn.x + btn.w/2, btn.y + btn.h/2 + 8);
|
||||
});
|
||||
}
|
||||
|
||||
function drawGame() {
|
||||
// 1. 보드
|
||||
const counts = getNumberCounts();
|
||||
board.forEach((val, i) => {
|
||||
const r = Math.floor(i/9), c = i%9, x = c*CELL, y = r*CELL;
|
||||
// 배경
|
||||
if (isIncorrect[i]) { ctx.fillStyle = "#ffe3e3"; ctx.fillRect(x,y,CELL,CELL); }
|
||||
else if (selectedNumber && val === selectedNumber) { ctx.fillStyle = "#e2f0ff"; ctx.fillRect(x,y,CELL,CELL); }
|
||||
|
||||
// 격자
|
||||
ctx.strokeStyle = "#ddd"; ctx.lineWidth = 1; ctx.strokeRect(x,y,CELL,CELL);
|
||||
|
||||
// 숫자
|
||||
if (val !== 0) {
|
||||
ctx.fillStyle = isIncorrect[i] ? "#e03131" : (isEditable[i] ? "#4a90e2" : "#333");
|
||||
ctx.font = `bold ${CELL*0.6}px Arial`; ctx.textAlign = "center";
|
||||
ctx.fillText(val, x+CELL/2, y+CELL/2+10);
|
||||
}
|
||||
});
|
||||
|
||||
// 3x3 굵은 선
|
||||
ctx.strokeStyle = "#333"; ctx.lineWidth = 3;
|
||||
for(let i=0; i<=9; i+=3) {
|
||||
ctx.beginPath(); ctx.moveTo(i*CELL, 0); ctx.lineTo(i*CELL, 600); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, i*CELL); ctx.lineTo(600, i*CELL); ctx.stroke();
|
||||
}
|
||||
|
||||
// 2. 컨트롤 버튼 (Undo, Hint)
|
||||
[layout.controls.undo, layout.controls.hint].forEach(btn => {
|
||||
fillRoundRect(btn.x, btn.y, btn.w, btn.h, 5, "#6c757d");
|
||||
ctx.fillStyle = "#fff"; ctx.font = "16px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText(btn.label, btn.x+btn.w/2, btn.y+btn.h/2+6);
|
||||
});
|
||||
// 점수/생명 표시
|
||||
ctx.fillStyle = "#333"; ctx.font = "20px Arial"; ctx.textAlign = "right";
|
||||
ctx.fillText(`LIFE: ${lives} TIME: ${seconds}s`, V_W-20, 635);
|
||||
|
||||
// 3. 숫자 패드
|
||||
for(let i=1; i<=9; i++) {
|
||||
const bx = (i-1)*layout.numBtnW;
|
||||
const isDone = counts[i] >= 9;
|
||||
const bgColor = isDone ? "#e0e0e0" : (selectedNumber == i ? "#4a90e2" : "#f8f9fa");
|
||||
const textColor = isDone ? "#aaa" : (selectedNumber == i ? "#fff" : "#333");
|
||||
|
||||
fillRoundRect(bx+4, layout.numPadY, layout.numBtnW-8, 80, 8, bgColor);
|
||||
ctx.fillStyle = textColor; ctx.font = "bold 30px Arial"; ctx.textAlign = "center";
|
||||
ctx.fillText(i, bx+layout.numBtnW/2, layout.numPadY+50);
|
||||
}
|
||||
}
|
||||
|
||||
function getNumberCounts() {
|
||||
const counts = {}; for(let i=1; i<=9; i++) counts[i]=0;
|
||||
board.forEach((v, i) => { if(v!==0 && !isIncorrect[i]) counts[v]++; });
|
||||
return counts;
|
||||
}
|
||||
|
||||
// --- 게임 로직 ---
|
||||
async function startGame(diff) {
|
||||
try {
|
||||
const data = await Api.request(`/puzzle/sudoku/start?difficulty=${diff}`);
|
||||
board = data.question.split('').map(Number);
|
||||
solution = data.solution.split('').map(Number);
|
||||
isEditable = board.map(v => v === 0);
|
||||
isIncorrect = Array(81).fill(false);
|
||||
currentPuzzleId = data.puzzleId;
|
||||
history = []; lives = 5; seconds = 0; gameState = 'PLAYING';
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
timerInterval = setInterval(() => { seconds++; draw(); }, 1000);
|
||||
draw();
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
function fillCell(idx, num) {
|
||||
if (!isEditable[idx]) return;
|
||||
const prev = board[idx];
|
||||
if (prev === num) return; // 변경 없음
|
||||
|
||||
// 히스토리 저장
|
||||
history.push({ idx, prev, wasIncorrect: isIncorrect[idx] });
|
||||
|
||||
board[idx] = num;
|
||||
|
||||
if (solution[idx] === num) {
|
||||
isIncorrect[idx] = false;
|
||||
// 숫자 9개 다 찼으면 선택 해제
|
||||
if (getNumberCounts()[num] >= 9) selectedNumber = null;
|
||||
checkWin();
|
||||
} else {
|
||||
isIncorrect[idx] = true;
|
||||
lives--;
|
||||
if (lives <= 0) {
|
||||
alert("Game Over"); location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleUndo() {
|
||||
if (history.length === 0) return;
|
||||
const last = history.pop();
|
||||
board[last.idx] = last.prev;
|
||||
isIncorrect[last.idx] = last.wasIncorrect;
|
||||
}
|
||||
|
||||
function handleHint() {
|
||||
const emptyIndices = board.map((v, i) => v === 0 || isIncorrect[i] ? i : -1).filter(i => i !== -1);
|
||||
if (emptyIndices.length === 0) return;
|
||||
|
||||
const idx = emptyIndices[Math.floor(Math.random() * emptyIndices.length)];
|
||||
// 힌트는 히스토리 없이 정답 처리
|
||||
board[idx] = solution[idx];
|
||||
isIncorrect[idx] = false;
|
||||
// 힌트 사용 시 점수나 생명 차감 등 추가 가능
|
||||
checkWin();
|
||||
}
|
||||
|
||||
function checkWin() {
|
||||
if (board.every((v, i) => v === solution[i])) {
|
||||
clearInterval(timerInterval);
|
||||
showSuccessOverlay({ title: "SUDOKU CLEAR!", scoreLabel: "LIFE", scoreValue: lives, timeValue: seconds });
|
||||
setTimeout(() => Game.showSuccessModal({
|
||||
gameType: 'SUDOKU', contextId: currentPuzzleId, primaryScore: seconds
|
||||
}), 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 입력 핸들러 ---
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
const p = getCoords(e);
|
||||
if (gameState === 'START') {
|
||||
for(const [k, btn] of Object.entries(layout.menu)) if(isInside(p, btn)) startGame(k);
|
||||
} else {
|
||||
// 컨트롤 버튼
|
||||
if (isInside(p, layout.controls.undo)) { handleUndo(); draw(); return; }
|
||||
if (isInside(p, layout.controls.hint)) { handleHint(); draw(); return; }
|
||||
|
||||
// 숫자 패드
|
||||
if (p.y > layout.numPadY) {
|
||||
const num = Math.floor(p.x / layout.numBtnW) + 1;
|
||||
if (getNumberCounts()[num] < 9) selectedNumber = (selectedNumber == num) ? null : num;
|
||||
}
|
||||
// 보드 클릭
|
||||
else if (p.y < 600 && selectedNumber) {
|
||||
const c = Math.floor(p.x/CELL), r = Math.floor(p.y/CELL);
|
||||
if (c>=0 && c<9 && r>=0 && r<9) fillCell(r*9+c, parseInt(selectedNumber));
|
||||
}
|
||||
}
|
||||
draw();
|
||||
});
|
||||
|
||||
resize(); draw();
|
||||
});
|
||||
@ -35,49 +35,82 @@
|
||||
|
||||
<section class="wrapper style1">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div id="content_inner">
|
||||
<article>
|
||||
<section th:each="post , iterStat : ${Posts}">
|
||||
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
|
||||
<span class="image left">
|
||||
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}"
|
||||
alt="Post Thumbnail"
|
||||
th:onerror="|this.onerror=null; this.src='@{/images/bum.png}';|" />
|
||||
</span>
|
||||
<article id="feed-container">
|
||||
<section th:each="item, iterStat : ${feedItems}" class="feed-item">
|
||||
|
||||
<div class="inner">
|
||||
<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>
|
||||
</div>
|
||||
<th:block th:if="${iterStat.count % 3 == 0}">
|
||||
<section>
|
||||
<div class="box ad-container" style="padding: 2em; text-align: center;">
|
||||
<p style="margin-bottom: 1em; color: #888;">- Advertisement -</p>
|
||||
<ins class="adsbygoogle"
|
||||
style="display:block"
|
||||
data-ad-client="ca-pub-9504446465764716"
|
||||
data-ad-slot="5334609005"
|
||||
data-ad-format="auto"
|
||||
data-full-width-responsive="true"></ins>
|
||||
<script>
|
||||
(adsbygoogle = window.adsbygoogle || []).push({});
|
||||
</script>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</section>
|
||||
</article>
|
||||
<div th:if="${item.type.name() == 'POST'}" class="box post"
|
||||
th:onclick="|location.href='@{${item.url}}'|" style="cursor: pointer;">
|
||||
<span class="image left">
|
||||
<img th:if="${item.thumbnail}" th:src="${apiBaseUrl + item.thumbnail}"
|
||||
alt="Thumbnail" th:onerror="|this.onerror=null; this.src='@{/images/pic01.jpg}';|" />
|
||||
<img th:unless="${item.thumbnail}" th:src="@{/images/pic01.jpg}" alt="Default Thumbnail" />
|
||||
</span>
|
||||
<div class="inner">
|
||||
<h3 th:text="${item.title}">제목</h3>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;"
|
||||
th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd HH:mm')} + ' by ' + ${item.writer}"></p>
|
||||
<p th:text="${#strings.abbreviate(item.content, 150)}">내용 요약</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:if="${item.type.name() == 'GIBBERISH'}" class="box post gibberish-card"
|
||||
th:onclick="|location.href='@{${item.url}}'|"
|
||||
style="cursor: pointer; background-color: #fff9c4; border-left: 5px solid #fbc02d;">
|
||||
<div class="inner">
|
||||
<blockquote>
|
||||
<i class="icon fa-quote-left" style="color:#fbc02d; margin-right:10px;"></i>
|
||||
<span th:text="${item.content}" style="font-size: 1.1em; font-weight: bold; color: #333;"></span>
|
||||
<i class="icon fa-quote-right" style="color:#fbc02d; margin-left:10px;"></i>
|
||||
</blockquote>
|
||||
<p style="text-align: right; font-size: 0.8em; color: #777; margin-top: 10px;"
|
||||
th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd HH:mm')}"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:if="${item.type.name() == 'BOOKMARK'}" class="box post bookmark-card"
|
||||
style="border: 1px dashed #3498db;">
|
||||
<div class="inner" style="display: flex; align-items: center;">
|
||||
<div style="flex-shrink: 0; margin-right: 20px;" th:if="${item.thumbnail}">
|
||||
<img th:src="${apiBaseUrl + item.thumbnail}"
|
||||
style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;" />
|
||||
</div>
|
||||
<div style="flex-grow: 1;">
|
||||
<h4>
|
||||
<a th:href="${item.url}" target="_blank" style="text-decoration: none; color: #3498db;">
|
||||
<i class="icon solid fa-bookmark"></i> <span th:text="${item.title}">북마크 제목</span>
|
||||
<i class="icon solid fa-external-link-alt" style="font-size: 0.7em;"></i>
|
||||
</a>
|
||||
</h4>
|
||||
<p th:if="${item.content}" th:text="${item.content}" style="font-size: 0.9em; color: #666; margin-bottom: 0.5em;"></p>
|
||||
<p style="font-size: 0.8em; color: #999;">
|
||||
Saved on <span th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd')}"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div th:if="${iterStat.count % 3 == 0}" class="box ad-container" style="padding: 1em; text-align: center; margin-bottom: 2em; background: #f4f4f4;">
|
||||
<span style="font-size: 0.8em; color: #aaa;">- Advertisement -</span>
|
||||
<ins class="adsbygoogle"
|
||||
style="display:block"
|
||||
data-ad-client="ca-pub-9504446465764716"
|
||||
data-ad-slot="5334609005"
|
||||
data-ad-format="auto"
|
||||
data-full-width-responsive="true"></ins>
|
||||
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<div id="load-more-container" style="text-align: center; margin-top: 2em; margin-bottom: 2em;">
|
||||
<button id="btn-load-more" class="button alt"
|
||||
th:if="${nextCursor != null}"
|
||||
th:data-cursor="${nextCursor}"
|
||||
onclick="loadMoreFeed()">
|
||||
More Stories <i class="icon solid fa-chevron-down"></i>
|
||||
</button>
|
||||
<div id="loading-spinner" style="display:none;">
|
||||
<i class="icon solid fa-spinner fa-spin fa-2x"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -131,5 +164,113 @@
|
||||
</header>
|
||||
</div>
|
||||
</section>
|
||||
<script th:inline="javascript">
|
||||
const apiBaseUrl = /*[[${apiBaseUrl}]]*/ ''; // Thymeleaf 변수 바인딩
|
||||
|
||||
function loadMoreFeed() {
|
||||
const btn = document.getElementById('btn-load-more');
|
||||
const spinner = document.getElementById('loading-spinner');
|
||||
const container = document.getElementById('feed-container');
|
||||
|
||||
// 현재 커서 값 가져오기
|
||||
const cursor = btn.getAttribute('data-cursor');
|
||||
if (!cursor) return;
|
||||
|
||||
// UI 상태 변경 (로딩 중)
|
||||
btn.style.display = 'none';
|
||||
spinner.style.display = 'inline-block';
|
||||
|
||||
// API 호출
|
||||
fetch(`/api/feed?cursor=${cursor}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// 데이터가 없으면 버튼 숨기고 종료
|
||||
if (!data.items || data.items.length === 0) {
|
||||
spinner.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// 받아온 데이터를 HTML로 변환하여 추가
|
||||
data.items.forEach(item => {
|
||||
const html = createFeedItemHtml(item);
|
||||
// section 태그로 감싸서 추가
|
||||
const section = document.createElement('section');
|
||||
section.className = 'feed-item';
|
||||
section.innerHTML = html;
|
||||
container.appendChild(section);
|
||||
});
|
||||
|
||||
// 다음 커서 업데이트
|
||||
if (data.nextCursor) {
|
||||
btn.setAttribute('data-cursor', data.nextCursor);
|
||||
btn.style.display = 'inline-block';
|
||||
} else {
|
||||
// 더 이상 불러올 게 없으면 버튼 제거
|
||||
btn.remove();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Feed load error:', err);
|
||||
alert('추가 콘텐츠를 불러오는 중 오류가 발생했습니다.');
|
||||
btn.style.display = 'inline-block';
|
||||
})
|
||||
.finally(() => {
|
||||
spinner.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// JSON 데이터를 HTML 문자열로 변환하는 헬퍼 함수
|
||||
function createFeedItemHtml(item) {
|
||||
const dateStr = new Date(item.createdAt).toISOString().split('T')[0];
|
||||
|
||||
if (item.type === 'POST') {
|
||||
const thumbSrc = item.thumbnail ? (apiBaseUrl + item.thumbnail) : '/images/pic01.jpg';
|
||||
return `
|
||||
<div class="box post" onclick="location.href='${item.url}'" style="cursor: pointer;">
|
||||
<span class="image left"><img src="${thumbSrc}" onerror="this.onerror=null; this.src='/images/pic01.jpg';" /></span>
|
||||
<div class="inner">
|
||||
<h3>${item.title || 'Untitled'}</h3>
|
||||
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;">${dateStr} by ${item.writer || 'Bum'}</p>
|
||||
<p>${item.content ? item.content.substring(0, 150) + '...' : ''}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
else if (item.type === 'GIBBERISH') {
|
||||
return `
|
||||
<div class="box post gibberish-card" onclick="location.href='${item.url}'"
|
||||
style="cursor: pointer; background-color: #fff9c4; border-left: 5px solid #fbc02d;">
|
||||
<div class="inner">
|
||||
<blockquote>
|
||||
<i class="icon fa-quote-left" style="color:#fbc02d; margin-right:10px;"></i>
|
||||
<span style="font-size: 1.1em; font-weight: bold; color: #333;">${item.content}</span>
|
||||
</blockquote>
|
||||
<p style="text-align: right; font-size: 0.8em; color: #777;">${dateStr}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
else if (item.type === 'BOOKMARK') {
|
||||
const thumbImg = item.thumbnail ?
|
||||
`<div style="flex-shrink: 0; margin-right: 20px;"><img src="${apiBaseUrl + item.thumbnail}" style="width: 100px; height: 100px; object-fit: cover; border-radius: 5px;" /></div>` : '';
|
||||
|
||||
return `
|
||||
<div class="box post bookmark-card" style="border: 1px dashed #3498db;">
|
||||
<div class="inner" style="display: flex; align-items: center;">
|
||||
${thumbImg}
|
||||
<div style="flex-grow: 1;">
|
||||
<h4>
|
||||
<a href="${item.url}" target="_blank" style="text-decoration: none; color: #3498db;">
|
||||
<i class="icon solid fa-bookmark"></i> ${item.title}
|
||||
<i class="icon solid fa-external-link-alt" style="font-size: 0.7em;"></i>
|
||||
</a>
|
||||
</h4>
|
||||
<p style="font-size: 0.9em; color: #666; margin-bottom: 0.5em;">${item.content || ''}</p>
|
||||
<p style="font-size: 0.8em; color: #999;">Saved on ${dateStr}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
</script>
|
||||
</th:block>
|
||||
</html>
|
||||
@ -9,23 +9,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div class="game-body-wrapper">
|
||||
<h1>2048 Puzzle</h1>
|
||||
<p>화살표나 터치로 타일을 합쳐 2048을 만드세요!</p>
|
||||
<div class="game-play-box">
|
||||
<div class="score-container score-board">
|
||||
<div>SCORE: <span id="score">0</span></div>
|
||||
</div>
|
||||
<div id="game-board"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div th:replace="~{fragments/game_template :: gameBody('2048', 'game2048Canvas')}"></div>
|
||||
|
||||
<script type="module" th:src="@{/js/pages/game_2048.js}"></script>
|
||||
|
||||
<div class="container" style="text-align:center;">
|
||||
<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-9504446465764716" data-ad-slot="5334609005" data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
||||
</div>
|
||||
<script type="module" th:src="@{/js/pages/game_2048_canvas.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,36 +1,14 @@
|
||||
<!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" id="head">
|
||||
</th:block >
|
||||
|
||||
<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="content">
|
||||
<div class="game-body-wrapper">
|
||||
<h1>Nonogram Logic</h1>
|
||||
<div class="game-play-box wide">
|
||||
<div id="game-controls" style="margin: 0 0 20px 0; width:100%; display:flex; justify-content:space-between;">
|
||||
<div id="mode-selector">
|
||||
<label><input type="radio" name="play-mode" value="fill" checked><span>Fill</span></label>
|
||||
<label><input type="radio" name="play-mode" value="mark"><span>Mark</span></label>
|
||||
</div>
|
||||
<div id="points-info" class="score-board">❤️ <span id="points-display">5</span></div>
|
||||
<button id="hint-btn">Hint</button>
|
||||
</div>
|
||||
<div id="board-viewport">
|
||||
<div id="game-board"></div>
|
||||
<img id="grayscale-reveal" class="reveal-img" src="" alt="">
|
||||
<img id="original-reveal" class="reveal-img" src="" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
window.puzzleData = /*[[${puzzle}]]*/ null;
|
||||
window.puzzleData = [[${puzzle}]];
|
||||
</script>
|
||||
<script type="module" th:src="@{/js/pages/game_nonogram.js}"></script>
|
||||
<div th:replace="~{fragments/game_template :: gameBody('nonogram', 'nonogramCanvas')}"></div>
|
||||
|
||||
<script type="module" th:src="@{/js/pages/game_nonogram_canvas.js}"></script>
|
||||
</th:block>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -8,12 +8,8 @@
|
||||
<th:block layout:fragment="head" id="head">
|
||||
</th:block >
|
||||
<th:block layout:fragment="content">
|
||||
<div class="game-body-wrapper">
|
||||
<h1>Spider Solitaire</h1>
|
||||
<div class="game-play-box wide" id="game-container">
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div th:replace="~{fragments/game_template :: gameBody('Spider', 'spiderCanvas')}"></div>
|
||||
|
||||
<script type="module" th:src="@{/js/pages/game_spider.js}"></script>
|
||||
</th:block>
|
||||
</html>
|
||||
@ -9,46 +9,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div class="game-body-wrapper">
|
||||
<h1>Sudoku Daily</h1>
|
||||
<div class="game-play-box">
|
||||
<div id="board-area">
|
||||
<div id="setup-container">
|
||||
<select id="difficulty-select">
|
||||
<option value="easy">쉬움</option>
|
||||
<option value="medium">중간</option>
|
||||
<option value="hard">어려움</option>
|
||||
</select>
|
||||
<button id="start-btn">게임 시작</button>
|
||||
</div>
|
||||
<div id="sudoku-board" class="hidden"></div>
|
||||
</div>
|
||||
<div th:replace="~{fragments/game_template :: gameBody('Sudoku Daily', 'sudokuCanvas')}"></div>
|
||||
|
||||
<div id="game-controls-container" class="hidden">
|
||||
<div class="game-info score-board">
|
||||
<div id="score">SCORE: 5</div>
|
||||
<div id="timer">00:00</div>
|
||||
</div>
|
||||
<div id="number-input-buttons">
|
||||
<button class="num-btn" data-number="1">1</button>
|
||||
<button class="num-btn" data-number="2">2</button>
|
||||
<button class="num-btn" data-number="3">3</button>
|
||||
<button class="num-btn" data-number="4">4</button>
|
||||
<button class="num-btn" data-number="5">5</button>
|
||||
<button class="num-btn" data-number="6">6</button>
|
||||
<button class="num-btn" data-number="7">7</button>
|
||||
<button class="num-btn" data-number="8">8</button>
|
||||
<button class="num-btn" data-number="9">9</button>
|
||||
<button id="undo-btn" class="clear-btn">↩</button>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button id="hint-btn">힌트</button>
|
||||
<button id="complete-btn">정답 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" th:src="@{/js/pages/game_sudoku.js}"></script>
|
||||
<script type="module" th:src="@{/js/pages/game_sudoku_canvas.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
52
src/main/resources/templates/content/stock/config.html
Normal file
52
src/main/resources/templates/content/stock/config.html
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/default_layout}">
|
||||
<section layout:fragment="content">
|
||||
<div class="container" style="max-width: 800px; margin: 50px auto;">
|
||||
<header class="major">
|
||||
<h2>한국투자증권 API 설정</h2>
|
||||
<p>보안을 위해 입력하신 키는 DB에 저장되지 않으며, 세션 종료 시 즉시 파기됩니다.</p>
|
||||
</header>
|
||||
|
||||
<form id="kisConfigForm" class="box">
|
||||
<div class="row gtr-uniform">
|
||||
<div class="col-12">
|
||||
<label for="kis_app_key">App Key (실전투자)</label>
|
||||
<input type="text" id="kis_app_key" placeholder="App Key를 입력하세요" required />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="kis_app_secret">App Secret</label>
|
||||
<input type="password" id="kis_app_secret" placeholder="App Secret을 입력하세요" required />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="kis_account_no">계좌번호</label>
|
||||
<input type="text" id="kis_account_no" placeholder="'-' 제외 10자리 (예: 1234567801)" maxlength="10" required />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<ul class="actions fit">
|
||||
<li><button type="button" id="btn_save_kis" class="button primary fit">연결 및 시작하기</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
document.getElementById('btn_save_kis').addEventListener('click', async () => {
|
||||
const data = {
|
||||
appKey: document.getElementById('kis_app_key').value,
|
||||
appSecret: document.getElementById('kis_app_secret').value,
|
||||
accountNo: document.getElementById('kis_account_no').value
|
||||
};
|
||||
|
||||
const res = await Api.request('/api/stock/config', 'POST', data);
|
||||
if (res.resultCode === 0) {
|
||||
location.href = "/stock/dashboard.bs";
|
||||
} else {
|
||||
UI.showAlert("연결 실패", res.resultMsg);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</section>
|
||||
</html>
|
||||
59
src/main/resources/templates/content/stock/dashboard.html
Normal file
59
src/main/resources/templates/content/stock/dashboard.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/default_layout}">
|
||||
<section layout:fragment="content">
|
||||
<div class="container">
|
||||
<header class="major">
|
||||
<h2>나의 투자 대시보드</h2>
|
||||
</header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-4 col-12-medium">
|
||||
<div class="box">
|
||||
<h3>총 자산</h3>
|
||||
<h2 id="total_asset">0 원</h2>
|
||||
<p>수익률: <span id="total_profit_rate">0.00%</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-8 col-12-medium">
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목명</th>
|
||||
<th>보유수량</th>
|
||||
<th>매입가</th>
|
||||
<th>현재가</th>
|
||||
<th>수익률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="stock_list">
|
||||
<tr><td colspan="5" class="text-center">데이터를 불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const res = await Api.request('/api/stock/balance');
|
||||
if (res.resultCode === 0) {
|
||||
renderBalance(res.data);
|
||||
} else {
|
||||
location.href = "/stock/config.bs";
|
||||
}
|
||||
} catch (e) {
|
||||
UI.showAlert("오류", "데이터를 가져올 수 없습니다.");
|
||||
}
|
||||
});
|
||||
|
||||
function renderBalance(data) {
|
||||
// 자산 및 종목 렌더링 로직
|
||||
}
|
||||
</script>
|
||||
</section>
|
||||
</html>
|
||||
56
src/main/resources/templates/content/stock/market.html
Normal file
56
src/main/resources/templates/content/stock/market.html
Normal file
@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/default_layout}">
|
||||
|
||||
<section layout:fragment="content">
|
||||
<div class="container">
|
||||
<header class="major">
|
||||
<h2>글로벌 시장 지표</h2>
|
||||
</header>
|
||||
<div class="row">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script th:inline="javascript">
|
||||
async function loadMarketData() {
|
||||
try {
|
||||
// Api 모듈이 window에 등록될 때까지 기다리거나 체크
|
||||
if (typeof Api === 'undefined') {
|
||||
console.error("Api 모듈이 아직 로드되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("시장 지표 로드 시작");
|
||||
const data = await Api.request('/api/stock/market', 'GET');
|
||||
|
||||
const kospiEl = document.getElementById('idx_kospi');
|
||||
if(kospiEl && data.kospi) {
|
||||
kospiEl.innerText = `${parseFloat(data.kospi).toLocaleString()}`;
|
||||
renderChange(kospiEl, data.kospi_change);
|
||||
}
|
||||
|
||||
// KOSDAQ 등 나머지 업데이트...
|
||||
} catch (e) {
|
||||
console.error("시장 지표 로드 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChange(el, change) {
|
||||
if (!change) return;
|
||||
const val = parseFloat(change);
|
||||
const color = val > 0 ? '#ff4d4d' : (val < 0 ? '#4d4dff' : '#000');
|
||||
const prefix = val > 0 ? '▲' : (val < 0 ? '▼' : '');
|
||||
const span = document.createElement('span');
|
||||
span.style.color = color;
|
||||
span.style.fontSize = '0.6em';
|
||||
span.style.marginLeft = '10px';
|
||||
span.innerText = `${prefix} ${Math.abs(val)}%`;
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
// common.js가 로드된 후 실행되도록 보장
|
||||
window.addEventListener('load', loadMarketData);
|
||||
</script>
|
||||
</section>
|
||||
</html>
|
||||
71
src/main/resources/templates/fragments/game_template.html
Normal file
71
src/main/resources/templates/fragments/game_template.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:fragment="gameBody(gameTitle, canvasId)" class="game-body-wrapper">
|
||||
<h1 th:text="${gameTitle}" style="text-align:center; margin-bottom: 15px;">Game Title</h1>
|
||||
|
||||
<div class="game-play-box wide" id="game-container"
|
||||
style="display: flex; flex-direction: column; align-items: center; width: 100%; max-width: 600px; min-width: 320px; margin: 0 auto; background: #fff; box-shadow: 0 4px 10px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden;">
|
||||
|
||||
<canvas th:id="${canvasId}" style="display: block; width: 100%; height: auto; flex-shrink: 0;"></canvas>
|
||||
|
||||
<div id="ad-slot-container" style="display: block; width: 100%; min-width: 300px; min-height: 280px; background: #fafafa; border-top: 1px solid #eee; text-align: center; overflow: hidden; flex-shrink: 0;">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const adBox = document.getElementById('ad-slot-container');
|
||||
|
||||
function injectAdSense() {
|
||||
// 중복 주입 방지
|
||||
if (adBox.querySelector('.adsbygoogle')) return;
|
||||
|
||||
// 너비가 너무 작으면(숨겨진 상태 등) 주입하지 않음
|
||||
if (adBox.clientWidth < 200) return;
|
||||
|
||||
console.log("🚀 광고 주입 (너비 확보됨: " + adBox.clientWidth + "px)");
|
||||
|
||||
const ins = document.createElement('ins');
|
||||
ins.className = 'adsbygoogle';
|
||||
ins.style.display = 'block';
|
||||
ins.setAttribute('data-ad-client', 'ca-pub-9504446465764716');
|
||||
ins.setAttribute('data-ad-slot', '5334609005');
|
||||
ins.setAttribute('data-ad-format', 'auto');
|
||||
ins.setAttribute('data-full-width-responsive', 'true');
|
||||
|
||||
adBox.appendChild(ins);
|
||||
|
||||
try {
|
||||
(adsbygoogle = window.adsbygoogle || []).push({});
|
||||
} catch (e) {
|
||||
console.error("AdSense Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 너비 감지 로직 (ResizeObserver)
|
||||
if ('ResizeObserver' in window) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
// 테두리 제외한 내부 너비가 200px 이상일 때만 실행
|
||||
if (entry.contentRect.width > 200) {
|
||||
injectAdSense();
|
||||
observer.disconnect(); // 한 번 넣으면 감시 종료
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(adBox);
|
||||
} else {
|
||||
// 구형 브라우저 폴백
|
||||
const interval = setInterval(() => {
|
||||
if (adBox.clientWidth > 200) {
|
||||
injectAdSense();
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -15,6 +15,14 @@
|
||||
<ul>
|
||||
<li id="menu_home" ><a th:href="@{/}">Home</a></li>
|
||||
<li id="menu_posts"><a href="blog/posts">Posts</a></li>
|
||||
<li id="menu_stock">
|
||||
<a href="#">Stock</a>
|
||||
<ul>
|
||||
<li sec:authorize="isAuthenticated()"><a href="/stock/config">API 설정</a></li>
|
||||
<li sec:authorize="isAuthenticated()"><a href="/stock/dashboard">내 잔고/거래</a></li>
|
||||
<li><a href="/stock/market">시장 지표</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li id="menu_bookmarks"><a href="/bookmarks">Bookmarks</a></li>
|
||||
<li id="menu_drop">
|
||||
<a href="#">Game</a>
|
||||
|
||||
@ -13,6 +13,9 @@
|
||||
<meta name="_csrf" th:content="${_csrf.token}"/>
|
||||
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
|
||||
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/>
|
||||
<!-- <th:block th:with="userTheme=${user != null ? user.theme : 'default'}">-->
|
||||
<!-- <link rel="stylesheet" th:href="@{/css/themes/} + ${userTheme} + '.css'" />-->
|
||||
<!-- </th:block>-->
|
||||
<script th:inline="javascript">
|
||||
/*
|
||||
* [Modified] This object must include all fields from the Post.kt model.
|
||||
@ -55,8 +58,8 @@
|
||||
keyword: /*[[${keyword ?: ''}]]*/,
|
||||
// --- [핵심 추가] ---
|
||||
token: /*[[${jwtToken}]]*/,
|
||||
apiBaseUrl : /*[[${apiBaseUrl}]]*/,
|
||||
userTheme: [[${#authentication.principal != 'anonymousUser' ? user?.theme : null}]],
|
||||
apiBaseUrl : /*[[${apiBaseUrl}]]*/,
|
||||
userTheme: [[${#authentication.principal != 'anonymousUser' ? user?.theme : null}]],
|
||||
};
|
||||
</script>
|
||||
</th:block>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user