This commit is contained in:
lunaticbum 2025-12-26 17:31:21 +09:00
parent 9b29b623c2
commit e8355b3048
46 changed files with 2510 additions and 1765 deletions

View File

@ -112,8 +112,11 @@ class SecurityConfig(
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음 .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } // API는 세션을 사용하지 않음
.authorizeHttpRequests { auth -> .authorizeHttpRequests { auth ->
auth auth
.requestMatchers(HttpMethod.GET,"/api/feed").permitAll()
.requestMatchers("/api/stock/**").permitAll()
.requestMatchers("/api/ranks/**").permitAll() .requestMatchers("/api/ranks/**").permitAll()
.requestMatchers("/api/stats/visitors").permitAll() .requestMatchers("/api/stats/visitors").permitAll()
.requestMatchers(HttpMethod.GET,"/api/stock/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/images/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/images/**").permitAll()
.requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용 .requestMatchers("/api/auth/login").permitAll() // 로그인 API는 모두 허용
.anyRequest().authenticated() // 나머지 API는 인증 필요 .anyRequest().authenticated() // 나머지 API는 인증 필요
@ -164,9 +167,11 @@ class SecurityConfig(
.requestMatchers( .requestMatchers(
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**" "/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
).permitAll() ).permitAll()
.requestMatchers(HttpMethod.GET,"/stock/**").permitAll()
// 2. 공개 GET API 및 페이지 = permitAll // 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET, .requestMatchers(HttpMethod.GET,
"/api/images/**", "/api/images/**",
"/stock/**",
"/", "/home.bs", "/bums/where.bs", "/", "/home.bs", "/bums/where.bs",
"/user/login.bs", "/user/join.bs", "/user/login.bs", "/user/join.bs",
"/blog/viewer/**", "/blog/posts", "/blog/viewer/**", "/blog/posts",

View File

@ -23,12 +23,20 @@ class GlobalControllerAdvice(
} }
// [추가] 로그인한 경우, User 엔티티(테마 정보 포함)를 모델에 "user"라는 이름으로 추가 // [추가] 로그인한 경우, User 엔티티(테마 정보 포함)를 모델에 "user"라는 이름으로 추가
// @ModelAttribute("user")
// fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono<User> {
// return if (userDetails != null) {
// userManager.findById(userDetails.username)
// } else {
// Mono.empty()
// }
// }
@ModelAttribute("user") @ModelAttribute("user")
fun addUserToModel(@AuthenticationPrincipal userDetails: UserDetails?): Mono<User> { fun currentUser(@AuthenticationPrincipal userDetails: UserDetails?): User? {
return if (userDetails != null) { if (userDetails == null) return null
userManager.findById(userDetails.username)
} else { // [수정] Mono 객체를 그대로 반환하지 말고 .block()을 통해 실제 객체를 반환해야 합니다.
Mono.empty() return userManager.findById(userDetails.username).block()
}
} }
} }

View File

@ -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>>
}
}

View File

@ -36,6 +36,10 @@ import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kr.lunaticbum.back.lun.model.Message 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 import org.springframework.stereotype.Controller
@Controller @Controller

View File

@ -7,13 +7,13 @@ import kr.lunaticbum.back.lun.model.BookmarkDataDto
import kr.lunaticbum.back.lun.model.BookmarkImage import kr.lunaticbum.back.lun.model.BookmarkImage
import kr.lunaticbum.back.lun.model.BookmarkType import kr.lunaticbum.back.lun.model.BookmarkType
import kr.lunaticbum.back.lun.model.BookmarkUpdateRequest 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.ImageMetaService
import kr.lunaticbum.back.lun.model.ImageUrlRequest import kr.lunaticbum.back.lun.model.ImageUrlRequest
import kr.lunaticbum.back.lun.model.ImageVisibilityRequest import kr.lunaticbum.back.lun.model.ImageVisibilityRequest
import kr.lunaticbum.back.lun.model.Visibility import kr.lunaticbum.back.lun.model.Visibility
import kr.lunaticbum.back.lun.model.WebBookmark 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 kr.lunaticbum.back.lun.utils.LogService
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Page import org.springframework.data.domain.Page

View File

@ -5,6 +5,9 @@ import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.* 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.LogService
import kr.lunaticbum.back.lun.utils.PayloadDecoder import kr.lunaticbum.back.lun.utils.PayloadDecoder
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus

View File

@ -5,11 +5,11 @@ import kotlinx.coroutines.reactive.awaitSingle
import kr.lunaticbum.back.lun.model.BookmarkImage import kr.lunaticbum.back.lun.model.BookmarkImage
import kr.lunaticbum.back.lun.model.Comment import kr.lunaticbum.back.lun.model.Comment
import kr.lunaticbum.back.lun.model.CommentResponse 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.ImageMetaService
import kr.lunaticbum.back.lun.model.ResultMV import kr.lunaticbum.back.lun.model.ResultMV
import kr.lunaticbum.back.lun.model.VoteResponse 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 kr.lunaticbum.back.lun.utils.PayloadDecoder
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal

View File

@ -8,9 +8,9 @@ import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.model.LocationLog import kr.lunaticbum.back.lun.model.LocationLog
import kr.lunaticbum.back.lun.model.Post 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.ResponceResult
import kr.lunaticbum.back.lun.model.ResultMV 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.services.LocationLogService
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.plainText import kr.lunaticbum.back.lun.utils.plainText

View File

@ -8,6 +8,9 @@ import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactive.awaitSingleOrNull import kotlinx.coroutines.reactive.awaitSingleOrNull
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import kr.lunaticbum.back.lun.model.* 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.services.ImageService
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
import net.coobird.thumbnailator.Thumbnails import net.coobird.thumbnailator.Thumbnails
@ -32,6 +35,7 @@ class PostViewController(
private val visitorLogService: VisitorLogService, private val visitorLogService: VisitorLogService,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val logService: LogService, private val logService: LogService,
private val feedService: FeedService,
private val imageService: ImageService // [주입 추가] private val imageService: ImageService // [주입 추가]
) { ) {
@Value("\${image.upload.path}") @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 --- // --- View Endpoints ---
@GetMapping("/", "/home.bs") @GetMapping("/", "/home.bs")
@ -148,7 +156,10 @@ class PostViewController(
vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8") vm.modelMap["gibberish"] = URLDecoder.decode(randomGibberish.content, "UTF-8")
vm.modelMap["gibberishId"] = randomGibberish.id 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() val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
vm.modelMap["Posts"] = postsList.map { processPostForView(it) } vm.modelMap["Posts"] = postsList.map { processPostForView(it) }
vm.modelMap["path"] = "/blog/viewer/" vm.modelMap["path"] = "/blog/viewer/"

View 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
)

View File

@ -1,5 +1,7 @@
package kr.lunaticbum.back.lun.model 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.domain.Sort
import org.springframework.data.mongodb.core.ReactiveMongoTemplate import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation import org.springframework.data.mongodb.core.aggregation.Aggregation

View File

@ -2,6 +2,7 @@ package kr.lunaticbum.back.lun.model
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment import kr.lunaticbum.back.lun.configs.core.GlobalEnvironment
import kr.lunaticbum.back.lun.repository.PostHistoryRepository
import kr.lunaticbum.back.lun.utils.LogService import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor import lombok.AllArgsConstructor
import lombok.Data import lombok.Data
@ -75,25 +76,7 @@ data class PostHistory(
var archivedAt: Long = System.currentTimeMillis() // 보관된 시간 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 { enum class PostType {
STANDARD, // 일반 블로그 글 STANDARD, // 일반 블로그 글
@ -178,487 +161,7 @@ class CommentsResult {
data class AggregationCount(val totalCount: Long) 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)
}
}

View 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? // 다음 요청 시 이 시간을 보내면 그 이전 글들을 줍니다.
)

View File

@ -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> // [신규 추가]
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>>
}

View File

@ -4,7 +4,7 @@ package kr.lunaticbum.back.lun.service
import kr.lunaticbum.back.lun.model.BookmarkType import kr.lunaticbum.back.lun.model.BookmarkType
import kr.lunaticbum.back.lun.model.MetadataStatus import kr.lunaticbum.back.lun.model.MetadataStatus
import kr.lunaticbum.back.lun.model.WebBookmark 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 kr.lunaticbum.back.lun.utils.LogService
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.data.mongodb.repository.ReactiveMongoRepository

View File

@ -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) } // [수정] 디코딩 로직 추가
}
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View 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
}
}
}

View File

@ -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)
}
}

View File

@ -57,27 +57,40 @@ import java.util.Base64
//} //}
fun String.plainText() = String(Base64.getMimeDecoder().decode(this)) 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 { try {
val decodedBytes: ByteArray = Base64.getDecoder().decode(this) // 1. Base64 디코딩 (클라이언트가 btoa로 보낸 경우)
String(decodedBytes).let { resultString -> val decodedBytes = java.util.Base64.getDecoder().decode(this)
try { val jsonString = String(decodedBytes, Charsets.UTF_8)
Gson().fromJson<RequestModel>(resultString, RequestModel::class.java).let { model ->
model.data?.let { jsonString -> // 2. 만약 페이로드가 { "data": "...", "key": "..." } 구조라면 필요한 부분만 추출
try { // 여기서는 단순화를 위해 전체 스트링을 그대로 넘깁니다.
println("RequestModel ${jsonString}") completion(null, 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) { } 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) class Base64DecodeException(message: String, cause : Throwable? = null) : Exception(message, cause)

View File

@ -1,70 +1,24 @@
/* game.css - 게임 공통 테마 및 레이아웃 */ /* src/main/resources/static/css/pages/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;
}
/* 게임 페이지 전체 래퍼 */
.game-body-wrapper { .game-body-wrapper {
text-align: center; padding: 20px;
padding: 20px 10px; /* 모바일 여백 확보 */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
min-height: 60vh; /* 최소 높이 확보 */
} }
/* [핵심] 게임 공통 컨테이너 (카드 UI) */ .game-body-wrapper h1 {
.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;
margin-bottom: 20px; margin-bottom: 20px;
font-size: 1.2em; font-size: 1.8rem;
font-weight: bold; color: #333;
color: var(--text-main);
background: var(--bg-element-alt);
padding: 10px 20px;
border-radius: 50px; /* 둥근 알약 모양 */
} }
.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;
} }

View File

@ -23,6 +23,7 @@
#game-board { grid-gap: 10px; padding: 10px; } #game-board { grid-gap: 10px; padding: 10px; }
} }
.tile { .tile {
width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center;
font-weight: bold; border-radius: 3px; font-weight: bold; border-radius: 3px;

View File

@ -384,4 +384,34 @@ window.openBookmarkEditPopup = function(btn) {
UI.showAlert("알림", "북마크 수정 기능 준비 중"); UI.showAlert("알림", "북마크 수정 기능 준비 중");
}; };
window.openBookmarkCategoryPopup = () => 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";
}
}

View File

@ -14,65 +14,47 @@ export let Api = {
return meta ? meta.getAttribute('content') : ''; return meta ? meta.getAttribute('content') : '';
}, },
/**
* 공통 Fetch Wrapper (GET/POST/PUT/DELETE)
*/
async request(url, method = 'GET', body = null, headers = {}) { async request(url, method = 'GET', body = null, headers = {}) {
const defaultHeaders = { // [수정] URL이 /로 시작하지 않으면 자동으로 붙여줌
'X-CSRF-TOKEN': this.getCsrfToken() const targetUrl = url.startsWith('/') ? url : '/' + url;
};
const config = { const defaultHeaders = { 'X-CSRF-TOKEN': this.getCsrfToken() };
method: method, const config = { method, headers: { ...defaultHeaders, ...headers } };
headers: { ...defaultHeaders, ...headers }
};
if (body) { if (body) {
// FormData는 Content-Type을 설정하지 않음 (브라우저 자동 설정) if (body instanceof FormData) config.body = body;
if (body instanceof FormData) { else {
config.body = body;
} else {
config.headers['Content-Type'] = 'application/json'; config.headers['Content-Type'] = 'application/json';
config.body = JSON.stringify(body); config.body = JSON.stringify(body);
} }
} }
try { try {
const response = await fetch(url, config); const response = await fetch(targetUrl, config);
if (!response.ok) { if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
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 등) 대비 return await response.text();
const text = await response.text();
return text ? JSON.parse(text) : {};
} catch (error) { } catch (error) {
console.error(`API Request Failed [${method} ${url}]:`, error); console.error(`API Fail [${method} ${targetUrl}]:`, error);
throw error; throw error;
} }
}, },
/**
* [Legacy 호환] 암호화된 POST 요청
* (기존 post() 함수 대체 - fetch 사용)
*/
async postEncrypted(url, type, dataObj, key) { async postEncrypted(url, type, dataObj, key) {
// 데이터 암호화 (unformat 로직) // [수정] 암호화(encrypt)를 호출하지 않고 바로 JSON을 Base64로 인코딩만 합니다.
const dataStr = JSON.stringify(dataObj); const dataStr = JSON.stringify(dataObj);
const encryptedData = this.encrypt(type, dataStr, key);
const payload = { // 백엔드 extractModelData 구조와 맞추기 위해 Base64 인코딩만 수행
data: encryptedData, const base64Payload = btoa(unescape(encodeURIComponent(dataStr)));
key: key,
type: type
};
// Base64 인코딩
const base64Payload = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'text/plain', 'Content-Type': 'text/plain', // 기존 규격 유지
'X-CSRF-TOKEN': this.getCsrfToken() 'X-CSRF-TOKEN': this.getCsrfToken()
}, },
body: base64Payload body: base64Payload
@ -80,19 +62,5 @@ export let Api = {
if (!response.ok) throw new Error('Network response was not ok'); if (!response.ok) throw new Error('Network response was not ok');
return await response.json(); 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("");
}
} }
}; };

View 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 };
}
};

View 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();
});

View 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();
});

View File

@ -1,610 +1,257 @@
import { Api } from '../modules/api.js'; import { Api } from '../modules/api.js';
import { Game } from '../modules/game.js'; import { Game } from '../modules/game.js';
import { CommonCanvas } from '../modules/canvas_utils.js';
import { UI } from '../modules/ui.js'; import { UI } from '../modules/ui.js';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// 1. 상수 및 변수 선언 const canvas = document.getElementById('spiderCanvas');
const canvas = document.getElementById('gameCanvas'); if (!canvas) return;
const ctx = canvas.getContext('2d');
const SAVED_GAME_ID_KEY = 'spider_saved_game_id';
// 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; let isProcessing = false;
const UI_ELEMENTS = {}; const cardBack = new Image(); cardBack.src = '/css/images/card-back.png';
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;
let currentGame = null; const ui = {
let isGameCompleted = false; start: { x: 400, y: 480, w: 200, h: 60, label: "새 게임 시작" },
let gameStartTime = 0, completionTimeSeconds = 0; stock: { x: 880, y: 850, w: CARD_W, h: CARD_H },
const currentGameType = 'SPIDER'; undo: { x: 50, y: 900, w: 120, h: 50, label: "실행 취소" }
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: '어려움' }]
}; };
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() { function draw() {
if (!assetsLoaded) return; ctx.clearRect(0, 0, V, V);
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#006633"; ctx.fillRect(0, 0, V, V); // 펠트색
if (currentGame) drawGame(currentGame); if (!currentGame) drawMenu(); else drawGame();
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);
}
} }
function drawUI() { function drawMenu() {
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = "#fff"; ctx.font = "bold 60px Arial"; ctx.textAlign = "center";
if (!currentGame) { ctx.fillText("SPIDER SOLITAIRE", V/2, 250);
const { suitSelect, cardCountSelect, startButton, loadButton } = UI_ELEMENTS; fillRoundRect(ui.start.x, ui.start.y, ui.start.w, ui.start.h, 10, "#4CAF50");
ctx.fillStyle = "#fff"; ctx.font = "bold 24px Arial";
// Draw Suit Select ctx.fillText(ui.start.label, ui.start.x + 100, ui.start.y + 38);
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 drawGame(game) { function drawGame() {
drawBackground(); // Tableau (테이블 카드)
drawTableau(game.tableau); currentGame.tableau.forEach((stack, sIdx) => {
drawStockAndFoundation(game.stock, game.foundation); stack.forEach((card, cIdx) => {
drawDraggedCards(draggedCards); if (draggedCards.includes(card)) return; // 드래그 중인 카드는 나중에
drawCompletionAnimation(); const x = 50 + sIdx*GAP_X, y = 120 + cIdx*OVER_Y;
} card.currentX = x; card.currentY = y; // 좌표 저장
drawCard(card, x, y);
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 drawDraggedCards(cards) { // Stock (덱)
if (!isDragging || !Array.isArray(cards) || cards.length === 0) return; if (currentGame.stock.length > 0) ctx.drawImage(cardBack, ui.stock.x, ui.stock.y, CARD_W, CARD_H);
cards.forEach((card, index) => {
const x = cards[0].x, y = cards[0].y + index * cardOverlapY; // Foundation (완성된 세트)
drawSingleCard(card, x, y); currentGame.foundation.forEach((set, i) => {
drawCard(set[set.length-1], 20 + i*35, 850);
}); });
}
function drawCompletionAnimation() { // UI 버튼
if (isAnimatingCompletion) { fillRoundRect(ui.undo.x, ui.undo.y, ui.undo.w, ui.undo.h, 5, "#ff9800");
const now = Date.now(); ctx.fillStyle = "#fff"; ctx.font = "16px Arial"; ctx.textAlign = "center";
completedStackCards = completedStackCards.filter(card => { ctx.fillText(ui.undo.label, ui.undo.x + 60, ui.undo.y + 30);
if (now < card.animEndTime) {
const progress = (now - (card.animEndTime - 500)) / 500; // 드래그 중인 카드 (최상단)
const currentX = card.animStartX + (card.animTargetX - card.animStartX) * progress; if (draggedCards.length > 0) {
const currentY = card.animStartY + (card.animTargetY - card.animStartY) * progress; draggedCards.forEach((c, i) => drawCard(c, c.drawX, c.drawY + i*OVER_Y));
drawSingleCard(card, currentX, currentY);
return true;
}
return false;
});
if (completedStackCards.length === 0) isAnimatingCompletion = false;
} }
} }
function drawSingleCard(card, x, y) { function drawCard(card, x, y) {
card.x = x; card.y = y; card.width = cardWidth; card.height = cardHeight; if (!card.isFaceUp) { ctx.drawImage(cardBack, x, y, CARD_W, CARD_H); return; }
if (card.isFaceUp) { fillRoundRect(x, y, CARD_W, CARD_H, 5, "#fff");
ctx.fillStyle = '#ffffff'; ctx.fillRect(x, y, cardWidth, cardHeight); ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.strokeRect(x, y, CARD_W, CARD_H);
ctx.strokeStyle = '#333333'; ctx.strokeRect(x, y, cardWidth, cardHeight);
const isRed = (card.suit === 'heart' || card.suit === 'diamond'); const isRed = (card.suit === 'heart' || card.suit === 'diamond');
ctx.fillStyle = isRed ? '#ff0000' : '#000000'; ctx.fillStyle = isRed ? "#d32f2f" : "#000";
ctx.font = `${cardWidth * 0.25}px Arial`; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.font = "bold 18px Arial"; ctx.textAlign = "left";
ctx.fillText(getRankText(card.rank), x + cardWidth * CARD_RANK_LEFT_PADDING, y + cardHeight * CARD_RANK_TOP_PADDING); ctx.fillText(getRankStr(card.rank), x+6, y+22);
drawSuitSymbols(card, x, y);
} else { ctx.font = "24px Arial"; ctx.textAlign = "center";
ctx.drawImage(cardBackImage, x, y, cardWidth, cardHeight); ctx.fillText(getSuitStr(card.suit), x+CARD_W/2, y+CARD_H/2+5);
}
} }
function drawSuitSymbols(card, x, y) { function getRankStr(r) { return r==1?'A':r==11?'J':r==12?'Q':r==13?'K':r; }
const symbol = getSuitSymbol(card.suit); function getSuitStr(s) { return {spade:'♠',heart:'♥',club:'♣',diamond:'♦'}[s]||''; }
// (심볼 그리기 로직은 너무 길어서 간략화함 - 기존 로직 유지)
ctx.font = `${cardWidth * 0.6}px Arial`; // --- 게임 로직 ---
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; async function start() {
ctx.fillText(symbol, x + cardWidth / 2, y + cardHeight / 2); 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; function deal() {
const foundationArea = UI_ELEMENTS.foundationArea; if (currentGame.stock.length === 0) return;
ctx.strokeStyle = getCssVar('--color-felt-border') || '#004d00'; saveUndoState();
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; const dealCards = currentGame.stock.splice(0, 10);
ctx.fillRect(foundationArea.x, foundationArea.y, foundationArea.width, foundationArea.height); dealCards.forEach((c, i) => { c.isFaceUp = true; currentGame.tableau[i].push(c); });
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); });
currentGame.moves++; currentGame.moves++;
checkCompletedStacks(); checkFoundation();
draw();
} }
function addUndoState() {
const stateToSave = { // 드래그 시작 판정
tableau: JSON.parse(JSON.stringify(currentGame.tableau)), function findDrag(p) {
stock: JSON.parse(JSON.stringify(currentGame.stock)), // 역순 탐색 (위쪽 카드부터)
foundation: JSON.parse(JSON.stringify(currentGame.foundation)), for (let sIdx=9; sIdx>=0; sIdx--) {
moves: currentGame.moves const stack = currentGame.tableau[sIdx];
}; for (let cIdx=stack.length-1; cIdx>=0; cIdx--) {
currentGame.undoHistory.push(stateToSave); const card = stack[cIdx];
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];
if (!card.isFaceUp) continue; 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 handleDrop(p) {
function getBestMoveForStack(cardsToMove) { let bestDist = 9999, targetIdx = -1;
if (cardsToMove.length === 0) return null;
const firstCardToMove = cardsToMove[0]; // 가장 가까운 컬럼 찾기 (X축 기준)
for (let i = 0; i < 10; i++) { for (let i=0; i<10; i++) {
const destStackCards = currentGame.tableau[i]; const cx = 50 + i*GAP_X + CARD_W/2;
if (destStackCards.length === 0) return `tableau-${i + 1}`; 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 { else {
const destTopCard = destStackCards[destStackCards.length - 1]; const top = destStack[destStack.length-1];
if (firstCardToMove.rank === destTopCard.rank - 1) return `tableau-${i + 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() { // 세트 완성 체크 (K...A)
for (let stackIndex = 0; stackIndex < currentGame.tableau.length; stackIndex++) { function checkFoundation() {
const stack = currentGame.tableau[stackIndex]; currentGame.tableau.forEach(stack => {
if (stack.length < 13) continue; if (stack.length < 13) return;
const last13Cards = stack.slice(stack.length - 13); // 끝에서 13장 검사
let isCompleted = true; const suffix = stack.slice(stack.length-13);
for (let i = 0; i < 12; i++) { let isSeq = true;
if (last13Cards[i].rank !== last13Cards[i+1].rank + 1 || last13Cards[i].suit !== last13Cards[i+1].suit) { isCompleted = false; break; } 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) { if (isSeq) { // 완성!
isAnimatingCompletion = true; stack.splice(stack.length-13, 13);
const cardsToRemove = stack.slice(stack.length - 13); if (stack.length>0) stack[stack.length-1].isFaceUp = true;
const originalStackLength = stack.length; currentGame.foundation.push(suffix);
cardsToRemove.forEach((card, index) => {
const cardIndexInStack = originalStackLength - 13 + index; // 게임 클리어 체크
card.animStartX = tableauStartX + stackIndex * (cardWidth + cardGapX); if (currentGame.foundation.length === 8) {
card.animStartY = (cardHeight * 0.5) + cardIndexInStack * cardOverlapY; showSuccessOverlay({
card.animEndTime = Date.now() + 500; title: "SPIDER CLEAR!", scoreLabel: "MOVES", scoreValue: currentGame.moves
card.animTargetX = (UI_ELEMENTS.foundationArea.x + currentGame.foundation.length * (cardWidth * FOUNDATION_CARD_SPACING)); });
card.animTargetY = UI_ELEMENTS.foundationArea.y; setTimeout(() => Game.showSuccessModal({ gameType: 'SPIDER', primaryScore: currentGame.moves }), 2500);
completedStackCards.push(card); }
});
stack.splice(stack.length - 13, 13);
if (stack.length > 0) stack[stack.length - 1].isFaceUp = true;
currentGame.foundation.push(cardsToRemove);
} }
} });
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 모듈 사용) // Undo 관련
async function startNewGame(loadFromSaved) { function saveUndoState() {
isProcessing = true; if (!currentGame.history) currentGame.history = [];
try { const state = JSON.parse(JSON.stringify({
let gameData; t: currentGame.tableau, s: currentGame.stock, f: currentGame.foundation, m: currentGame.moves
if (loadFromSaved) { }));
const savedId = localStorage.getItem(SAVED_GAME_ID_KEY); currentGame.history.push(state);
if (!savedId) throw new Error("저장된 게임이 없습니다."); if (currentGame.history.length > 10) currentGame.history.shift();
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;
}
} }
async function saveGameToServer() { function handleUndo() {
if (!currentGame || isProcessing) return; if (!currentGame || !currentGame.history || currentGame.history.length===0) return;
isProcessing = true; const prev = currentGame.history.pop();
try { currentGame.tableau = prev.t;
const savedGame = await Api.request(`/puzzle/spider/update`, 'POST', currentGame); currentGame.stock = prev.s;
currentGame.id = savedGame.id; currentGame.foundation = prev.f;
localStorage.setItem(SAVED_GAME_ID_KEY, currentGame.id); currentGame.moves = prev.m;
UI.showAlert("알림", "게임이 저장되었습니다.");
} catch (error) {
UI.showAlert("알림", "게임 저장 실패");
} finally {
isProcessing = false;
}
} }
resizeCanvas(); // 입력 이벤트
function gameLoop() { draw(); requestAnimationFrame(gameLoop); } canvas.addEventListener('mousedown', e => {
gameLoop(); 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(); };
}); });

View File

@ -1,319 +1,40 @@
import { Api } from '../modules/api.js'; import { CommonCanvas } from '../modules/canvas_utils.js';
import { Game } from '../modules/game.js';
import { UI } from '../modules/ui.js';
document.addEventListener('DOMContentLoaded', () => { const V_SIZE = 600; // 논리적 고정 크기
const currentGameType = 'SUDOKU'; const CELL_SIZE = V_SIZE / 9;
// DOM 요소 참조 function drawSudoku(ctx, boardData, state) {
const setupContainer = document.getElementById('setup-container'); ctx.clearRect(0, 0, V_SIZE, V_SIZE);
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');
// 게임 상태 변수 // 1. 그리드 그리기
let currentPuzzleId, solvedPuzzle, timerInterval, secondsElapsed = 0; for (let i = 0; i <= 9; i++) {
let selectedNumber = null, focusedCell = null, score = 5, history = []; 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. 게임 시작 버튼 핸들러 // 2. 숫자 및 하이라이트 그리기
document.getElementById('start-btn').addEventListener('click', async () => { boardData.forEach((val, i) => {
const diff = document.getElementById('difficulty-select').value; const r = Math.floor(i / 9);
try { const c = i % 9;
const data = await Api.request(`/puzzle/sudoku/start?difficulty=${diff}`); const x = c * CELL_SIZE;
currentPuzzleId = data.puzzleId; const y = r * CELL_SIZE;
solvedPuzzle = data.solution;
history = [];
score = 5;
renderBoard(data.question); // 선택된 셀 하이라이트
startTimer(); if (state.focusedIndex === i) {
updateScore(); ctx.fillStyle = 'rgba(0, 123, 255, 0.2)';
updateButtonStates(); // [복구됨] ctx.fillRect(x, y, CELL_SIZE, CELL_SIZE);
}
setupContainer.classList.add('hidden'); // 숫자 렌더링
boardEl.classList.remove('hidden'); if (val !== 0) {
gameControls.classList.remove('hidden'); ctx.fillStyle = state.isEditable[i] ? '#007bff' : '#000';
} catch (e) { ctx.font = `bold ${CELL_SIZE * 0.6}px Arial`;
UI.showAlert("오류", "게임 로딩 실패: " + e.message); 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'));
}
});

View 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();
});

View File

@ -35,49 +35,82 @@
<section class="wrapper style1"> <section class="wrapper style1">
<div class="container"> <div class="container">
<div class="row"> <article id="feed-container">
<div class="col-12"> <section th:each="item, iterStat : ${feedItems}" class="feed-item">
<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>
<div class="inner"> <div th:if="${item.type.name() == 'POST'}" class="box post"
<h3 style="display: flex; justify-content: space-between; align-items: center;"> th:onclick="|location.href='@{${item.url}}'|" style="cursor: pointer;">
<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 class="image left">
<span style="font-size: 0.75em; color: #888; font-weight: normal; white-space: nowrap; margin-left: 1em;"> <img th:if="${item.thumbnail}" th:src="${apiBaseUrl + item.thumbnail}"
(읽음: <span th:text="${post.readCount}">0</span>) alt="Thumbnail" th:onerror="|this.onerror=null; this.src='@{/images/pic01.jpg}';|" />
</span> <img th:unless="${item.thumbnail}" th:src="@{/images/pic01.jpg}" alt="Default Thumbnail" />
</h3> </span>
<p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;" th:text="${'by ' + post.writer}"></p> <div class="inner">
<h3 th:text="${item.title}">제목</h3>
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p> <p style="font-size: 0.9em; color: #555; margin-bottom: 0.5em;"
</div> th:text="${#dates.format(new java.util.Date(item.createdAt), 'yyyy-MM-dd HH:mm')} + ' by ' + ${item.writer}"></p>
</div> <p th:text="${#strings.abbreviate(item.content, 150)}">내용 요약</p>
<th:block th:if="${iterStat.count % 3 == 0}"> </div>
<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> </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> </div>
</div> </div>
@ -131,5 +164,113 @@
</header> </header>
</div> </div>
</section> </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> </th:block>
</html> </html>

View File

@ -9,23 +9,9 @@
</head> </head>
<body> <body>
<th:block layout:fragment="content"> <th:block layout:fragment="content">
<div class="game-body-wrapper"> <div th:replace="~{fragments/game_template :: gameBody('2048', 'game2048Canvas')}"></div>
<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>
<script type="module" th:src="@{/js/pages/game_2048.js}"></script> <script type="module" th:src="@{/js/pages/game_2048_canvas.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>
</th:block> </th:block>
</body> </body>
</html> </html>

View File

@ -1,36 +1,14 @@
<!DOCTYPE html> <!DOCTYPE html>
<html <html xmlns:th="http://www.thymeleaf.org"
xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security" layout:decorate="~{layout/default_layout}">
layout:decorate="~{layout/default_layout}"
>
<th:block layout:fragment="head" id="head">
</th:block >
<th:block layout:fragment="content"> <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"> <script th:inline="javascript">
window.puzzleData = /*[[${puzzle}]]*/ null; window.puzzleData = [[${puzzle}]];
</script> </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> </th:block>
</html> </html>

View File

@ -8,12 +8,8 @@
<th:block layout:fragment="head" id="head"> <th:block layout:fragment="head" id="head">
</th:block > </th:block >
<th:block layout:fragment="content"> <th:block layout:fragment="content">
<div class="game-body-wrapper"> <div th:replace="~{fragments/game_template :: gameBody('Spider', 'spiderCanvas')}"></div>
<h1>Spider Solitaire</h1>
<div class="game-play-box wide" id="game-container">
<canvas id="gameCanvas"></canvas>
</div>
</div>
<script type="module" th:src="@{/js/pages/game_spider.js}"></script> <script type="module" th:src="@{/js/pages/game_spider.js}"></script>
</th:block> </th:block>
</html> </html>

View File

@ -9,46 +9,9 @@
</head> </head>
<body> <body>
<th:block layout:fragment="content"> <th:block layout:fragment="content">
<div class="game-body-wrapper"> <div th:replace="~{fragments/game_template :: gameBody('Sudoku Daily', 'sudokuCanvas')}"></div>
<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 id="game-controls-container" class="hidden"> <script type="module" th:src="@{/js/pages/game_sudoku_canvas.js}"></script>
<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>
</th:block> </th:block>
</body> </body>
</html> </html>

View 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>

View 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>

View 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>

View 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>

View File

@ -15,6 +15,14 @@
<ul> <ul>
<li id="menu_home" ><a th:href="@{/}">Home</a></li> <li id="menu_home" ><a th:href="@{/}">Home</a></li>
<li id="menu_posts"><a href="blog/posts">Posts</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_bookmarks"><a href="/bookmarks">Bookmarks</a></li>
<li id="menu_drop"> <li id="menu_drop">
<a href="#">Game</a> <a href="#">Game</a>

View File

@ -13,6 +13,9 @@
<meta name="_csrf" th:content="${_csrf.token}"/> <meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/> <meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/> <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"> <script th:inline="javascript">
/* /*
* [Modified] This object must include all fields from the Post.kt model. * [Modified] This object must include all fields from the Post.kt model.
@ -55,8 +58,8 @@
keyword: /*[[${keyword ?: ''}]]*/, keyword: /*[[${keyword ?: ''}]]*/,
// --- [핵심 추가] --- // --- [핵심 추가] ---
token: /*[[${jwtToken}]]*/, token: /*[[${jwtToken}]]*/,
apiBaseUrl : /*[[${apiBaseUrl}]]*/, apiBaseUrl : /*[[${apiBaseUrl}]]*/,
userTheme: [[${#authentication.principal != 'anonymousUser' ? user?.theme : null}]], userTheme: [[${#authentication.principal != 'anonymousUser' ? user?.theme : null}]],
}; };
</script> </script>
</th:block> </th:block>