...
This commit is contained in:
parent
39c9624774
commit
17aea8b43b
@ -13,16 +13,17 @@ ENV RESOURCE_LOCATION=default
|
|||||||
ENV IMAGE_UPLOAD_PATH=default
|
ENV IMAGE_UPLOAD_PATH=default
|
||||||
ENV PUZZLE_IMAGE_UPLOAD_PATH=default
|
ENV PUZZLE_IMAGE_UPLOAD_PATH=default
|
||||||
ENV GAPI_KEY=default
|
ENV GAPI_KEY=default
|
||||||
|
ENV API_BASE_URL=default
|
||||||
WORKDIR /imgUpload
|
WORKDIR /imgUpload
|
||||||
LABEL maintainer="lunaticbum <lunaticbum@gmail.com>"
|
LABEL maintainer="lunaticbum <lunaticbum@gmail.com>"
|
||||||
LABEL version="0.0.7"
|
LABEL version="0.0.7"
|
||||||
LABEL description="Spring Boot Jar Test"
|
LABEL description="Spring Boot Jar Test"
|
||||||
|
|
||||||
ARG JAR_FILE=build/libs/lun-0.0.7-SNAPSHOT.jar
|
ARG JAR_FILE=build/libs/lun-0.0.7-SNAPSHOT-prod.jar
|
||||||
COPY ${JAR_FILE} app.jar
|
COPY ${JAR_FILE} app.jar
|
||||||
EXPOSE 443
|
EXPOSE 443
|
||||||
#EXPOSE 27012
|
#EXPOSE 27012
|
||||||
#EXPOSE 3307
|
#EXPOSE 3307
|
||||||
#ENTRYPOINT ["java","-jar","app.jar","-Dspring-boot.run.arguments=--telegram.bot.key=${BOT_KEY}, --telegram.my.id=${TG_MINE}, --telegram.target.id=${TG_TARGET_ID}, --weather.api.key=${WEATHER_KEY}"]
|
#ENTRYPOINT ["java","-jar","app.jar","-Dspring-boot.run.arguments=--telegram.bot.key=${BOT_KEY}, --telegram.my.id=${TG_MINE}, --telegram.target.id=${TG_TARGET_ID}, --weather.api.key=${WEATHER_KEY}"]
|
||||||
ENTRYPOINT ["java","-Dtelegram.bot.key=${BOT_KEY}","-Dtelegram.my.id=${TG_MINE}","-Dtelegram.target.id=${TG_TARGET_ID}","-Dweather.api.key=${WEATHER_KEY}","-Dspring.datasource.url=${DATASOURCE_URL}" ,"-Dspring.data.mongodb.uri=${MONGODB_HOST}","-Dspring.data.mongodb.database=${MONGODB_NAME}","-Dspring.datasource.username=${MRA_ADMIN}","-Dspring.datasource.password=${MRA_PW}","-Dresource.handler=${RESOURCE_HANDLER}","-Dresource.location=${RESOURCE_LOCATION}","-Dimage.upload.path=${IMAGE_UPLOAD_PATH}","-Dpuzzle.image.path=${PUZZLE_IMAGE_UPLOAD_PATH}","-Dapi.gg.place=${GAPI_KEY}","-jar","app.jar"]
|
ENTRYPOINT ["java","-Dtelegram.bot.key=${BOT_KEY}","-Dtelegram.my.id=${TG_MINE}","-Dtelegram.target.id=${TG_TARGET_ID}","-Dweather.api.key=${WEATHER_KEY}","-Dspring.datasource.url=${DATASOURCE_URL}" ,"-Dspring.data.mongodb.uri=${MONGODB_HOST}","-Dspring.data.mongodb.database=${MONGODB_NAME}","-Dspring.datasource.username=${MRA_ADMIN}","-Dspring.datasource.password=${MRA_PW}","-Dresource.handler=${RESOURCE_HANDLER}","-Dresource.location=${RESOURCE_LOCATION}","-Dimage.upload.path=${IMAGE_UPLOAD_PATH}","-Dpuzzle.image.path=${PUZZLE_IMAGE_UPLOAD_PATH}","-Dapi.gg.place=${GAPI_KEY}","-Dapi.base-url=${API_BASE_URL}","-jar","app.jar"]
|
||||||
#-Dtelegram.bot.key=bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w -Dtelegram.target.id=71476436 -Dtelegram.my.id=71476436 -Dweather.api.key=de574a260b1f474d99955729241909 -Dspring.datasource.url=jdbc:mariadb://mra.sbspace.synology.me -Dspring.data.mongodb.uri=mongodb://lun_admin:VioPup*383@mongo.sbspace.synology.me/?wtimeoutMS=300&connectTimeoutMS=500&socketTimeoutMS=200 -Dspring.data.mongodb.database=lun_db -Dspring.datasource.username=lun_admin -Dspring.datasource.password=VioPup*383 -Dresource.handler=/blog/post/image/** -Dresource.location=file:///imgUpload -Dimage.upload.path=imgUpload
|
#-Dtelegram.bot.key=bot7934509464:AAE_xUbICxMdywLGnxo7BkeIqA1nVza4P9w -Dtelegram.target.id=71476436 -Dtelegram.my.id=71476436 -Dweather.api.key=de574a260b1f474d99955729241909 -Dspring.datasource.url=jdbc:mariadb://mra.sbspace.synology.me -Dspring.data.mongodb.uri=mongodb://lun_admin:VioPup*383@mongo.sbspace.synology.me/?wtimeoutMS=300&connectTimeoutMS=500&socketTimeoutMS=200 -Dspring.data.mongodb.database=lun_db -Dspring.datasource.username=lun_admin -Dspring.datasource.password=VioPup*383 -Dresource.handler=/blog/post/image/** -Dresource.location=file:///imgUpload -Dimage.upload.path=imgUpload
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import org.commonmark.parser.Parser
|
|||||||
import org.commonmark.renderer.html.HtmlRenderer
|
import org.commonmark.renderer.html.HtmlRenderer
|
||||||
import com.github.jk1.license.render.InventoryMarkdownReportRenderer
|
import com.github.jk1.license.render.InventoryMarkdownReportRenderer
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
|
import org.springframework.boot.gradle.tasks.bundling.BootJar
|
||||||
|
|
||||||
//import org.gradle.internal.impldep.org.jsoup.Jsoup
|
//import org.gradle.internal.impldep.org.jsoup.Jsoup
|
||||||
|
|
||||||
@ -220,6 +221,28 @@ tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게
|
|||||||
dependsOn(tasks.named("updateLicensePage"))
|
dependsOn(tasks.named("updateLicensePage"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 기본 bootJar 태스크의 설정을 가져오기 위한 참조
|
||||||
|
val bootJar by tasks.getting(BootJar::class)
|
||||||
|
|
||||||
|
// 'prod' 프로필이 내장된 JAR를 빌드하는 최종 태스크 정의
|
||||||
|
tasks.register<BootJar>("bootJarProd") {
|
||||||
|
group = "build"
|
||||||
|
description = "Builds a production JAR that defaults to the 'prod' profile."
|
||||||
|
archiveClassifier.set("prod")
|
||||||
|
|
||||||
|
// --- 필수 설정 복사 ---
|
||||||
|
// 1. Main 클래스 설정 복사
|
||||||
|
mainClass.set(bootJar.mainClass)
|
||||||
|
// 2. Classpath 설정 복사
|
||||||
|
classpath = bootJar.classpath
|
||||||
|
// 3. Target Java Version 설정 복사 (이번 오류 해결)
|
||||||
|
targetJavaVersion.set(bootJar.targetJavaVersion)
|
||||||
|
|
||||||
|
manifest {
|
||||||
|
attributes["Spring-Profiles-Active"] = "prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
//// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
|
//// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
|
||||||
//tasks.build {
|
//tasks.build {
|
||||||
|
|||||||
@ -0,0 +1,20 @@
|
|||||||
|
package kr.lunaticbum.back.lun.configs
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute
|
||||||
|
|
||||||
|
@ControllerAdvice // 이 클래스가 모든 컨트롤러에 적용될 것임을 선언
|
||||||
|
class GlobalControllerAdvice {
|
||||||
|
|
||||||
|
// application.properties에서 값을 주입받는 것은 동일
|
||||||
|
@Value("\${api.base-url}")
|
||||||
|
private lateinit var apiBaseUrl: String
|
||||||
|
|
||||||
|
// @ModelAttribute 어노테이션을 사용한 메서드를 정의
|
||||||
|
// 이 메서드의 반환값은 자동으로 모든 모델에 "apiBaseUrl"이라는 이름으로 추가됨
|
||||||
|
@ModelAttribute("apiBaseUrl")
|
||||||
|
fun addApiBaseUrlToModel(): String {
|
||||||
|
return apiBaseUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -81,7 +81,8 @@ class SecurityConfig(
|
|||||||
.csrf { csrf ->
|
.csrf { csrf ->
|
||||||
csrf.ignoringRequestMatchers(
|
csrf.ignoringRequestMatchers(
|
||||||
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
|
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
|
||||||
"/api/ranks/submit" // 통합 랭킹 API
|
"/api/ranks/submit", // 통합 랭킹 API
|
||||||
|
"/puzzle/**", // <-- 이 줄을 추가하세요.
|
||||||
)
|
)
|
||||||
}.authorizeHttpRequests { auth ->
|
}.authorizeHttpRequests { auth ->
|
||||||
auth
|
auth
|
||||||
@ -98,13 +99,16 @@ class SecurityConfig(
|
|||||||
"/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx",
|
"/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx",
|
||||||
"/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx",
|
"/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx",
|
||||||
"/blog/categories.bjx", "/blog/hashtags.bjx",
|
"/blog/categories.bjx", "/blog/hashtags.bjx",
|
||||||
"/puzzle/**", "/api/ranks/list", "/licenses"
|
"/puzzle/**", "/api/ranks/list", "/licenses",
|
||||||
|
"/puzzle/images/**"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
|
|
||||||
// 3. 공개 POST API = permitAll
|
// 3. 공개 POST API = permitAll
|
||||||
.requestMatchers(HttpMethod.POST,
|
.requestMatchers(HttpMethod.POST,
|
||||||
"/user/login.bjx", "/user/joinUser.bjx",
|
"/user/login.bjx", "/user/joinUser.bjx",
|
||||||
"/api/ranks/submit",
|
"/api/ranks/submit",
|
||||||
|
"/bums/save/loc.api",
|
||||||
|
"/puzzle/**", // <-- 이 줄을 추가하세요.
|
||||||
// [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용
|
// [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용
|
||||||
"/blog/post/*/like.bjx",
|
"/blog/post/*/like.bjx",
|
||||||
"/blog/post/*/unlike.bjx"
|
"/blog/post/*/unlike.bjx"
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import java.nio.file.Paths
|
|||||||
import java.nio.file.StandardCopyOption
|
import java.nio.file.StandardCopyOption
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
// --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 ---
|
// --- API 응답을 위한 DTO (Data Transfer Object) 클래스들 ---
|
||||||
|
|
||||||
@ -69,6 +70,8 @@ class BlogController(
|
|||||||
|
|
||||||
@Value("\${image.upload.path}")
|
@Value("\${image.upload.path}")
|
||||||
private val uploadPath: String? = null
|
private val uploadPath: String? = null
|
||||||
|
@Value("\${api.base-url}")
|
||||||
|
private lateinit var apiBaseUrl: String
|
||||||
|
|
||||||
private data class DeltaOp(val insert: Any)
|
private data class DeltaOp(val insert: Any)
|
||||||
private data class Delta(val ops: List<DeltaOp>)
|
private data class Delta(val ops: List<DeltaOp>)
|
||||||
@ -237,11 +240,25 @@ class BlogController(
|
|||||||
@ResponseBody
|
@ResponseBody
|
||||||
suspend fun home(): ResultMV {
|
suspend fun home(): ResultMV {
|
||||||
val vm = ResultMV("content/home")
|
val vm = ResultMV("content/home")
|
||||||
|
val defaultBannerImage = "/api/images/0e2bf8b1-1848-4650-b084-5b52d0815be9.jpg"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull()
|
var bannerImagePath: String? = null
|
||||||
if (randomImage != null) {
|
val randomImage: ImageMeta? = imageMetaService.getRandomBannerImage().awaitSingleOrNull()
|
||||||
vm.modelMap["randomBannerImage"] = randomImage.path
|
|
||||||
|
if (randomImage != null && !randomImage.path.isNullOrBlank()) {
|
||||||
|
// 1. 이미지 경로가 예전 방식인지 확인하고 수정합니다.
|
||||||
|
if (randomImage.path.contains("/blog/post/images/")) {
|
||||||
|
bannerImagePath = randomImage.path.replace("/blog/post/images/", "/api/images/")
|
||||||
|
} else {
|
||||||
|
bannerImagePath = randomImage.path
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 최종 경로가 유효하면 모델에 추가하고, 아니면 기본 이미지를 사용합니다.
|
||||||
|
vm.modelMap["randomBannerImage"] = if (!bannerImagePath.isNullOrBlank()) bannerImagePath else defaultBannerImage
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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) }
|
||||||
@ -254,6 +271,26 @@ class BlogController(
|
|||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [신규 추가] 이미지 배너 승인 API (관리자 전용)
|
||||||
|
@PostMapping("/api/images/{imageId}/approve-banner")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@ResponseBody
|
||||||
|
fun approveBannerImage(@PathVariable imageId: String): Mono<ResponseEntity<ImageMeta>> {
|
||||||
|
return imageMetaService.approveForBanner(imageId)
|
||||||
|
.map { ResponseEntity.ok(it) }
|
||||||
|
.defaultIfEmpty(ResponseEntity.notFound().build())
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규 추가] 이미지 배너 승인 해제 API (관리자 전용)
|
||||||
|
@PostMapping("/api/images/{imageId}/revoke-banner")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
@ResponseBody
|
||||||
|
fun revokeBannerImage(@PathVariable imageId: String): Mono<ResponseEntity<ImageMeta>> {
|
||||||
|
return imageMetaService.revokeBannerApproval(imageId)
|
||||||
|
.map { ResponseEntity.ok(it) }
|
||||||
|
.defaultIfEmpty(ResponseEntity.notFound().build())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [수정됨] 게시물 목록 페이지를 역할 기반으로 렌더링합니다.
|
* [수정됨] 게시물 목록 페이지를 역할 기반으로 렌더링합니다.
|
||||||
*/
|
*/
|
||||||
@ -553,11 +590,35 @@ class BlogController(
|
|||||||
}
|
}
|
||||||
val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}"
|
val uniqueFilename = "${UUID.randomUUID()}_${file.originalFilename}"
|
||||||
val targetPath = Paths.get(uploadPath, uniqueFilename)
|
val targetPath = Paths.get(uploadPath, uniqueFilename)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
// 1. 파일을 디스크에 저장
|
||||||
Files.createDirectories(targetPath.parent)
|
Files.createDirectories(targetPath.parent)
|
||||||
Files.copy(file.inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING)
|
file.transferTo(targetPath.toFile())
|
||||||
Mono.just(ImageUploadResponse(0, "Success", uniqueFilename))
|
|
||||||
} catch (e: IOException) {
|
// 2. 저장된 파일의 이미지 정보(가로/세로 크기) 읽기
|
||||||
|
val bufferedImage = ImageIO.read(targetPath.toFile())
|
||||||
|
val width = bufferedImage?.width ?: 0
|
||||||
|
val height = bufferedImage?.height ?: 0
|
||||||
|
|
||||||
|
// 3. DB에 저장할 ImageMeta 객체 생성
|
||||||
|
val imageMeta = ImageMeta(
|
||||||
|
fileName = uniqueFilename,
|
||||||
|
originalFileName = file.originalFilename,
|
||||||
|
fileType = file.contentType,
|
||||||
|
fileSize = file.size,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
uploadTime = System.currentTimeMillis(),
|
||||||
|
path = "/api/images/$uniqueFilename" // 새로운 API 경로 사용
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. 메타데이터를 DB에 저장하고, 성공하면 클라이언트에 응답
|
||||||
|
imageMetaService.save(imageMeta).map {
|
||||||
|
ImageUploadResponse(0, "Success", uniqueFilename)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logService.log("File upload or metadata save failed: ${e.message}")
|
||||||
Mono.just(ImageUploadResponse(2, "File save failed: ${e.message}", null))
|
Mono.just(ImageUploadResponse(2, "File save failed: ${e.message}", null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import kr.lunaticbum.back.lun.model.GameRank
|
|||||||
import kr.lunaticbum.back.lun.model.GameRankService
|
import kr.lunaticbum.back.lun.model.GameRankService
|
||||||
import kr.lunaticbum.back.lun.model.GameType
|
import kr.lunaticbum.back.lun.model.GameType
|
||||||
import kr.lunaticbum.back.lun.model.UnifiedRankDto
|
import kr.lunaticbum.back.lun.model.UnifiedRankDto
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
@ -14,12 +15,20 @@ import reactor.core.publisher.Mono
|
|||||||
class GameRankController(private val gameRankService: GameRankService) {
|
class GameRankController(private val gameRankService: GameRankService) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모든 게임을 위한 통합 랭킹 등록 엔드포인트
|
* [수정] 모든 게임을 위한 통합 랭킹 등록 엔드포인트 (에러 처리 추가)
|
||||||
*/
|
*/
|
||||||
@PostMapping("/submit")
|
@PostMapping("/submit")
|
||||||
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<GameRank>> {
|
fun submitUnifiedRank(@RequestBody rankDto: UnifiedRankDto): Mono<ResponseEntity<Any>> {
|
||||||
return gameRankService.submitRank(rankDto)
|
return gameRankService.submitRank(rankDto)
|
||||||
.map { savedRank -> ResponseEntity.ok(savedRank) }
|
.map { savedRank -> ResponseEntity.ok<Any>(savedRank) }
|
||||||
|
.onErrorResume(IllegalArgumentException::class.java) { e ->
|
||||||
|
// 서비스에서 이름 중복 예외가 발생하면 409 Conflict 상태와 에러 메시지를 반환
|
||||||
|
Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).body(e.message))
|
||||||
|
}
|
||||||
|
.onErrorResume {
|
||||||
|
// 기타 예외는 500 Internal Server Error로 처리
|
||||||
|
Mono.just(ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("랭킹 등록 중 서버 오류가 발생했습니다."))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,262 +0,0 @@
|
|||||||
//package kr.lunaticbum.back.lun.controllers
|
|
||||||
//
|
|
||||||
//import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
//import com.google.gson.Gson
|
|
||||||
//import com.google.gson.JsonObject
|
|
||||||
//import com.google.gson.JsonParser
|
|
||||||
//import jakarta.servlet.http.HttpServletResponse
|
|
||||||
//import kotlinx.coroutines.reactor.awaitSingle
|
|
||||||
//import kotlinx.coroutines.reactor.awaitSingleOrNull
|
|
||||||
//import kr.lunaticbum.back.lun.model.ImageMeta
|
|
||||||
//import kr.lunaticbum.back.lun.model.ImageMetaService
|
|
||||||
//import kr.lunaticbum.back.lun.model.Post
|
|
||||||
//import kr.lunaticbum.back.lun.model.PostManager
|
|
||||||
//import kr.lunaticbum.back.lun.model.ResultMV
|
|
||||||
//import kr.lunaticbum.back.lun.utils.LogService
|
|
||||||
//import net.coobird.thumbnailator.Thumbnails
|
|
||||||
//import org.commonmark.node.Node
|
|
||||||
//import org.commonmark.parser.Parser
|
|
||||||
//import org.commonmark.renderer.html.HtmlRenderer
|
|
||||||
//import org.jsoup.Jsoup
|
|
||||||
//import org.jsoup.nodes.Element
|
|
||||||
//import org.springframework.beans.factory.annotation.Autowired
|
|
||||||
//import org.springframework.beans.factory.annotation.Value
|
|
||||||
//import org.springframework.data.domain.Pageable
|
|
||||||
//import org.springframework.web.bind.annotation.GetMapping
|
|
||||||
//import org.springframework.web.bind.annotation.RequestMapping
|
|
||||||
//import org.springframework.web.bind.annotation.RestController
|
|
||||||
//import java.io.File
|
|
||||||
//import java.io.IOException
|
|
||||||
//import java.net.URLDecoder
|
|
||||||
//
|
|
||||||
//@RestController
|
|
||||||
//@RequestMapping()
|
|
||||||
//class Home {
|
|
||||||
//
|
|
||||||
// @Autowired
|
|
||||||
// private lateinit var imageMetaService: ImageMetaService
|
|
||||||
//
|
|
||||||
// @Autowired
|
|
||||||
// lateinit var logService: LogService
|
|
||||||
//
|
|
||||||
// @Autowired
|
|
||||||
// private lateinit var postManager: PostManager
|
|
||||||
//
|
|
||||||
// data class PostView(
|
|
||||||
// val id: Long,
|
|
||||||
// val title: String,
|
|
||||||
// val thumb: String?,
|
|
||||||
// val writeTime: Long,
|
|
||||||
// val textOnly: String,
|
|
||||||
// val firstImage: String?
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// data class DeltaOp(val insert: Any)
|
|
||||||
// data class Delta(val ops: List<DeltaOp>)
|
|
||||||
//
|
|
||||||
// fun extractFromDelta(deltaJson: String): Pair<String, String?> {
|
|
||||||
//
|
|
||||||
// val delta: Delta = Gson().fromJson(deltaJson, Delta::class.java)
|
|
||||||
//
|
|
||||||
// var textOnly = StringBuilder()
|
|
||||||
// var firstImage: String? = null
|
|
||||||
//
|
|
||||||
// delta.ops.forEach { op ->
|
|
||||||
// if (op.insert is String) {
|
|
||||||
// textOnly.append(op.insert)
|
|
||||||
// } else if (op.insert is Map<*, *>) {
|
|
||||||
// val obj = op.insert as Map<*, *>
|
|
||||||
// if (obj["image"] != null && firstImage == null) {
|
|
||||||
// firstImage = obj["image"].toString()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return textOnly.toString() to firstImage
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @GetMapping("/","/home.bs")
|
|
||||||
// suspend fun home() : ResultMV {
|
|
||||||
// val vm = ResultMV("content/home")
|
|
||||||
// try {
|
|
||||||
// try {
|
|
||||||
// // 1. [수정] awaitSingle() 대신 awaitSingleOrNull()을 사용합니다.
|
|
||||||
// // DB가 비어있으면(이미지 0개) Exception 대신 null을 반환합니다.
|
|
||||||
// val randomImage: ImageMeta? = imageMetaService.getRandomImage().awaitSingleOrNull() //
|
|
||||||
//
|
|
||||||
// // 2. [수정] randomImage 객체가 null이 아닐 경우(성공 시)에만 모델맵에 경로를 추가합니다.
|
|
||||||
// if (randomImage != null) {
|
|
||||||
// vm.modelMap.put("randomBannerImage", randomImage.path)
|
|
||||||
// }
|
|
||||||
// // 3. else (null인 경우): 아무것도 하지 않습니다.
|
|
||||||
// // 뷰(home.html)는 randomBannerImage 변수가 null이므로 기본 CSS 배너를 사용합니다.
|
|
||||||
//
|
|
||||||
// } catch (e: Exception) {
|
|
||||||
// // 4. (Fallback) DB 연결 오류 등 쿼리 자체의 심각한 오류 발생 시 로그만 남깁니다.
|
|
||||||
// logService.log("CRITICAL Error during random banner image query: ${e.message}")
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // === [FIXED LOGIC] ===
|
|
||||||
// // 1. Asynchronously await the Mono result without blocking the thread.
|
|
||||||
// // Use awaitSingleOrNull() just like the random image query.
|
|
||||||
// val postsList: List<Post> = postManager.find8().awaitSingleOrNull() ?: emptyList()
|
|
||||||
//
|
|
||||||
// // 2. Apply the processing logic to the resulting list.
|
|
||||||
// vm.modelMap.put("Posts", postsList.apply {
|
|
||||||
// this.forEach {
|
|
||||||
// it.title = URLDecoder.decode(it.title)
|
|
||||||
// it.content = URLDecoder.decode(it.content)
|
|
||||||
// val parser: Parser = Parser.builder().build()
|
|
||||||
// val document: Node = parser.parse(it.content)
|
|
||||||
// val renderer = HtmlRenderer.builder().build()
|
|
||||||
// println("content >>> ${it.content}")
|
|
||||||
// try {
|
|
||||||
// JsonParser.parseString(it.content)
|
|
||||||
// it.content?.let { content ->
|
|
||||||
// var delta = extractFromDelta(content)
|
|
||||||
// val firstImg = delta.second
|
|
||||||
// it.image = firstImg ?: "images/pic01.jpg"
|
|
||||||
// it.thumb = firstImg ?: "images/pic01.jpg"
|
|
||||||
// it.html = delta.first
|
|
||||||
// }
|
|
||||||
// } catch (e: Exception) {
|
|
||||||
// Jsoup.parse(renderer.render(document))?.let { doc ->
|
|
||||||
// val firstImg: Element? = doc.select("img")?.first()
|
|
||||||
// val imgSrc: String = firstImg?.attr("src") ?: ""
|
|
||||||
// it.image = imgSrc
|
|
||||||
// it.thumb = imgSrc.replace(imgSrc.split("/").last(), imgSrc.split("/").last().replace(".","_thumbnail."))
|
|
||||||
// generateThumbnail(imgSrc.split("/").last(), 200)
|
|
||||||
// it.html = doc.text()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// it.title = if ((it.title?.length ?: 0) >= 1) it.title else ""
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// // === [END FIXED LOGIC] ===
|
|
||||||
// }catch (ex: Exception){ex.printStackTrace()}
|
|
||||||
// vm.modelMap.put("path","/blog/viewer/")
|
|
||||||
// return vm
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @Value("\${image.upload.path}")
|
|
||||||
// private val uploadPath: String? = null
|
|
||||||
//
|
|
||||||
// @Value("\${resource.handler}")
|
|
||||||
// private val resourceHandler: String? = null
|
|
||||||
//
|
|
||||||
// fun generateThumbnail(originalPath: String, targetWidth: Int) {
|
|
||||||
// try {
|
|
||||||
// val originalFile = File("$uploadPath${File.separator}$originalPath")
|
|
||||||
// println("origin ${originalPath}")
|
|
||||||
// println("thumb ${originalPath
|
|
||||||
// .replace(".", "_thumbnail.")}")
|
|
||||||
// // 썸네일 경로 생성 (예: /upload/uuid.jpg → /upload/uuid_thumbnail.jpg)
|
|
||||||
// val thumbnailPath = originalPath
|
|
||||||
// .replace(".", "_thumbnail.")
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// val thumbnailFile = File("$uploadPath${File.separator}$thumbnailPath")
|
|
||||||
// // 썸네일 이미 존재하면 종료
|
|
||||||
// if (thumbnailFile.exists()) {
|
|
||||||
// println("썸네일 이미 존재: $thumbnailPath")
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// // 원본 파일 존재 확인
|
|
||||||
// if (!originalFile.exists()) {
|
|
||||||
// println("원본 파일 없음: $originalPath")
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // 썸네일 생성 (가로 기준 비율 유지)
|
|
||||||
// Thumbnails.of(originalFile)
|
|
||||||
// .width(targetWidth)
|
|
||||||
// .keepAspectRatio(true)
|
|
||||||
// .toFile(thumbnailFile)
|
|
||||||
//
|
|
||||||
// println("썸네일 생성 완료: $thumbnailFile")
|
|
||||||
// } catch (e: IOException) {
|
|
||||||
// println("썸네일 생성 실패: ${e.message}")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// @GetMapping("/h2")
|
|
||||||
// fun home2() : ResultMV {
|
|
||||||
// val vm = ResultMV("content/index_ex")
|
|
||||||
// vm.modelMap.put("Posts", postManager.find20().apply {
|
|
||||||
// this.forEach {
|
|
||||||
// it.title = URLDecoder.decode(it.title)
|
|
||||||
// it.content = URLDecoder.decode(it.content)
|
|
||||||
// logService.log(Gson().toJson(it))
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return vm
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @GetMapping("/left-sidebar")
|
|
||||||
// fun lside() : ResultMV {
|
|
||||||
// val vm = ResultMV("content/left-sidebar")
|
|
||||||
// vm.modelMap.put("Posts", postManager.find20().apply {
|
|
||||||
// this.forEach {
|
|
||||||
// it.title = URLDecoder.decode(it.title)
|
|
||||||
// it.content = URLDecoder.decode(it.content)
|
|
||||||
// logService.log(Gson().toJson(it))
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return vm
|
|
||||||
// }
|
|
||||||
// @GetMapping("/no-sidebar")
|
|
||||||
// fun nside() : ResultMV {
|
|
||||||
// val vm = ResultMV("content/no-sidebar")
|
|
||||||
// vm.modelMap.put("Posts", postManager.find20().apply {
|
|
||||||
// this.forEach {
|
|
||||||
// it.title = URLDecoder.decode(it.title)
|
|
||||||
// it.content = URLDecoder.decode(it.content)
|
|
||||||
// logService.log(Gson().toJson(it))
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return vm
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @GetMapping("/right-sidebar")
|
|
||||||
// fun rside() : ResultMV {
|
|
||||||
// val vm = ResultMV("content/right-sidebar")
|
|
||||||
// vm.modelMap.put("Posts", postManager.find20().apply {
|
|
||||||
// this.forEach {
|
|
||||||
// it.title = URLDecoder.decode(it.title)
|
|
||||||
// it.content = URLDecoder.decode(it.content)
|
|
||||||
// logService.log(Gson().toJson(it))
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return vm
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @GetMapping("/two-sidebar")
|
|
||||||
// fun bside() : ResultMV {
|
|
||||||
// val vm = ResultMV("content/two-sidebar")
|
|
||||||
// vm.modelMap.put("Posts", postManager.find20().apply {
|
|
||||||
// this.forEach {
|
|
||||||
// it.title = URLDecoder.decode(it.title)
|
|
||||||
// it.content = URLDecoder.decode(it.content)
|
|
||||||
// logService.log(Gson().toJson(it))
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// return vm
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// @GetMapping("/login")
|
|
||||||
// fun login(response: HttpServletResponse) {
|
|
||||||
// response.sendRedirect("/user/login")
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// @GetMapping("/licenses")
|
|
||||||
// fun licenses() : ResultMV {
|
|
||||||
// val vm = ResultMV("content/licenses")
|
|
||||||
// return vm
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
//}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
package kr.lunaticbum.back.lun.controllers
|
|
||||||
|
|
||||||
class Owner {
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -3,10 +3,13 @@ package kr.lunaticbum.back.lun.controllers
|
|||||||
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||||
import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
|
import kr.lunaticbum.back.lun.model.* // 필요한 모든 모델 클래스를 import
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.core.io.UrlResource
|
||||||
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.ui.Model
|
import org.springframework.ui.Model
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [통합 게임 API 허브 컨트롤러]
|
* [통합 게임 API 허브 컨트롤러]
|
||||||
@ -22,6 +25,25 @@ class PuzzleController(
|
|||||||
@Value("\${puzzle.image.path}") private val puzzleImagePath: String
|
@Value("\${puzzle.image.path}") private val puzzleImagePath: String
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
// [신규 추가] 저장된 퍼즐 이미지를 제공하는 API
|
||||||
|
@GetMapping("/images/{filename}")
|
||||||
|
fun getPuzzleImage(@PathVariable filename: String): ResponseEntity<Any> {
|
||||||
|
return try {
|
||||||
|
val path = Paths.get(puzzleImagePath).resolve(filename)
|
||||||
|
val resource = UrlResource(path.toUri())
|
||||||
|
|
||||||
|
if (resource.exists() || resource.isReadable) {
|
||||||
|
ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.IMAGE_PNG) // 이미지는 PNG로 저장했으므로
|
||||||
|
.body(resource)
|
||||||
|
} else {
|
||||||
|
ResponseEntity.notFound().build()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ResponseEntity.internalServerError().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ======================================================
|
// ======================================================
|
||||||
// 1. NONOGRAM API (기존 엔드포인트 유지)
|
// 1. NONOGRAM API (기존 엔드포인트 유지)
|
||||||
// ======================================================
|
// ======================================================
|
||||||
|
|||||||
@ -43,9 +43,12 @@ import kotlin.collections.emptyList
|
|||||||
@RequestMapping("/user")
|
@RequestMapping("/user")
|
||||||
class UserController(
|
class UserController(
|
||||||
private val rememberMeServices: RememberMeServices,
|
private val rememberMeServices: RememberMeServices,
|
||||||
private val userManager: UserManager, // 의존성 주입 추가
|
private val userManager: UserManager,
|
||||||
private val postManager: PostManager, // 의존성 주입 추가
|
private val postManager: PostManager,
|
||||||
private val commentService: CommentService // 의존성 주입 추가
|
private val commentService: CommentService,
|
||||||
|
private val gameRankService: GameRankService, // [신규 추가] GameRankService 의존성 주입
|
||||||
|
|
||||||
|
private val imageMetaService: ImageMetaService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
|
||||||
@ -235,7 +238,7 @@ class UserController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [신규 추가] '내 정보' 페이지를 위한 핸들러
|
* [수정] '내 정보' 페이지를 위한 핸들러 (게임 랭킹 조회 추가)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/info")
|
@GetMapping("/info")
|
||||||
suspend fun myInfoPage(@AuthenticationPrincipal userDetails: UserDetails?): ResultMV {
|
suspend fun myInfoPage(@AuthenticationPrincipal userDetails: UserDetails?): ResultMV {
|
||||||
@ -263,6 +266,11 @@ class UserController(
|
|||||||
// 3. 내가 쓴 댓글 목록 조회 (최신 10개)
|
// 3. 내가 쓴 댓글 목록 조회 (최신 10개)
|
||||||
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
|
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
|
||||||
vm.modelMap["myComments"] = myComments ?: emptyList()
|
vm.modelMap["myComments"] = myComments ?: emptyList()
|
||||||
|
|
||||||
|
// 4. [신규 추가] 내가 남긴 게임 랭킹 조회 (최신 20개)
|
||||||
|
val myRanks = gameRankService.getRanksByPlayer(username).take(20).collectList().block()
|
||||||
|
vm.modelMap["myRanks"] = myRanks ?: emptyList()
|
||||||
|
|
||||||
vm.modelMap["pageTitle"] = "내 정보" // 동적 페이지 제목 설정
|
vm.modelMap["pageTitle"] = "내 정보" // 동적 페이지 제목 설정
|
||||||
|
|
||||||
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
|
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
|
||||||
@ -273,6 +281,8 @@ class UserController(
|
|||||||
vm.modelMap["allUsers"] = userManager.findAllUsers().collectList().block()
|
vm.modelMap["allUsers"] = userManager.findAllUsers().collectList().block()
|
||||||
vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block()
|
vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block()
|
||||||
vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회
|
vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회
|
||||||
|
vm.modelMap["allImages"] = imageMetaService.getAllImages().collectList().block()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return vm
|
return vm
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import org.springframework.data.annotation.Id
|
|||||||
import org.springframework.data.mongodb.core.mapping.Document
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import org.springframework.data.repository.reactive.ReactiveSortingRepository
|
import org.springframework.data.repository.reactive.ReactiveSortingRepository
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
@ -70,11 +73,16 @@ interface GameRankRepository : ReactiveSortingRepository<GameRank, String> {
|
|||||||
gameType: GameType,
|
gameType: GameType,
|
||||||
contextId: String?
|
contextId: String?
|
||||||
): Flux<GameRank>
|
): Flux<GameRank>
|
||||||
|
|
||||||
|
// [신규 추가] 특정 플레이어의 랭킹을 최신순으로 조회
|
||||||
|
fun findByPlayerNameOrderByTimestampDesc(playerName: String): Flux<GameRank>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class GameRankService(private val rankRepository: GameRankRepository) {
|
class GameRankService(
|
||||||
|
private val rankRepository: GameRankRepository,
|
||||||
|
private val userManager: UserManager ) {
|
||||||
/**
|
/**
|
||||||
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
|
* 게임 타입에 따라 적절한 정렬 방식으로 랭킹을 조회합니다.
|
||||||
*/
|
*/
|
||||||
@ -91,9 +99,36 @@ class GameRankService(private val rankRepository: GameRankRepository) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공통 DTO를 받아 랭킹을 저장합니다.
|
* [수정] 공통 DTO를 받아 랭킹을 저장 (사용자 이름 중복 체크 로직 추가)
|
||||||
*/
|
*/
|
||||||
fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> {
|
fun submitRank(rankDto: UnifiedRankDto): Mono<GameRank> {
|
||||||
|
val auth = SecurityContextHolder.getContext().authentication
|
||||||
|
|
||||||
|
// 로그인 사용자인지, 비로그인(익명) 사용자인지 확인
|
||||||
|
val isAuthenticated = auth != null && auth.isAuthenticated && auth !is AnonymousAuthenticationToken
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// 로그인 사용자: DTO의 playerName을 실제 로그인한 사용자의 ID로 강제 설정 (보안 강화)
|
||||||
|
val principal = auth.principal as UserDetails
|
||||||
|
val authenticatedUsername = principal.username
|
||||||
|
|
||||||
|
val gameRank = GameRank(
|
||||||
|
gameType = rankDto.gameType,
|
||||||
|
contextId = rankDto.contextId,
|
||||||
|
playerName = authenticatedUsername, // 실제 인증된 이름 사용
|
||||||
|
primaryScore = rankDto.primaryScore,
|
||||||
|
secondaryScore = rankDto.secondaryScore
|
||||||
|
)
|
||||||
|
return rankRepository.save(gameRank)
|
||||||
|
} else {
|
||||||
|
// 비로그인 사용자: 입력한 이름이 기존 회원 ID와 중복되는지 확인
|
||||||
|
return userManager.findById(rankDto.playerName)
|
||||||
|
.flatMap<GameRank> { existingUser ->
|
||||||
|
// 사용자가 존재하면 에러 발생
|
||||||
|
Mono.error(IllegalArgumentException("이미 등록된 회원의 이름입니다. 다른 이름을 사용해주세요."))
|
||||||
|
}
|
||||||
|
.switchIfEmpty(Mono.defer {
|
||||||
|
// 사용자가 존재하지 않으면 랭킹 저장 진행
|
||||||
val gameRank = GameRank(
|
val gameRank = GameRank(
|
||||||
gameType = rankDto.gameType,
|
gameType = rankDto.gameType,
|
||||||
contextId = rankDto.contextId,
|
contextId = rankDto.contextId,
|
||||||
@ -101,6 +136,15 @@ class GameRankService(private val rankRepository: GameRankRepository) {
|
|||||||
primaryScore = rankDto.primaryScore,
|
primaryScore = rankDto.primaryScore,
|
||||||
secondaryScore = rankDto.secondaryScore
|
secondaryScore = rankDto.secondaryScore
|
||||||
)
|
)
|
||||||
return rankRepository.save(gameRank)
|
rankRepository.save(gameRank)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다.
|
||||||
|
*/
|
||||||
|
fun getRanksByPlayer(playerName: String): Flux<GameRank> {
|
||||||
|
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12,11 +12,13 @@ import org.bson.codecs.pojo.annotations.BsonId
|
|||||||
import org.bson.codecs.pojo.annotations.BsonRepresentation
|
import org.bson.codecs.pojo.annotations.BsonRepresentation
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.data.mongodb.core.mapping.Document
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
import org.springframework.data.mongodb.repository.Aggregation
|
import org.springframework.data.mongodb.repository.Aggregation
|
||||||
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import reactor.core.publisher.Flux
|
||||||
import reactor.core.publisher.Mono
|
import reactor.core.publisher.Mono
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
@ -37,7 +39,9 @@ data class ImageMeta(
|
|||||||
var width: Int, // 이미지 가로 픽셀
|
var width: Int, // 이미지 가로 픽셀
|
||||||
var height: Int, // 이미지 세로 픽셀
|
var height: Int, // 이미지 세로 픽셀
|
||||||
var uploadTime: Long, // 등록일시 (Timestamp)
|
var uploadTime: Long, // 등록일시 (Timestamp)
|
||||||
var path: String // 이미지 접근 가능 URL 경로
|
var path: String, // 이미지 접근 가능 URL 경로
|
||||||
|
|
||||||
|
var isBannerCandidate: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -48,6 +52,13 @@ interface ImageMetaRepository : ReactiveMongoRepository<ImageMeta, String> {
|
|||||||
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
|
@Aggregation(pipeline = [ "{ \$sample: { size: 1 } }" ])
|
||||||
fun findRandomImage(): Mono<ImageMeta>
|
fun findRandomImage(): Mono<ImageMeta>
|
||||||
|
|
||||||
|
// [신규 추가] isBannerCandidate가 true인 이미지 중에서만 랜덤으로 1개를 선택
|
||||||
|
@Aggregation(pipeline = [
|
||||||
|
"{ \$match: { isBannerCandidate: true } }",
|
||||||
|
"{ \$sample: { size: 1 } }"
|
||||||
|
])
|
||||||
|
fun findRandomBannerCandidate(): Mono<ImageMeta>
|
||||||
|
|
||||||
fun findByFileName(fileName: String): Mono<ImageMeta>
|
fun findByFileName(fileName: String): Mono<ImageMeta>
|
||||||
|
|
||||||
// [신규 추가] 파일 이름 리스트를 기반으로 문서를 전부 삭제하는 기능
|
// [신규 추가] 파일 이름 리스트를 기반으로 문서를 전부 삭제하는 기능
|
||||||
@ -87,6 +98,7 @@ class ImageMetaService(
|
|||||||
/**
|
/**
|
||||||
* [신규 추가] Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너
|
* [신규 추가] Spring Boot가 준비되었을 때(부팅 완료) 실행되는 리스너
|
||||||
*/
|
*/
|
||||||
|
@Profile("!local")
|
||||||
@EventListener(ApplicationReadyEvent::class)
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
fun onApplicationReady() {
|
fun onApplicationReady() {
|
||||||
logService.log("Application ready. Launching initial image DB sync task...")
|
logService.log("Application ready. Launching initial image DB sync task...")
|
||||||
@ -178,4 +190,33 @@ class ImageMetaService(
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [이름 변경 및 로직 수정] 기존 getRandomImage -> getRandomBannerImage
|
||||||
|
* 배너 후보로 지정된 이미지 중에서 랜덤으로 하나를 가져옵니다.
|
||||||
|
*/
|
||||||
|
fun getRandomBannerImage(): Mono<ImageMeta> {
|
||||||
|
return repository.findRandomBannerCandidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규 추가] 관리자 페이지에서 모든 이미지를 조회하기 위한 메서드
|
||||||
|
fun getAllImages(): Flux<ImageMeta> {
|
||||||
|
return repository.findAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규 추가] 특정 이미지를 배너 후보로 승인하는 메서드
|
||||||
|
fun approveForBanner(imageId: String): Mono<ImageMeta> {
|
||||||
|
return repository.findById(imageId).flatMap { image ->
|
||||||
|
image.isBannerCandidate = true
|
||||||
|
repository.save(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// [신규 추가] 특정 이미지의 배너 후보 자격을 해제하는 메서드
|
||||||
|
fun revokeBannerApproval(imageId: String): Mono<ImageMeta> {
|
||||||
|
return repository.findById(imageId).flatMap { image ->
|
||||||
|
image.isBannerCandidate = false
|
||||||
|
repository.save(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -35,7 +35,9 @@ import java.time.Duration
|
|||||||
import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가]
|
import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가]
|
||||||
import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가]
|
import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가]
|
||||||
import org.springframework.data.mongodb.core.index.Indexed // [신규 추가]
|
import org.springframework.data.mongodb.core.index.Indexed // [신규 추가]
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
@Document(collection = "Post")
|
@Document(collection = "Post")
|
||||||
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
|
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
|
||||||
@ -121,19 +123,28 @@ interface CommentRepository : ReactiveMongoRepository<Comment, String> {
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
class CommentService(private val commentRepository: CommentRepository) {
|
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> {
|
fun getRepliesForComment(parentId: String): Flux<Comment> {
|
||||||
return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId)
|
return commentRepository.findByParentIdOrderByWriteTimeAsc(parentId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||||
}
|
}
|
||||||
fun addComment(comment: Comment): Mono<Comment> {
|
fun addComment(comment: Comment): Mono<Comment> {
|
||||||
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리
|
// 예시: 부모 댓글 존재 여부/권한 검증 등 비즈니스 로직 처리
|
||||||
return commentRepository.save(comment)
|
return commentRepository.save(comment).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCommentsForPost(postId: String): Flux<Comment> {
|
fun getCommentsForPost(postId: String): Flux<Comment> {
|
||||||
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId)
|
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||||
}
|
}
|
||||||
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
|
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
|
||||||
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable)
|
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable).map { decodeCommentContent(it) } // [수정] 디코딩 로직 추가
|
||||||
}
|
}
|
||||||
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
|
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
|
||||||
}
|
}
|
||||||
@ -281,8 +292,16 @@ class PostManager(
|
|||||||
.collectList()
|
.collectList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> { // [신규 추가]
|
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> {
|
||||||
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -310,14 +329,20 @@ class PostManager(
|
|||||||
* 인증된 사용자를 위한 메서드 (모든 버전 조회)
|
* 인증된 사용자를 위한 메서드 (모든 버전 조회)
|
||||||
* [FIX]: Change return type to Mono<List<Post>> and remove the blocking call.
|
* [FIX]: Change return type to Mono<List<Post>> and remove the blocking call.
|
||||||
*/
|
*/
|
||||||
fun findAllVersionsPaginated(pageable :Pageable) : Mono<List<Post>> { // <-- 1. Change return type
|
fun findAllVersionsPaginated(pageable :Pageable) : Mono<List<Post>> {
|
||||||
println("pageSize >>> ${pageable.pageSize}")
|
|
||||||
println("pageNumber >>> ${pageable.pageNumber}")
|
|
||||||
return postRepository.findAllByOrderByModifyTimeDesc(pageable)
|
return postRepository.findAllByOrderByModifyTimeDesc(pageable)
|
||||||
.doOnNext { println(it) } // map 대신 doOnNext로 로그 출력
|
.map { post ->
|
||||||
.collectList() // Flux<Post> → Mono<List<Post>>
|
// 1. 제목을 UTF-8로 디코딩합니다.
|
||||||
// .block(Duration.ofSeconds(30)) // <-- 2. REMOVE THIS BLOCK
|
post.title = post.title?.let { URLDecoder.decode(it, "UTF-8") } ?: ""
|
||||||
// ?: listOf()
|
|
||||||
|
// 2. 제목이 비어있으면 작성 시간을 기반으로 기본 제목을 설정합니다.
|
||||||
|
if (post.title.isNullOrBlank()) {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm")
|
||||||
|
post.title = "무제(無題) [${sdf.format(Date(post.writeTime))}]"
|
||||||
|
}
|
||||||
|
post // 수정된 post 객체를 반환
|
||||||
|
}
|
||||||
|
.collectList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -51,7 +51,9 @@ data class NonogramPuzzle(
|
|||||||
@Repository
|
@Repository
|
||||||
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
|
interface NonogramPuzzleRepository : ReactiveMongoRepository<NonogramPuzzle, String> {
|
||||||
@Aggregation(pipeline = [
|
@Aggregation(pipeline = [
|
||||||
"{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
|
// "{ \$match: { originalImage: { \$exists: true }, grayscaleImage: { \$exists: true } } }",
|
||||||
|
// "{ \$sample: { size: 1 } }"
|
||||||
|
"{ \$match: { originalImageFile: { \$exists: true }, grayscaleImageFile: { \$exists: true } } }",
|
||||||
"{ \$sample: { size: 1 } }"
|
"{ \$sample: { size: 1 } }"
|
||||||
])
|
])
|
||||||
fun findRandom(): Flux<NonogramPuzzle> // (★ Flux를 인식하기 위해 import 필요)
|
fun findRandom(): Flux<NonogramPuzzle> // (★ Flux를 인식하기 위해 import 필요)
|
||||||
|
|||||||
@ -145,7 +145,7 @@ class UserManager(
|
|||||||
// return userRepository.findByEmail(id)
|
// return userRepository.findByEmail(id)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
override fun findById(id: String): Mono<User>? {
|
override fun findById(id: String): Mono<User> {
|
||||||
return userRepository.findById(id)
|
return userRepository.findById(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
103
src/main/resources/application-local.properties
Normal file
103
src/main/resources/application-local.properties
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
spring.application.name=lun
|
||||||
|
server.port=443
|
||||||
|
spring.datasource.username=c
|
||||||
|
spring.datasource.password=c
|
||||||
|
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
|
||||||
|
#<<<<<<< HEAD
|
||||||
|
#spring.data.mongodb.host=nas.lunaticbum.kr
|
||||||
|
#spring.data.mongodb.host=localhost
|
||||||
|
#spring.data.mongodb.port=27017
|
||||||
|
#spring.data.mongodb.database=lun_db
|
||||||
|
#SSL
|
||||||
|
#server.ssl.key-store=classpath:prv.p12
|
||||||
|
#server.ssl.key-store-type=PKCS12
|
||||||
|
#server.ssl.key-store-password=VioPup*383
|
||||||
|
#server.http2.enabled=true
|
||||||
|
#spring.main.web-application-type=SERVLET
|
||||||
|
#logging.level.org.springframework.boot.autoconfigure=ERROR
|
||||||
|
#spring.mvc.view.prefix=/templates
|
||||||
|
#spring.mvc.view.suffix=.html
|
||||||
|
#server.servlet.register-default-servlet=true
|
||||||
|
#=======
|
||||||
|
spring.datasource.url=b
|
||||||
|
spring.data.mongodb.uri=a
|
||||||
|
spring.data.mongodb.authentication-database=admin
|
||||||
|
spring.data.mongodb.database=l
|
||||||
|
spring.thymeleaf.prefix=classpath:/templates/
|
||||||
|
spring.thymeleaf.suffix=.html
|
||||||
|
spring.thymeleaf.enabled=true
|
||||||
|
|
||||||
|
spring.servlet.multipart.max-file-size=1024MB
|
||||||
|
spring.servlet.multipart.max-request-size=1024MB
|
||||||
|
spring.servlet.multipart.enabled=true
|
||||||
|
|
||||||
|
# ?? ???? ??? ?? ? ?? ????.
|
||||||
|
spring.devtools.livereload.enabled=true
|
||||||
|
# thymeleaf? ?? ??? ??? ???. cache=false ??(???? true)
|
||||||
|
spring.thymeleaf.cache=false
|
||||||
|
# templates ????? ??? ??? ??? ??, ??? ??? ?????.
|
||||||
|
spring.thymeleaf.check-template-location=true
|
||||||
|
telegram.bot.key=1
|
||||||
|
telegram.my.id=2
|
||||||
|
telegram.target.id=3
|
||||||
|
weather.api.key=3
|
||||||
|
api.gg.place=5
|
||||||
|
spring.data.mongodb.option.min-connection-per-host=0
|
||||||
|
spring.data.mongodb.option.max-connection-per-host=100
|
||||||
|
spring.data.mongodb.option.threads-allowed-to-block-for-connection-multiplier=5
|
||||||
|
spring.data.mongodb.option.server-selection-timeout=30000
|
||||||
|
spring.data.mongodb.option.max-wait-time=120000
|
||||||
|
spring.data.mongodb.option.max-connection-idle-time=0
|
||||||
|
spring.data.mongodb.option.max-connection-life-time=0
|
||||||
|
spring.data.mongodb.option.connect-timeout=10000
|
||||||
|
spring.data.mongodb.option.socket-timeout=0
|
||||||
|
|
||||||
|
spring.data.mongodb.option.socket-keep-alive=false
|
||||||
|
spring.data.mongodb.option.ssl-enabled=false
|
||||||
|
spring.data.mongodb.option.ssl-invalid-host-name-allowed=false
|
||||||
|
spring.data.mongodb.option.always-use-m-beans=false
|
||||||
|
|
||||||
|
spring.data.mongodb.option.heartbeat-socket-timeout=20000
|
||||||
|
spring.data.mongodb.option.heartbeat-connect-timeout=20000
|
||||||
|
spring.data.mongodb.option.min-heartbeat-frequency=500
|
||||||
|
spring.data.mongodb.option.heartbeat-frequency=10000
|
||||||
|
spring.data.mongodb.option.local-threshold=15
|
||||||
|
|
||||||
|
spring.ai.ollama.base-url=https://lama.lunaticbum.kr
|
||||||
|
#spring.ai.ollama.chat.options.model=phi4:14b
|
||||||
|
|
||||||
|
##spring.data.redis.url=ollama.lunaticbum.kr
|
||||||
|
#spring.data.redis.host=lunaticbum.kr
|
||||||
|
#spring.data.redis.port=6379
|
||||||
|
#
|
||||||
|
##spring.ai.vectorstore.redis.uri="redis://lunaticbum.kr:6379"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#spring.ai.vectorstore.redis.initialize-schema=true
|
||||||
|
#spring.ai.vectorstore.redis.index=spring-ai-redis-index
|
||||||
|
#spring.ai.vectorstore.redis.prefix=spring-ai-redis-embedding
|
||||||
|
#https://ollama.lunaticbum.kr/collections/blama_vectors
|
||||||
|
spring.ai.vectorstore.qdrant.host=ollama.lunaticbum.kr
|
||||||
|
spring.ai.vectorstore.qdrant.port=443
|
||||||
|
#spring.ai.vectorstore.qdrant.initialize-schema=true
|
||||||
|
spring.ai.vectorstore.qdrant.api-key=blama-admin-key-gb
|
||||||
|
spring.ai.vectorstore.qdrant.collection-name=blama_vectors
|
||||||
|
#spring.ai.ollama.embedding.model=nomic-embed-text
|
||||||
|
|
||||||
|
spring.ai.ollama.embedding.enabled=true
|
||||||
|
|
||||||
|
resource.handler=.
|
||||||
|
resource.location=.
|
||||||
|
server.forward-headers-strategy=framework
|
||||||
|
#>>>>>>> ab915d0a416c69708f1df1ad76d7a14c779c1f59
|
||||||
|
logging.level.org.thymeleaf=DEBUG
|
||||||
|
spring.jpa.show-sql=true
|
||||||
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
|
logging.level.org.hibernate.SQL=DEBUG
|
||||||
|
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
||||||
|
|
||||||
|
# Increase server connection timeout to 60 seconds (default is often 20 or 30s)
|
||||||
|
server.tomcat.connection-timeout=60s
|
||||||
|
# For reactive applications (like yours), also set this timeout
|
||||||
|
spring.webflux.response-timeout=60s
|
||||||
|
api.base-url=ss
|
||||||
103
src/main/resources/application-prod.properties
Normal file
103
src/main/resources/application-prod.properties
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
spring.application.name=lun
|
||||||
|
server.port=443
|
||||||
|
spring.datasource.username=c
|
||||||
|
spring.datasource.password=c
|
||||||
|
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
|
||||||
|
#<<<<<<< HEAD
|
||||||
|
#spring.data.mongodb.host=nas.lunaticbum.kr
|
||||||
|
#spring.data.mongodb.host=localhost
|
||||||
|
#spring.data.mongodb.port=27017
|
||||||
|
#spring.data.mongodb.database=lun_db
|
||||||
|
#SSL
|
||||||
|
#server.ssl.key-store=classpath:prv.p12
|
||||||
|
#server.ssl.key-store-type=PKCS12
|
||||||
|
#server.ssl.key-store-password=VioPup*383
|
||||||
|
#server.http2.enabled=true
|
||||||
|
#spring.main.web-application-type=SERVLET
|
||||||
|
#logging.level.org.springframework.boot.autoconfigure=ERROR
|
||||||
|
#spring.mvc.view.prefix=/templates
|
||||||
|
#spring.mvc.view.suffix=.html
|
||||||
|
#server.servlet.register-default-servlet=true
|
||||||
|
#=======
|
||||||
|
spring.datasource.url=b
|
||||||
|
spring.data.mongodb.uri=a
|
||||||
|
spring.data.mongodb.authentication-database=admin
|
||||||
|
spring.data.mongodb.database=l
|
||||||
|
spring.thymeleaf.prefix=classpath:/templates/
|
||||||
|
spring.thymeleaf.suffix=.html
|
||||||
|
spring.thymeleaf.enabled=true
|
||||||
|
|
||||||
|
spring.servlet.multipart.max-file-size=1024MB
|
||||||
|
spring.servlet.multipart.max-request-size=1024MB
|
||||||
|
spring.servlet.multipart.enabled=true
|
||||||
|
|
||||||
|
# ?? ???? ??? ?? ? ?? ????.
|
||||||
|
spring.devtools.livereload.enabled=true
|
||||||
|
# thymeleaf? ?? ??? ??? ???. cache=false ??(???? true)
|
||||||
|
spring.thymeleaf.cache=false
|
||||||
|
# templates ????? ??? ??? ??? ??, ??? ??? ?????.
|
||||||
|
spring.thymeleaf.check-template-location=true
|
||||||
|
telegram.bot.key=1
|
||||||
|
telegram.my.id=2
|
||||||
|
telegram.target.id=3
|
||||||
|
weather.api.key=3
|
||||||
|
api.gg.place=5
|
||||||
|
spring.data.mongodb.option.min-connection-per-host=0
|
||||||
|
spring.data.mongodb.option.max-connection-per-host=100
|
||||||
|
spring.data.mongodb.option.threads-allowed-to-block-for-connection-multiplier=5
|
||||||
|
spring.data.mongodb.option.server-selection-timeout=30000
|
||||||
|
spring.data.mongodb.option.max-wait-time=120000
|
||||||
|
spring.data.mongodb.option.max-connection-idle-time=0
|
||||||
|
spring.data.mongodb.option.max-connection-life-time=0
|
||||||
|
spring.data.mongodb.option.connect-timeout=10000
|
||||||
|
spring.data.mongodb.option.socket-timeout=0
|
||||||
|
|
||||||
|
spring.data.mongodb.option.socket-keep-alive=false
|
||||||
|
spring.data.mongodb.option.ssl-enabled=false
|
||||||
|
spring.data.mongodb.option.ssl-invalid-host-name-allowed=false
|
||||||
|
spring.data.mongodb.option.always-use-m-beans=false
|
||||||
|
|
||||||
|
spring.data.mongodb.option.heartbeat-socket-timeout=20000
|
||||||
|
spring.data.mongodb.option.heartbeat-connect-timeout=20000
|
||||||
|
spring.data.mongodb.option.min-heartbeat-frequency=500
|
||||||
|
spring.data.mongodb.option.heartbeat-frequency=10000
|
||||||
|
spring.data.mongodb.option.local-threshold=15
|
||||||
|
|
||||||
|
spring.ai.ollama.base-url=https://lama.lunaticbum.kr
|
||||||
|
#spring.ai.ollama.chat.options.model=phi4:14b
|
||||||
|
|
||||||
|
##spring.data.redis.url=ollama.lunaticbum.kr
|
||||||
|
#spring.data.redis.host=lunaticbum.kr
|
||||||
|
#spring.data.redis.port=6379
|
||||||
|
#
|
||||||
|
##spring.ai.vectorstore.redis.uri="redis://lunaticbum.kr:6379"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
#spring.ai.vectorstore.redis.initialize-schema=true
|
||||||
|
#spring.ai.vectorstore.redis.index=spring-ai-redis-index
|
||||||
|
#spring.ai.vectorstore.redis.prefix=spring-ai-redis-embedding
|
||||||
|
#https://ollama.lunaticbum.kr/collections/blama_vectors
|
||||||
|
spring.ai.vectorstore.qdrant.host=ollama.lunaticbum.kr
|
||||||
|
spring.ai.vectorstore.qdrant.port=443
|
||||||
|
#spring.ai.vectorstore.qdrant.initialize-schema=true
|
||||||
|
spring.ai.vectorstore.qdrant.api-key=blama-admin-key-gb
|
||||||
|
spring.ai.vectorstore.qdrant.collection-name=blama_vectors
|
||||||
|
#spring.ai.ollama.embedding.model=nomic-embed-text
|
||||||
|
|
||||||
|
spring.ai.ollama.embedding.enabled=true
|
||||||
|
|
||||||
|
resource.handler=.
|
||||||
|
resource.location=.
|
||||||
|
server.forward-headers-strategy=framework
|
||||||
|
#>>>>>>> ab915d0a416c69708f1df1ad76d7a14c779c1f59
|
||||||
|
logging.level.org.thymeleaf=DEBUG
|
||||||
|
spring.jpa.show-sql=true
|
||||||
|
spring.jpa.properties.hibernate.format_sql=true
|
||||||
|
logging.level.org.hibernate.SQL=DEBUG
|
||||||
|
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
||||||
|
|
||||||
|
# Increase server connection timeout to 60 seconds (default is often 20 or 30s)
|
||||||
|
server.tomcat.connection-timeout=60s
|
||||||
|
# For reactive applications (like yours), also set this timeout
|
||||||
|
spring.webflux.response-timeout=60s
|
||||||
|
api.base-url=ss
|
||||||
@ -100,3 +100,4 @@ logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
|
|||||||
server.tomcat.connection-timeout=60s
|
server.tomcat.connection-timeout=60s
|
||||||
# For reactive applications (like yours), also set this timeout
|
# For reactive applications (like yours), also set this timeout
|
||||||
spring.webflux.response-timeout=60s
|
spring.webflux.response-timeout=60s
|
||||||
|
api.base-url=ss
|
||||||
@ -89,7 +89,7 @@ button:disabled {
|
|||||||
|
|
||||||
/* (★ 통일) 게임 컨트롤/랭킹 등을 감싸는 공통 '카드' UI (변수 사용) */
|
/* (★ 통일) 게임 컨트롤/랭킹 등을 감싸는 공통 '카드' UI (변수 사용) */
|
||||||
#sudoku-game-app .container,
|
#sudoku-game-app .container,
|
||||||
.ranking-container,
|
.game-body-wrapper .ranking-container, /* <-- 이렇게 수정하세요 */
|
||||||
#setup-container,
|
#setup-container,
|
||||||
#game-controls {
|
#game-controls {
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
|
|||||||
@ -110,9 +110,9 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 1. 사이드바 목록 가져오기 (이 기능은 이미 바닐라 JS였습니다) ---
|
// --- 1. 사이드바 목록 가져오기 (이 기능은 이미 바닐라 JS였습니다) ---
|
||||||
if (document.querySelector(".rank_of_view")) {
|
// if (document.querySelector(".rank_of_view")) {
|
||||||
fetchRankOfViews();
|
// fetchRankOfViews();
|
||||||
}
|
// }
|
||||||
if (document.querySelector(".recent_posts")) {
|
if (document.querySelector(".recent_posts")) {
|
||||||
fetchRecentPosts();
|
fetchRecentPosts();
|
||||||
}
|
}
|
||||||
@ -165,7 +165,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// isLoggedIn 변수를 사용하여 추가적인 클라이언트 측 방어
|
// isLoggedIn 변수를 사용하여 추가적인 클라이언트 측 방어
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
alert('로그인이 필요합니다.');
|
showAlert("알림",'로그인이 필요합니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
submitComment();
|
submitComment();
|
||||||
@ -421,7 +421,7 @@ function selectLocalVideo() {
|
|||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
if (!file || !file.type.startsWith('video/')) {
|
if (!file || !file.type.startsWith('video/')) {
|
||||||
alert('동영상 파일만 업로드할 수 있습니다.');
|
showAlert("알림",'동영상 파일만 업로드할 수 있습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadVideo(file);
|
uploadVideo(file);
|
||||||
@ -617,7 +617,7 @@ function save() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadUrl = `${getMainPath()}/blog/post.bjx`;
|
const uploadUrl = `${getMainPath()}/blog/post.bjx`;
|
||||||
if (confirm("해당 내용으로 저장하시겠습니까?")) {
|
if (showConfirm("확인","해당 내용으로 저장하시겠습니까?")) {
|
||||||
console.log("Data being sent to server:", dataToSend);
|
console.log("Data being sent to server:", dataToSend);
|
||||||
|
|
||||||
// 5. 서버로 전송 (바닐라 XHR 헬퍼 함수 사용)
|
// 5. 서버로 전송 (바닐라 XHR 헬퍼 함수 사용)
|
||||||
@ -626,14 +626,14 @@ function save() {
|
|||||||
const response = JSON.parse(resultData);
|
const response = JSON.parse(resultData);
|
||||||
if (response.resultCode === 0 && response.data && response.data.postId) {
|
if (response.resultCode === 0 && response.data && response.data.postId) {
|
||||||
// 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동
|
// 6. 저장 성공 시: 서버가 돌려준 새 ID를 이용해 뷰어 페이지로 이동
|
||||||
alert("저장되었습니다. 게시물 보기 페이지로 이동합니다.");
|
showAlert("알림","저장되었습니다. 게시물 보기 페이지로 이동합니다.");
|
||||||
location.href = getMainPath() + "/blog/viewer/" + response.data.postId;
|
location.href = getMainPath() + "/blog/viewer/" + response.data.postId;
|
||||||
} else {
|
} else {
|
||||||
alert("저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error"));
|
showAlert("알림","저장되었으나 페이지 이동에 실패했습니다: " + (response.resultMsg || "Unknown error"));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to parse save response:", e, resultData);
|
console.error("Failed to parse save response:", e, resultData);
|
||||||
alert("저장에 성공했으나 서버 응답을 처리할 수 없습니다.");
|
showAlert("알림","저장에 성공했으나 서버 응답을 처리할 수 없습니다.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -805,7 +805,7 @@ function submitLoginForm() {
|
|||||||
if (response.isOk) {
|
if (response.isOk) {
|
||||||
location.reload(); // 로그인 성공 시 페이지 새로고침
|
location.reload(); // 로그인 성공 시 페이지 새로고침
|
||||||
} else {
|
} else {
|
||||||
alert(`로그인 실패: ${response.resultMsg}`);
|
showAlert(`로그인 실패`, `${response.resultMsg}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -843,7 +843,7 @@ function onclickJoin(type, keyword) {
|
|||||||
case user_id :
|
case user_id :
|
||||||
if (korean.test(text)) {
|
if (korean.test(text)) {
|
||||||
hasValues = false
|
hasValues = false
|
||||||
alert("id를 확인 해보슈.");
|
showAlert("알림","id를 확인 해보슈.");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case user_pw :
|
case user_pw :
|
||||||
@ -854,23 +854,23 @@ function onclickJoin(type, keyword) {
|
|||||||
false === spPattern.test(text)
|
false === spPattern.test(text)
|
||||||
) {
|
) {
|
||||||
hasValues = false
|
hasValues = false
|
||||||
alert("pw 한글 노노 영문 숫자 특문(~!@#$%<>^&*) 섞으셈.");
|
showAlert("알림","pw 한글 노노 영문 숫자 특문(~!@#$%<>^&*) 섞으셈.");
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case user_email : if(false === email.test(field.value)) {
|
case user_email : if(false === email.test(field.value)) {
|
||||||
hasValues = false
|
hasValues = false
|
||||||
alert("email를 확인 해보슈.");
|
showAlert("알림","email를 확인 해보슈.");
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else if (hasValues) {
|
} else if (hasValues) {
|
||||||
hasValues = false
|
hasValues = false
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case user_id : alert("id를 확인 해보슈.");break
|
case user_id : showAlert("알림","id를 확인 해보슈.");break
|
||||||
case user_pw : alert("pw를 확인 해보슈.");break
|
case user_pw : showAlert("알림","pw를 확인 해보슈.");break
|
||||||
case user_pw_check : alert("pw를 확인 해보슈.");break
|
case user_pw_check : showAlert("알림","pw를 확인 해보슈.");break
|
||||||
case user_name : alert("name를 확인 해보슈.");break
|
case user_name : showAlert("알림","name를 확인 해보슈.");break
|
||||||
case user_email : alert("email를 확인 해보슈.");break
|
case user_email : showAlert("알림","email를 확인 해보슈.");break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -882,15 +882,15 @@ function onclickJoin(type, keyword) {
|
|||||||
'user_name': user_name.value
|
'user_name': user_name.value
|
||||||
}
|
}
|
||||||
if (user_pw.value === user_pw_check.value) {
|
if (user_pw.value === user_pw_check.value) {
|
||||||
if(confirm(JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) {
|
if(showConfirm("확인",JSON.stringify(data) + "\n해당 내용으로\n유저 등록 하실??")) {
|
||||||
post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) {
|
post("joinUser.bjx",type,JSON.stringify(data),keyword, function (resultData) {
|
||||||
alert(resultData)
|
showAlert("알림",resultData)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert("비번이 다름요")
|
showAlert("알림","비번이 다름요")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -936,7 +936,7 @@ function post(target, type, data, key, callBackResult) {
|
|||||||
httpRequest.onreadystatechange = () => {
|
httpRequest.onreadystatechange = () => {
|
||||||
if (httpRequest.readyState === XMLHttpRequest.DONE) {
|
if (httpRequest.readyState === XMLHttpRequest.DONE) {
|
||||||
if (httpRequest.status === 200) callBackResult(httpRequest.response);
|
if (httpRequest.status === 200) callBackResult(httpRequest.response);
|
||||||
else alert('Request Error!');
|
else showAlert("알림",'Request Error!');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
httpRequest.open('POST', target, true);
|
httpRequest.open('POST', target, true);
|
||||||
@ -969,7 +969,7 @@ function postLogin(target, type, data, key, callBackResult) {
|
|||||||
try {
|
try {
|
||||||
callBackResult(JSON.parse(httpRequest.response));
|
callBackResult(JSON.parse(httpRequest.response));
|
||||||
} catch (e) { console.error("Login response parse error:", e); }
|
} catch (e) { console.error("Login response parse error:", e); }
|
||||||
} else { alert('Request Error!'); }
|
} else { showAlert("알림",'Request Error!'); }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
httpRequest.withCredentials = true; // 쿠키(세션) 전송 허용
|
httpRequest.withCredentials = true; // 쿠키(세션) 전송 허용
|
||||||
@ -1151,7 +1151,7 @@ function handleVote(buttonElement, voteType) {
|
|||||||
.catch(error => {
|
.catch(error => {
|
||||||
// 실패 시
|
// 실패 시
|
||||||
console.error('Error handling vote:', error);
|
console.error('Error handling vote:', error);
|
||||||
alert('투표 중 오류가 발생했습니다.');
|
showAlert("알림",'투표 중 오류가 발생했습니다.');
|
||||||
controls.querySelectorAll('button').forEach(btn => btn.disabled = false); // 버튼 다시 활성화
|
controls.querySelectorAll('button').forEach(btn => btn.disabled = false); // 버튼 다시 활성화
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1166,7 +1166,7 @@ function submitComment() {
|
|||||||
const content = commentInput.value.trim();
|
const content = commentInput.value.trim();
|
||||||
|
|
||||||
if (content.length === 0) {
|
if (content.length === 0) {
|
||||||
alert('댓글 내용을 입력하세요.');
|
showAlert("알림",'댓글 내용을 입력하세요.');
|
||||||
commentInput.focus();
|
commentInput.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1182,7 +1182,7 @@ function submitComment() {
|
|||||||
|
|
||||||
const postId = serverData.id;
|
const postId = serverData.id;
|
||||||
if (!postId) {
|
if (!postId) {
|
||||||
alert("게시물 ID를 찾을 수 없습니다.");
|
showAlert("알림","게시물 ID를 찾을 수 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1196,12 +1196,12 @@ function submitComment() {
|
|||||||
try {
|
try {
|
||||||
const response = JSON.parse(resultData);
|
const response = JSON.parse(resultData);
|
||||||
if (response.resultCode === 0) {
|
if (response.resultCode === 0) {
|
||||||
alert('댓글이 성공적으로 등록되었습니다.');
|
showAlert("알림",'댓글이 성공적으로 등록되었습니다.');
|
||||||
commentInput.value = ''; // 입력창 초기화
|
commentInput.value = ''; // 입력창 초기화
|
||||||
cancelReply(); // 답글 상태 초기화
|
cancelReply(); // 답글 상태 초기화
|
||||||
fetchComments(postId); // 목록 새로고침
|
fetchComments(postId); // 목록 새로고침
|
||||||
} else {
|
} else {
|
||||||
alert('댓글 등록 실패: ' + (response.resultMsg || '알 수 없는 오류'));
|
showAlert("알림",'댓글 등록 실패: ' + (response.resultMsg || '알 수 없는 오류'));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse comment submission response:', e, resultData);
|
console.error('Failed to parse comment submission response:', e, resultData);
|
||||||
@ -1370,7 +1370,9 @@ async function submitRank(gameType, contextId, playerName, primaryScore, seconda
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('랭킹 등록에 실패했습니다.');
|
// [수정] 서버가 에러 메시지를 본문에 보냈을 경우, 해당 메시지를 에러로 throw
|
||||||
|
const errorMessage = await response.text();
|
||||||
|
throw new Error(errorMessage || '랭킹 등록에 실패했습니다.');
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
@ -1395,3 +1397,158 @@ async function fetchRanks(gameType, contextId = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [핵심] 통합 게임 성공 모달을 표시하고 랭킹 관련 로직을 처리하는 함수
|
||||||
|
* @param {object} options - 게임 결과 정보
|
||||||
|
* @param {string} options.gameType - GameType Enum (예: 'SUDOKU')
|
||||||
|
* @param {string|null} options.contextId - 게임 세부 ID (예: 퍼즐 ID)
|
||||||
|
* @param {string} options.successMessage - 모달에 표시할 메시지 (예: "1분 20초만에 클리어!")
|
||||||
|
* @param {number} options.primaryScore - 랭킹에 등록할 주 점수
|
||||||
|
* @param {number|null} options.secondaryScore - 랭킹에 등록할 보조 점수
|
||||||
|
*/
|
||||||
|
async function showGameSuccessModal(options) {
|
||||||
|
const { gameType, contextId, successMessage, primaryScore, secondaryScore } = options;
|
||||||
|
|
||||||
|
// 1. 모달의 DOM 요소 가져오기
|
||||||
|
const modal = document.getElementById('unified-game-success-modal');
|
||||||
|
const messageEl = document.getElementById('ugsm-message');
|
||||||
|
const rankingListEl = document.getElementById('ugsm-ranking-list');
|
||||||
|
// ... (나머지 요소 가져오기는 기존과 동일)
|
||||||
|
const guestArea = document.getElementById('ugsm-guest-ranking');
|
||||||
|
const userArea = document.getElementById('ugsm-user-ranking');
|
||||||
|
const playerNameInput = document.getElementById('ugsm-player-name');
|
||||||
|
const saveBtn = document.getElementById('ugsm-save-score-btn');
|
||||||
|
// 닫기 버튼은 공통 로직으로 처리되므로 여기서 제어할 필요가 없습니다.
|
||||||
|
|
||||||
|
// 2. 성공 메시지 설정
|
||||||
|
messageEl.textContent = successMessage;
|
||||||
|
|
||||||
|
// 3. 랭킹 목록 표시 (footer.html의 updateGameRanking과 유사)
|
||||||
|
rankingListEl.innerHTML = '<li>로딩 중...</li>';
|
||||||
|
try {
|
||||||
|
const ranks = await fetchRanks(gameType, contextId);
|
||||||
|
rankingListEl.innerHTML = '';
|
||||||
|
if (ranks.length > 0) {
|
||||||
|
ranks.forEach((rank, index) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
// footer.html의 점수 포맷 함수 재사용
|
||||||
|
const formattedScore = formatScore(rank.primaryScore, rank.gameType);
|
||||||
|
li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span> <strong>${formattedScore}</strong>`;
|
||||||
|
rankingListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
rankingListEl.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
rankingListEl.innerHTML = '<li>랭킹을 불러오는데 실패했습니다.</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof currentUser !== 'undefined' && currentUser.isLoggedIn) {
|
||||||
|
// 로그인 상태일 경우
|
||||||
|
guestArea.style.display = 'none';
|
||||||
|
userArea.style.display = 'block';
|
||||||
|
|
||||||
|
// 서버에 랭킹 즉시 자동 제출
|
||||||
|
try {
|
||||||
|
await submitRank(gameType, contextId, currentUser.username, primaryScore, secondaryScore);
|
||||||
|
// 성공 후 랭킹 목록 새로고침
|
||||||
|
const updatedRanks = await fetchRanks(gameType, contextId);
|
||||||
|
// (랭킹 목록 업데이트 로직 추가...)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto rank submission failed:', error);
|
||||||
|
userArea.innerHTML = '<p style="color: red;">랭킹 자동 등록에 실패했습니다.</p>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 비로그인 상태일 경우
|
||||||
|
guestArea.style.display = 'block';
|
||||||
|
userArea.style.display = 'none';
|
||||||
|
playerNameInput.value = '';
|
||||||
|
|
||||||
|
// '점수 저장' 버튼에 이벤트 리스너 할당 (중복 할당 방지를 위해 기존 리스너 제거)
|
||||||
|
const newSaveBtn = saveBtn.cloneNode(true);
|
||||||
|
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
|
||||||
|
|
||||||
|
newSaveBtn.addEventListener('click', async () => {
|
||||||
|
const playerName = playerNameInput.value.trim();
|
||||||
|
if (!playerName) {
|
||||||
|
showAlert("알림",'이름을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newSaveBtn.disabled = true;
|
||||||
|
newSaveBtn.textContent = '저장 중...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await submitRank(gameType, contextId, playerName, primaryScore, secondaryScore);
|
||||||
|
showAlert("알림",'랭킹이 등록되었습니다!');
|
||||||
|
// ▼▼▼ [핵심 수정] 이 부분을 바꿔주세요 ▼▼▼
|
||||||
|
// 기존 코드: modal.style.display = 'none';
|
||||||
|
closePopup(); // 배경(dim)과 팝업을 모두 닫는 공통 함수 호출
|
||||||
|
// ▲▲▲ 여기까지 수정 ▲▲▲
|
||||||
|
} catch (error) {
|
||||||
|
showAlert("알림",'랭킹 등록에 실패했습니다: ' + error.message);
|
||||||
|
newSaveBtn.disabled = false;
|
||||||
|
newSaveBtn.textContent = '점수 저장';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ▼▼▼ [핵심 수정] 모달을 직접 조작하는 대신, 공통 오버레이와 팝업을 표시합니다. ▼▼▼
|
||||||
|
const overlay = document.querySelector('.dim_layer');
|
||||||
|
if (modal && overlay) {
|
||||||
|
overlay.style.display = 'block';
|
||||||
|
modal.style.display = 'block';
|
||||||
|
}
|
||||||
|
// ▲▲▲ 여기까지 수정 ▲▲▲
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게임 타입에 따라 점수 표시 형식을 변경합니다.
|
||||||
|
* SUDOKU, NONOGRAM처럼 시간 기반 게임은 mm:ss 형식으로,
|
||||||
|
* 그 외에는 점수 형식으로 변환합니다.
|
||||||
|
*/
|
||||||
|
function formatScore(score, gameType) {
|
||||||
|
if (['SUDOKU', 'NONOGRAM'].includes(gameType)) {
|
||||||
|
const minutes = Math.floor(score / 60).toString().padStart(2, '0');
|
||||||
|
const seconds = (score % 60).toString().padStart(2, '0');
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
if (gameType === 'SPIDER') {
|
||||||
|
return `${score} moves`;
|
||||||
|
}
|
||||||
|
return `${score} 점`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이트 공통 스타일을 적용한 커스텀 알림(Alert) 함수
|
||||||
|
* @param {string} title - 팝업의 제목
|
||||||
|
* @param {string} text - 팝업의 내용
|
||||||
|
* @param {string} icon - 'success', 'error', 'warning', 'info', 'question' 중 하나
|
||||||
|
*/
|
||||||
|
function showAlert(title, text, icon = 'info') {
|
||||||
|
Swal.fire({
|
||||||
|
title: title,
|
||||||
|
text: text,
|
||||||
|
icon: icon,
|
||||||
|
confirmButtonColor: '#FFA500', // main.css의 --point-color
|
||||||
|
confirmButtonText: '확인'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사이트 공통 스타일을 적용한 커스텀 확인(Confirm) 함수
|
||||||
|
* @param {string} title - 팝업의 제목
|
||||||
|
* @param {string} text - 팝업의 내용
|
||||||
|
* @returns {Promise<boolean>} 사용자가 '확인'을 누르면 true, '취소'를 누르면 false를 반환
|
||||||
|
*/
|
||||||
|
async function showConfirm(title, text) {
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: title,
|
||||||
|
text: text,
|
||||||
|
icon: 'question',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonColor: '#FFA500',
|
||||||
|
cancelButtonColor: '#555555', // main.css의 --button-alt-default
|
||||||
|
confirmButtonText: '확인',
|
||||||
|
cancelButtonText: '취소'
|
||||||
|
});
|
||||||
|
return result.isConfirmed;
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
layout:decorate="~{layout/default_layout}">
|
layout:decorate="~{layout/default_layout}">
|
||||||
<th:block layout:fragment="content" id="content">
|
<th:block layout:fragment="content" id="content">
|
||||||
<section id="banner"
|
<section id="banner"
|
||||||
th:styleappend="${randomBannerImage != null} ? 'background-image: url(\'' + @{${randomBannerImage}} + '\');' : ''">
|
th:styleappend="${randomBannerImage != null} ? |background-image: url('${apiBaseUrl}${randomBannerImage}');| : ''">
|
||||||
<header>
|
<header>
|
||||||
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
|
<h2>Bum's: <em>짧은 헛소리 혹은 기사?! 링크 있으면 링크까지</a></em></h2>
|
||||||
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
|
<a href="#" class="button">더 읽으쉴?!<br>[Read More Gibberish]</a>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
<section th:each="post : ${Posts}">
|
<section th:each="post : ${Posts}">
|
||||||
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
|
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
|
||||||
<span class="image left">
|
<span class="image left">
|
||||||
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
|
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<section th:each="post : ${postsPage.content}">
|
<section th:each="post : ${postsPage.content}">
|
||||||
<div class="box post" th:id="${post.id}">
|
<div class="box post" th:id="${post.id}">
|
||||||
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left">
|
<a href="javascript:void(0);" th:onclick="'goToViewer(this.parentNode)'" class="image left">
|
||||||
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? @{${post.thumb}} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
|
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
|
||||||
</a>
|
</a>
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
<h3 style="display: flex; justify-content: space-between; align-items: center;">
|
<h3 style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
|||||||
@ -9,21 +9,7 @@
|
|||||||
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* =================================
|
|
||||||
기본 및 전체 레이아웃 (수정됨)
|
|
||||||
================================= */
|
|
||||||
body {
|
|
||||||
/* (★ 삭제) font-family, text-align, background-color, color, margin, padding
|
|
||||||
-> 이 속성들은 모두 common_game_theme.css에서 관리합니다.
|
|
||||||
*/
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 15vw; /* 2048 고유의 큰 폰트 크기는 유지 */
|
|
||||||
margin: 20px 0;
|
|
||||||
/* (★ 삭제) color 속성 삭제 -> common_game_theme에서 상속 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.score-container {
|
.score-container {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@ -36,6 +22,7 @@
|
|||||||
#game-board {
|
#game-board {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
grid-template-rows: repeat(4, 1fr); /* <-- 이 줄을 추가하세요! */
|
||||||
grid-gap: 2vw;
|
grid-gap: 2vw;
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
max-width: 500px; /* (★ 수정) 400px -> 500px (다른 게임과 통일) */
|
max-width: 500px; /* (★ 수정) 400px -> 500px (다른 게임과 통일) */
|
||||||
@ -78,6 +65,7 @@
|
|||||||
background-color: #eceff1; /* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) */
|
background-color: #eceff1; /* #cdc1b4 (갈색) -> #eceff1 (밝은 블루 그레이) */
|
||||||
|
|
||||||
font-size: 5vw;
|
font-size: 5vw;
|
||||||
|
line-height: 1; /* <-- 이 줄을 추가하세요! */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 481px) {
|
@media (min-width: 481px) {
|
||||||
@ -95,8 +83,6 @@
|
|||||||
.tile-4 { background-color: #bbdefb; color: #333; } /* #ede0c8 (노란 베이지) -> #bbdefb (파랑) */
|
.tile-4 { background-color: #bbdefb; color: #333; } /* #ede0c8 (노란 베이지) -> #bbdefb (파랑) */
|
||||||
|
|
||||||
/* 8부터는 고유 색상이므로 유지 (새 테마와 잘 어울림) */
|
/* 8부터는 고유 색상이므로 유지 (새 테마와 잘 어울림) */
|
||||||
.tile-2 { background-color: #E3F2FD; color: #333; } /* 아주 밝은 파랑 */
|
|
||||||
.tile-4 { background-color: #BBDEFB; color: #333; } /* 밝은 파랑 */
|
|
||||||
.tile-8 { background-color: #90CAF9; color: #fff; } /* 파랑 */
|
.tile-8 { background-color: #90CAF9; color: #fff; } /* 파랑 */
|
||||||
.tile-16 { background-color: #64B5F6; color: #fff; } /* 조금 더 짙은 파랑 */
|
.tile-16 { background-color: #64B5F6; color: #fff; } /* 조금 더 짙은 파랑 */
|
||||||
.tile-32 { background-color: #42A5F5; color: #fff; } /* 짙은 파랑 */
|
.tile-32 { background-color: #42A5F5; color: #fff; } /* 짙은 파랑 */
|
||||||
@ -151,37 +137,6 @@
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =================================
|
|
||||||
랭킹 리스트 (테마 적용)
|
|
||||||
================================= */
|
|
||||||
.ranking-container {
|
|
||||||
/*
|
|
||||||
(★ 참고) 이 컨테이너는 common_game_theme.css에서
|
|
||||||
.game-card 스타일(흰색 배경, 그림자, 패딩)을 이미 적용받습니다.
|
|
||||||
여기서는 내부 정렬만 담당합니다.
|
|
||||||
*/
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px; /* 공통 테마와 동일하게 설정 (중복 선언이지만 명확성을 위해 둠) */
|
|
||||||
margin: 30px auto;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.ranking-container h3 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
#ranking-list {
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
#ranking-list li {
|
|
||||||
/* (★ 수정) 배경색 변경 */
|
|
||||||
background-color: #f0f4f8; /* #eee4da (베이지) -> #f0f4f8 (밝은 회색) */
|
|
||||||
margin-bottom: 5px;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
@ -204,15 +159,13 @@
|
|||||||
<button id="save-score">점수 저장</button>
|
<button id="save-score">점수 저장</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ranking-container">
|
|
||||||
<h3>🏆 랭킹</h3>
|
|
||||||
<ol id="ranking-list"></ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
window.pageContext = { pageType: 'game', gameType: 'GAME_2048', contextId: null };
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
// ... (DOM 요소 가져오기 - 동일)
|
// ... (DOM 요소 가져오기 - 동일)
|
||||||
const gameBoard = document.getElementById('game-board');
|
const gameBoard = document.getElementById('game-board');
|
||||||
const scoreDisplay = document.getElementById('score');
|
const scoreDisplay = document.getElementById('score');
|
||||||
@ -220,7 +173,6 @@
|
|||||||
const finalScoreDisplay = document.getElementById('final-score');
|
const finalScoreDisplay = document.getElementById('final-score');
|
||||||
const playerNameInput = document.getElementById('player-name');
|
const playerNameInput = document.getElementById('player-name');
|
||||||
const saveScoreButton = document.getElementById('save-score');
|
const saveScoreButton = document.getElementById('save-score');
|
||||||
const rankingList = document.getElementById('ranking-list');
|
|
||||||
|
|
||||||
// (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
|
// (★ 수정) 게임 ID 대신, 공통 Enum 타입 문자열 사용
|
||||||
const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
|
const currentGameType = 'GAME_2048'; // (GameType.GAME_2048과 일치)
|
||||||
@ -352,8 +304,17 @@
|
|||||||
addNumber();
|
addNumber();
|
||||||
updateBoard();
|
updateBoard();
|
||||||
if (isGameOver()) {
|
if (isGameOver()) {
|
||||||
finalScoreDisplay.textContent = score;
|
// ▼▼▼ 기존 팝업 대신 통합 모달 호출 ▼▼▼
|
||||||
gameOverPopup.style.display = 'flex';
|
showGameSuccessModal({
|
||||||
|
gameType: 'GAME_2048',
|
||||||
|
contextId: null,
|
||||||
|
successMessage: `최종 점수 ${score}점을 달성했습니다!`,
|
||||||
|
primaryScore: score,
|
||||||
|
secondaryScore: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// 게임 보드 리셋 로직은 모달이 닫힐 때 처리하거나 여기에 남겨둘 수 있습니다.
|
||||||
|
// 예: initializeBoard(); // 즉시 리셋
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -400,7 +361,7 @@
|
|||||||
// ----- 랭킹 API 연동 -----
|
// ----- 랭킹 API 연동 -----
|
||||||
saveScoreButton.addEventListener('click', async () => {
|
saveScoreButton.addEventListener('click', async () => {
|
||||||
const playerName = playerNameInput.value.trim();
|
const playerName = playerNameInput.value.trim();
|
||||||
if (playerName === "") return alert("이름을 입력해주세요.");
|
if (playerName === "") return showAlert("알림","이름을 입력해주세요.");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// (★ 수정) user.js의 공통 submitRank 함수 호출
|
// (★ 수정) user.js의 공통 submitRank 함수 호출
|
||||||
@ -410,44 +371,14 @@
|
|||||||
gameOverPopup.style.display = 'none';
|
gameOverPopup.style.display = 'none';
|
||||||
playerNameInput.value = '';
|
playerNameInput.value = '';
|
||||||
score = 0;
|
score = 0;
|
||||||
updateRankingList(); // 랭킹 리스트 새로고침
|
|
||||||
initializeBoard(); // 새 게임 시작
|
initializeBoard(); // 새 게임 시작
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting rank:', error);
|
console.error('Error submitting rank:', error);
|
||||||
alert('랭킹 등록 중 오류가 발생했습니다: ' + error.message);
|
showAlert("알림",'랭킹 등록 중 오류가 발생했습니다: ' + error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
|
||||||
*/
|
|
||||||
async function updateRankingList() {
|
|
||||||
rankingList.innerHTML = '<li>로딩 중...</li>';
|
|
||||||
try {
|
|
||||||
// (★ 수정) user.js의 공통 fetchRanks 함수 호출
|
|
||||||
const rankings = await fetchRanks(currentGameType, currentContextId);
|
|
||||||
|
|
||||||
rankingList.innerHTML = ''; // 리스트 비우기
|
|
||||||
if (!rankings || rankings.length === 0) {
|
|
||||||
rankingList.innerHTML = '<li>등록된 랭킹이 없습니다.</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rankings.forEach((rank, index) => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
// (★ 수정) 공통 모델(GameRank)의 필드명(playerName, primaryScore)을 사용
|
|
||||||
li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span><strong>${rank.primaryScore}점</strong>`;
|
|
||||||
rankingList.appendChild(li);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching ranks:', error);
|
|
||||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 게임 시작 -----
|
|
||||||
updateRankingList();
|
|
||||||
initializeBoard();
|
initializeBoard();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -168,25 +168,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#result-overlay {
|
|
||||||
position: fixed; /* 화면 전체에 고정 */
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw; /* 뷰포트 너비 100% */
|
|
||||||
height: 100vh; /* 뷰포트 높이 100% */
|
|
||||||
background-color: rgba(0, 0, 0, 0.75); /* 반투명 검은 배경 */
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 100;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
#result-overlay.visible {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
#result-modal {
|
#result-modal {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 20px 40px;
|
padding: 20px 40px;
|
||||||
@ -350,6 +332,14 @@
|
|||||||
/*<![CDATA[*/
|
/*<![CDATA[*/
|
||||||
const puzzleData = /*[[${puzzle}]]*/ null;
|
const puzzleData = /*[[${puzzle}]]*/ null;
|
||||||
/*]]>*/
|
/*]]>*/
|
||||||
|
|
||||||
|
if (puzzleData) {
|
||||||
|
window.pageContext = {
|
||||||
|
pageType: 'game',
|
||||||
|
gameType: 'NONOGRAM',
|
||||||
|
contextId: puzzleData.id
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
@ -700,55 +690,9 @@
|
|||||||
hintBtn.disabled = (points <= 0 || isGameFinished);
|
hintBtn.disabled = (points <= 0 || isGameFinished);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* (★ 신규) 노노그램 랭킹 등록을 처리하는 함수
|
|
||||||
* 이 함수는 user.js에 정의된 공통 submitRank 함수를 호출합니다.
|
|
||||||
*/
|
|
||||||
async function submitNonogramRank(completionTime, hintsUsed) {
|
|
||||||
const playerName = prompt("랭킹에 등록할 이름을 입력하세요:", "Player");
|
|
||||||
if (!playerName || playerName.trim() === "") return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// (★ 신규) user.js의 공통 submitRank 함수 호출
|
|
||||||
// 주 점수(primaryScore) = 완료 시간(초) (낮을수록 좋음)
|
|
||||||
// 보조 점수(secondaryScore) = 사용한 힌트 수(5-남은포인트) (낮을수록 좋음)
|
|
||||||
await submitRank(
|
|
||||||
'NONOGRAM', // GameType
|
|
||||||
puzzleData.id, // ContextId (퍼즐 고유 ID)
|
|
||||||
playerName.trim(), // playerName
|
|
||||||
completionTime, // primaryScore (시간)
|
|
||||||
hintsUsed // secondaryScore (힌트 사용 횟수)
|
|
||||||
);
|
|
||||||
|
|
||||||
alert("랭킹이 등록되었습니다!");
|
|
||||||
// 랭킹 등록 버튼 비활성화 (중복 제출 방지)
|
|
||||||
const submitBtn = document.getElementById('modal-submit-rank-btn');
|
|
||||||
if (submitBtn) submitBtn.disabled = true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Rank submission failed:", error);
|
|
||||||
alert("랭킹 등록에 실패했습니다: " + error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (★ 수정) 성공/실패 모달 (랭킹 등록 버튼 추가를 위해 ID 할당 기능 추가)
|
|
||||||
*/
|
|
||||||
function showResultModal(config) {
|
|
||||||
modalTitle.textContent = config.title;
|
|
||||||
modalMessage.textContent = config.message;
|
|
||||||
modalButtons.innerHTML = '';
|
|
||||||
config.buttons.forEach(btnInfo => {
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.textContent = btnInfo.text;
|
|
||||||
button.className = btnInfo.class || '';
|
|
||||||
button.onclick = btnInfo.action;
|
|
||||||
if (btnInfo.id) button.id = btnInfo.id; // (★ 신규) 버튼 ID 할당 기능
|
|
||||||
modalButtons.appendChild(button);
|
|
||||||
});
|
|
||||||
resultOverlay.classList.remove('hidden');
|
|
||||||
setTimeout(() => resultOverlay.classList.add('visible'), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -759,11 +703,12 @@
|
|||||||
isGameFinished = true;
|
isGameFinished = true;
|
||||||
document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none';
|
document.querySelector('.puzzle-grid-container').style.pointerEvents = 'none';
|
||||||
hintBtn.disabled = true;
|
hintBtn.disabled = true;
|
||||||
showResultModal({
|
showGameSuccessModal({
|
||||||
title: 'Failure', message: '포인트를 모두 사용했습니다.', buttons: [
|
gameType: 'NONOGRAM',
|
||||||
{ text: '재시도 (Retry)', class: 'primary', action: () => window.location.reload() },
|
contextId: puzzleData.id,
|
||||||
{ text: '홈으로 (Home)', action: () => window.location.href = '/' }
|
successMessage: `퍼즐 완성! (시간: ${completionTimeSeconds}초, 힌트 사용: ${hintsUsed}개)`,
|
||||||
]
|
primaryScore: completionTimeSeconds,
|
||||||
|
secondaryScore: hintsUsed
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -797,7 +742,10 @@
|
|||||||
img.style.left = `${left}px`;
|
img.style.left = `${left}px`;
|
||||||
img.style.width = `${gridRect.width}px`;
|
img.style.width = `${gridRect.width}px`;
|
||||||
img.style.height = `${gridRect.height}px`;
|
img.style.height = `${gridRect.height}px`;
|
||||||
img.src = (img.id === 'grayscale-reveal') ? puzzleData.grayscaleImage : puzzleData.originalImage;
|
// [수정] Base64 대신 URL 경로를 사용하도록 변경
|
||||||
|
img.src = (img.id === 'grayscale-reveal')
|
||||||
|
? `/puzzle/images/${puzzleData.grayscaleImageFile}`
|
||||||
|
: `/puzzle/images/${puzzleData.originalImageFile}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 애니메이션 순차 실행 ---
|
// --- 애니메이션 순차 실행 ---
|
||||||
@ -807,19 +755,12 @@
|
|||||||
originalImg.style.opacity = '1';
|
originalImg.style.opacity = '1';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// (★ 수정) 모달 버튼 설정에 "랭킹 등록" 버튼 추가
|
// (★ 수정) 모달 버튼 설정에 "랭킹 등록" 버튼 추가
|
||||||
showResultModal({
|
showGameSuccessModal({
|
||||||
title: 'Success! 🎉',
|
gameType: 'NONOGRAM',
|
||||||
message: `퍼즐을 완성했습니다! (시간: ${completionTimeSeconds}초, 힌트 사용: ${hintsUsed}개)`,
|
contextId: puzzleData.id,
|
||||||
buttons: [
|
successMessage: `퍼즐 완성! (시간: ${completionTimeSeconds}초, 힌트 사용: ${hintsUsed}개)`,
|
||||||
{
|
primaryScore: completionTimeSeconds,
|
||||||
text: '랭킹 등록',
|
secondaryScore: hintsUsed
|
||||||
class: 'primary',
|
|
||||||
id: 'modal-submit-rank-btn', // (★ 신규) 랭킹 제출 버튼
|
|
||||||
action: () => submitNonogramRank(completionTimeSeconds, hintsUsed)
|
|
||||||
},
|
|
||||||
{ text: '다른 문제 풀기', action: () => window.location.href = '/puzzle/play' },
|
|
||||||
{ text: '홈으로', action: () => window.location.href = '/' }
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@ -850,7 +791,7 @@
|
|||||||
checkAndLockCompletedLines(hintAffectedRows, hintAffectedCols);
|
checkAndLockCompletedLines(hintAffectedRows, hintAffectedCols);
|
||||||
checkWinCondition();
|
checkWinCondition();
|
||||||
} else {
|
} else {
|
||||||
alert("더 이상 사용할 힌트가 없습니다!");
|
showAlert("알림","더 이상 사용할 힌트가 없습니다!");
|
||||||
points++;
|
points++;
|
||||||
updatePointsDisplay();
|
updatePointsDisplay();
|
||||||
}
|
}
|
||||||
@ -880,26 +821,7 @@
|
|||||||
*/
|
*/
|
||||||
let currentPuzzleData = null; // 업로드 성공 시 퍼즐 데이터 저장
|
let currentPuzzleData = null; // 업로드 성공 시 퍼즐 데이터 저장
|
||||||
|
|
||||||
// (★ 수정 없음) 업로드 페이지용 성공 애니메이션 함수
|
|
||||||
function showSuccessAnimation() {
|
|
||||||
if (!currentPuzzleData) return;
|
|
||||||
|
|
||||||
const puzzleContainer = document.getElementById('puzzle-container');
|
|
||||||
const grayscaleImg = document.getElementById('grayscale-reveal');
|
|
||||||
const originalImg = document.getElementById('original-reveal');
|
|
||||||
|
|
||||||
grayscaleImg.src = currentPuzzleData.grayscaleImage;
|
|
||||||
originalImg.src = currentPuzzleData.originalImage;
|
|
||||||
|
|
||||||
puzzleContainer.style.transition = 'opacity 0.5s';
|
|
||||||
puzzleContainer.style.opacity = '0';
|
|
||||||
grayscaleImg.style.opacity = '1';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
grayscaleImg.style.opacity = '0';
|
|
||||||
originalImg.style.opacity = '1';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (★ 수정 없음) 업로드 페이지용 퍼즐 미리보기 그리기 함수
|
// (★ 수정 없음) 업로드 페이지용 퍼즐 미리보기 그리기 함수
|
||||||
function drawPuzzle(puzzleData) {
|
function drawPuzzle(puzzleData) {
|
||||||
@ -947,106 +869,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// upload.js의 DOMContentLoaded 리스너
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
|
|
||||||
const createBtn = document.getElementById('createBtn');
|
|
||||||
|
|
||||||
// createBtn이 없는 nonogram.html(게임 페이지)에서는 이 리스너가 아무것도 실행하지 않음.
|
|
||||||
if (!createBtn) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// (업로드 페이지 전용 로직)
|
|
||||||
createBtn.addEventListener('click', async () => {
|
|
||||||
const uploader = document.getElementById('imageUploader');
|
|
||||||
const statusDiv = document.getElementById('status');
|
|
||||||
const puzzleContainer = document.getElementById('puzzle-container');
|
|
||||||
const testSuccessBtn = document.getElementById('test-success-btn');
|
|
||||||
const deleteBtn = document.getElementById('delete-btn');
|
|
||||||
const playBtn = document.getElementById('play-btn');
|
|
||||||
|
|
||||||
if (uploader.files.length === 0) {
|
|
||||||
statusDiv.textContent = '이미지 파일을 선택해주세요.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageFile = uploader.files[0];
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('imageFile', imageFile);
|
|
||||||
|
|
||||||
statusDiv.textContent = '문제를 생성하는 중...';
|
|
||||||
puzzleContainer.innerHTML = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/upload.bjx 호출
|
|
||||||
const response = await fetch('/puzzle/upload.bjx', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const puzzleData = await response.json();
|
|
||||||
statusDiv.textContent = '문제 생성 성공!';
|
|
||||||
drawPuzzle(puzzleData); // 미리보기 그리기
|
|
||||||
|
|
||||||
currentPuzzleData = puzzleData;
|
|
||||||
testSuccessBtn.addEventListener('click', showSuccessAnimation);
|
|
||||||
|
|
||||||
testSuccessBtn.style.display = 'inline-block';
|
|
||||||
deleteBtn.style.display = 'inline-block';
|
|
||||||
playBtn.style.display = 'inline-block';
|
|
||||||
} else {
|
|
||||||
const errorMessage = await response.text();
|
|
||||||
statusDiv.textContent = `생성 실패: ${errorMessage}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('네트워크 오류:', error);
|
|
||||||
statusDiv.textContent = '서버와 통신 중 오류가 발생했습니다.';
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteBtn.addEventListener('click', async () => {
|
|
||||||
if (!currentPuzzleData || !currentPuzzleData.id) {
|
|
||||||
alert('삭제할 퍼즐이 선택되지 않았습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm('정말로 이 퍼즐을 삭제하시겠습니까?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// (★ 수정) API 경로 변경 -> 통합 컨트롤러의 /puzzle/{id}.bjx 호출
|
|
||||||
const response = await fetch(`/puzzle/${currentPuzzleData.id}.bjx`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
statusDiv.textContent = '퍼즐이 성공적으로 삭제되었습니다.';
|
|
||||||
puzzleContainer.innerHTML = '';
|
|
||||||
// (버그 수정) success-animation-container 내부의 img src를 초기화해야 함
|
|
||||||
document.getElementById('grayscale-reveal').src = "";
|
|
||||||
document.getElementById('original-reveal').src = "";
|
|
||||||
|
|
||||||
testSuccessBtn.style.display = 'none';
|
|
||||||
deleteBtn.style.display = 'none';
|
|
||||||
playBtn.style.display = 'none';
|
|
||||||
currentPuzzleData = null;
|
|
||||||
} else {
|
|
||||||
statusDiv.textContent = `삭제 실패: 서버 오류 (${response.status})`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('삭제 중 네트워크 오류:', error);
|
|
||||||
statusDiv.textContent = '삭제 중 오류가 발생했습니다.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
playBtn.addEventListener('click', () => {
|
|
||||||
if (currentPuzzleData && currentPuzzleData.id) {
|
|
||||||
// (★ 수정 없음) 이 경로는 PuzzleController의 페이지 서빙 경로와 일치하므로 올바름.
|
|
||||||
window.location.href = `/puzzle/play/${currentPuzzleData.id}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -9,41 +9,12 @@
|
|||||||
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
<link th:href="@{/css/common_game_theme.css}" rel="stylesheet" />
|
||||||
<style>
|
<style>
|
||||||
/* sudoku.css의 내용을 여기에 삽입 */
|
/* sudoku.css의 내용을 여기에 삽입 */
|
||||||
body {
|
|
||||||
/*
|
|
||||||
(★ 삭제) 아래 속성들은 common_game_theme.css에서 관리합니다.
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f4f7f9;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
#sudoku-game-app {
|
#sudoku-game-app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
/*
|
|
||||||
(★ 삭제) 아래 속성들은 common_game_theme.css에서
|
|
||||||
'#sudoku-game-app .container' 셀렉터로 이미 관리하고 있습니다.
|
|
||||||
|
|
||||||
background: white;
|
|
||||||
padding: clamp(15px, 4vw, 30px);
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
|
||||||
text-align: center;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0 auto;
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* (★ 남김) .container에만 필요한 고유 속성 (text-align)은 남겨두거나 common_game_theme로 이동 */
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,22 +23,6 @@
|
|||||||
color: #333;
|
color: #333;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
/* (★ 참고) h1은 common_game_theme의 스타일을 상속받습니다.
|
|
||||||
만약 스도쿠만 다른 스타일을 원한다면 여기에서 재정의(override)하면 됩니다.
|
|
||||||
현재는 공통 스타일이 적용됩니다. */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================= */
|
|
||||||
/* (★ 남김) 아래부터는 스도쿠 고유의 스타일입니다. (수정 불필요) */
|
|
||||||
/* ======================================= */
|
|
||||||
|
|
||||||
/* 게임 컨테이너 */
|
|
||||||
#game-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 500px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 게임 정보 (점수, 타이머) */
|
/* 게임 정보 (점수, 타이머) */
|
||||||
@ -85,14 +40,47 @@
|
|||||||
#score { color: #007bff; }
|
#score { color: #007bff; }
|
||||||
#timer { color: #333; }
|
#timer { color: #333; }
|
||||||
|
|
||||||
|
/* 보드 영역의 크기를 미리 고정시키는 스타일 */
|
||||||
|
#board-area {
|
||||||
|
position: relative; /* 자식 요소의 absolute 위치 기준점 */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto 15px auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 난이도 선택 UI를 보드 영역 중앙에 배치 */
|
||||||
|
#setup-container {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
#setup-container select, #setup-container button {
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 스도쿠 보드 */
|
/* 스도쿠 보드 */
|
||||||
#sudoku-board {
|
#sudoku-board {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(9, 1fr);
|
grid-template-columns: repeat(9, 1fr);
|
||||||
grid-template-rows: repeat(9, 1fr);
|
grid-template-rows: repeat(9, 1fr);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
border: 3px solid #333;
|
border: 3px solid #333;
|
||||||
aspect-ratio: 1 / 1;
|
}
|
||||||
|
|
||||||
|
#game-controls-container {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
@ -255,6 +243,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>스도쿠를 즐겨보세요!</h1>
|
<h1>스도쿠를 즐겨보세요!</h1>
|
||||||
|
|
||||||
|
<div id="board-area">
|
||||||
<div id="setup-container">
|
<div id="setup-container">
|
||||||
<select id="difficulty-select">
|
<select id="difficulty-select">
|
||||||
<option value="easy">쉬움</option>
|
<option value="easy">쉬움</option>
|
||||||
@ -263,13 +252,14 @@
|
|||||||
</select>
|
</select>
|
||||||
<button id="start-btn">게임 시작</button>
|
<button id="start-btn">게임 시작</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="sudoku-board" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="game-container" class="hidden">
|
<div id="game-controls-container" class="hidden">
|
||||||
<div class="game-info">
|
<div class="game-info">
|
||||||
<div id="score">SCORE: 5</div>
|
<div id="score">SCORE: 5</div>
|
||||||
<div id="timer">00:00</div>
|
<div id="timer">00:00</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="sudoku-board"></div>
|
|
||||||
<div id="number-input-buttons">
|
<div id="number-input-buttons">
|
||||||
<button class="num-btn" data-number="1">1</button>
|
<button class="num-btn" data-number="1">1</button>
|
||||||
<button class="num-btn" data-number="2">2</button>
|
<button class="num-btn" data-number="2">2</button>
|
||||||
@ -280,9 +270,8 @@
|
|||||||
<button class="num-btn" data-number="7">7</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="8">8</button>
|
||||||
<button class="num-btn" data-number="9">9</button>
|
<button class="num-btn" data-number="9">9</button>
|
||||||
<button id="undo-btn" class="clear-btn">실행취소</button>
|
<button id="undo-btn" class="clear-btn">↩</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button id="hint-btn">힌트 사용 (-1점)</button>
|
<button id="hint-btn">힌트 사용 (-1점)</button>
|
||||||
<button id="complete-btn">정답 확인</button>
|
<button id="complete-btn">정답 확인</button>
|
||||||
@ -312,11 +301,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// sudoku.js의 내용을 여기에 삽입
|
window.pageContext = { pageType: 'game', gameType: 'SUDOKU', contextId: undefined };
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 페이지 로드 시 스도쿠 전체 랭킹 표시
|
||||||
|
if (typeof updateGameRanking === 'function') {
|
||||||
|
updateGameRanking('SUDOKU', null);
|
||||||
|
}
|
||||||
|
|
||||||
// DOM 요소
|
// DOM 요소
|
||||||
const setupContainer = document.getElementById('setup-container');
|
const setupContainer = document.getElementById('setup-container');
|
||||||
const gameContainer = document.getElementById('game-container');
|
const gameControlsContainer = document.getElementById('game-controls-container');
|
||||||
const startBtn = document.getElementById('start-btn');
|
const startBtn = document.getElementById('start-btn');
|
||||||
const boardElement = document.getElementById('sudoku-board');
|
const boardElement = document.getElementById('sudoku-board');
|
||||||
const timerElement = document.getElementById('timer');
|
const timerElement = document.getElementById('timer');
|
||||||
@ -328,11 +323,10 @@
|
|||||||
const modalOverlay = document.getElementById('modal-overlay');
|
const modalOverlay = document.getElementById('modal-overlay');
|
||||||
const gameOverModal = document.getElementById('game-over-modal');
|
const gameOverModal = document.getElementById('game-over-modal');
|
||||||
const retryBtn = document.getElementById('retry-btn');
|
const retryBtn = document.getElementById('retry-btn');
|
||||||
const submitRankBtn = document.getElementById('submit-rank-btn');
|
|
||||||
const rankingList = document.getElementById('ranking-list');
|
|
||||||
const closeModalBtn = document.getElementById('close-modal-btn');
|
const closeModalBtn = document.getElementById('close-modal-btn');
|
||||||
|
|
||||||
// 게임 상태 변수
|
// 게임 상태 변수
|
||||||
|
const currentGameType = 'SUDOKU';
|
||||||
let currentPuzzleId = null;
|
let currentPuzzleId = null;
|
||||||
let solvedPuzzle = null;
|
let solvedPuzzle = null;
|
||||||
let timerInterval = null;
|
let timerInterval = null;
|
||||||
@ -342,11 +336,9 @@
|
|||||||
let score = 5;
|
let score = 5;
|
||||||
let history = [];
|
let history = [];
|
||||||
|
|
||||||
// (★ 수정) API 호출 경로를 통합 컨트롤러(/puzzle) 경로로 변경
|
|
||||||
startBtn.addEventListener('click', async () => {
|
startBtn.addEventListener('click', async () => {
|
||||||
const difficulty = document.getElementById('difficulty-select').value;
|
const difficulty = document.getElementById('difficulty-select').value;
|
||||||
try {
|
try {
|
||||||
// (★ 수정) API 경로 변경: /sudoku/start -> /puzzle/sudoku/start
|
|
||||||
const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
|
const response = await fetch(`/puzzle/sudoku/start?difficulty=${difficulty}`);
|
||||||
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
|
if (!response.ok) throw new Error('서버에서 게임 데이터를 가져오지 못했습니다.');
|
||||||
const gameData = await response.json();
|
const gameData = await response.json();
|
||||||
@ -354,6 +346,11 @@
|
|||||||
currentPuzzleId = gameData.puzzleId;
|
currentPuzzleId = gameData.puzzleId;
|
||||||
solvedPuzzle = gameData.solution;
|
solvedPuzzle = gameData.solution;
|
||||||
|
|
||||||
|
// 푸터 랭킹을 현재 퍼즐 랭킹으로 업데이트
|
||||||
|
if (typeof updateGameRanking === 'function') {
|
||||||
|
updateGameRanking(currentGameType, currentPuzzleId);
|
||||||
|
}
|
||||||
|
|
||||||
history = [];
|
history = [];
|
||||||
score = 5;
|
score = 5;
|
||||||
updateScoreDisplay();
|
updateScoreDisplay();
|
||||||
@ -362,12 +359,14 @@
|
|||||||
startTimer();
|
startTimer();
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
|
|
||||||
|
// 화면 전환
|
||||||
setupContainer.classList.add('hidden');
|
setupContainer.classList.add('hidden');
|
||||||
gameContainer.classList.remove('hidden');
|
boardElement.classList.remove('hidden');
|
||||||
numberInputButtons.classList.remove('hidden');
|
gameControlsContainer.classList.remove('hidden');
|
||||||
gameOverModal.classList.add('hidden');
|
gameOverModal.classList.add('hidden');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('게임 로딩에 실패했습니다: ' + error.message);
|
showAlert("알림",'게임 로딩에 실패했습니다: ' + error.message);
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -431,19 +430,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 게임 플레이 이벤트 핸들링 ---
|
|
||||||
numberInputButtons.addEventListener('click', (event) => {
|
numberInputButtons.addEventListener('click', (event) => {
|
||||||
const target = event.target.closest('button');
|
const target = event.target.closest('button');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
if (target === undoBtn) {
|
if (target === undoBtn) {
|
||||||
undoAction();
|
undoAction();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.classList.contains('completed')) return;
|
if (target.classList.contains('completed')) return;
|
||||||
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
|
document.querySelectorAll('.num-btn').forEach(btn => btn.classList.remove('selected'));
|
||||||
|
|
||||||
if (target.classList.contains('num-btn')) {
|
if (target.classList.contains('num-btn')) {
|
||||||
const num = target.dataset.number;
|
const num = target.dataset.number;
|
||||||
selectedNumber = (selectedNumber === num) ? null : num;
|
selectedNumber = (selectedNumber === num) ? null : num;
|
||||||
@ -460,18 +455,15 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
focusedCell = targetCell;
|
focusedCell = targetCell;
|
||||||
|
|
||||||
if (selectedNumber) {
|
if (selectedNumber) {
|
||||||
const previousValue = targetCell.textContent;
|
const previousValue = targetCell.textContent;
|
||||||
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
|
let newValue = (previousValue === selectedNumber) ? '' : selectedNumber;
|
||||||
targetCell.textContent = newValue;
|
targetCell.textContent = newValue;
|
||||||
|
|
||||||
recordAction(targetCell, previousValue, newValue);
|
recordAction(targetCell, previousValue, newValue);
|
||||||
validateCell(targetCell);
|
validateCell(targetCell);
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
checkIfBoardIsFull();
|
checkIfBoardIsFull();
|
||||||
}
|
}
|
||||||
|
|
||||||
highlightCells();
|
highlightCells();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -479,22 +471,18 @@
|
|||||||
if (score <= 0) return;
|
if (score <= 0) return;
|
||||||
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
|
const emptyCells = Array.from(boardElement.querySelectorAll('.cell.editable')).filter(cell => !cell.textContent);
|
||||||
if (emptyCells.length === 0) {
|
if (emptyCells.length === 0) {
|
||||||
alert('모든 칸이 채워져 있습니다.');
|
showAlert("알림",'모든 칸이 채워져 있습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
const randomCell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
||||||
const cellIndex = parseInt(randomCell.dataset.index);
|
const cellIndex = parseInt(randomCell.dataset.index);
|
||||||
const correctAnswer = solvedPuzzle[cellIndex];
|
const correctAnswer = solvedPuzzle[cellIndex];
|
||||||
const previousValue = randomCell.textContent;
|
const previousValue = randomCell.textContent;
|
||||||
|
|
||||||
score--;
|
score--;
|
||||||
updateScoreDisplay();
|
updateScoreDisplay();
|
||||||
recordAction(randomCell, previousValue, correctAnswer, true);
|
recordAction(randomCell, previousValue, correctAnswer, true);
|
||||||
|
|
||||||
randomCell.textContent = correctAnswer;
|
randomCell.textContent = correctAnswer;
|
||||||
randomCell.classList.remove('editable', 'incorrect');
|
randomCell.classList.remove('editable', 'incorrect');
|
||||||
|
|
||||||
updateButtonStates();
|
updateButtonStates();
|
||||||
highlightCells();
|
highlightCells();
|
||||||
checkIfBoardIsFull();
|
checkIfBoardIsFull();
|
||||||
@ -504,7 +492,6 @@
|
|||||||
if (history.length === 0) return;
|
if (history.length === 0) return;
|
||||||
const lastAction = history.pop();
|
const lastAction = history.pop();
|
||||||
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
|
const cell = boardElement.querySelector(`.cell[data-index="${lastAction.index}"]`);
|
||||||
|
|
||||||
if (cell) {
|
if (cell) {
|
||||||
cell.textContent = lastAction.previousValue;
|
cell.textContent = lastAction.previousValue;
|
||||||
if (lastAction.wasHint) {
|
if (lastAction.wasHint) {
|
||||||
@ -538,7 +525,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 하이라이트 기능 ---
|
|
||||||
function highlightCells() {
|
function highlightCells() {
|
||||||
document.querySelectorAll('.cell').forEach(cell => {
|
document.querySelectorAll('.cell').forEach(cell => {
|
||||||
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
cell.classList.remove('highlight-focused', 'highlight-same-number', 'highlight-selected-number');
|
||||||
@ -559,21 +545,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 게임 완료 및 모달 ---
|
|
||||||
async function checkSolution() {
|
async function checkSolution() {
|
||||||
let answerString = "";
|
let answerString = "";
|
||||||
boardElement.childNodes.forEach(cell => {
|
boardElement.childNodes.forEach(cell => {
|
||||||
answerString += cell.textContent || '0';
|
answerString += cell.textContent || '0';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (answerString.includes('0')) {
|
if (answerString.includes('0')) {
|
||||||
alert('모든 칸을 채워주세요!');
|
showAlert("알림",'모든 칸을 채워주세요!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// (★ 수정) API 경로 변경: /sudoku/validate -> /puzzle/sudoku/validate
|
|
||||||
// (★ 수정) currentPuzzleId 변수 사용
|
|
||||||
const response = await fetch('/puzzle/sudoku/validate', {
|
const response = await fetch('/puzzle/sudoku/validate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@ -582,14 +563,23 @@
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.correct) {
|
if (result.correct) {
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
alert('🎉 정답입니다!');
|
|
||||||
showRankingModal(); // 랭킹 모달 표시
|
// ▼▼▼ 기존 alert 및 showRankingModal 대신 통합 모달 호출 ▼▼▼
|
||||||
|
const minutes = Math.floor(secondsElapsed / 60);
|
||||||
|
const seconds = secondsElapsed % 60;
|
||||||
|
showGameSuccessModal({
|
||||||
|
gameType: 'SUDOKU',
|
||||||
|
contextId: currentPuzzleId,
|
||||||
|
successMessage: `정답입니다! 완료 시간: ${minutes}분 ${seconds}초`,
|
||||||
|
primaryScore: secondsElapsed,
|
||||||
|
secondaryScore: null
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
alert('🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
|
showAlert("알림",'🤔 틀린 부분이 있습니다. 다시 확인해주세요.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('정답 확인 중 오류 발생:', error);
|
console.error('정답 확인 중 오류 발생:', error);
|
||||||
alert('정답 확인 중 오류가 발생했습니다.');
|
showAlert("알림",'정답 확인 중 오류가 발생했습니다.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -600,64 +590,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
completeBtn.addEventListener('click', checkSolution);
|
completeBtn.addEventListener('click', checkSolution);
|
||||||
/**
|
|
||||||
* (★ 수정) user.js의 공통 fetchRanks 함수를 사용하도록 수정
|
|
||||||
*/
|
|
||||||
async function showRankingModal() {
|
|
||||||
modalOverlay.classList.remove('hidden');
|
|
||||||
document.getElementById('username-input').value = '';
|
|
||||||
submitRankBtn.disabled = false;
|
|
||||||
rankingList.innerHTML = '<li>로딩 중...</li>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// user.js의 공통 fetchRanks 함수 호출 (스도쿠 퍼즐 ID 전달)
|
|
||||||
// currentGameType 변수가 정의되어 있어야 합니다. 예: const currentGameType = 'sudoku';
|
|
||||||
const currentGameType = 'SUDOKU';
|
|
||||||
const rankings = await fetchRanks(currentGameType, currentPuzzleId);
|
|
||||||
|
|
||||||
rankingList.innerHTML = '';
|
|
||||||
if (rankings.length === 0) {
|
|
||||||
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
|
||||||
} else {
|
|
||||||
rankings.forEach((rank, index) => {
|
|
||||||
const li = document.createElement('li');
|
|
||||||
const minutes = Math.floor(rank.primaryScore / 60).toString().padStart(2, '0');
|
|
||||||
const seconds = (rank.primaryScore % 60).toString().padStart(2, '0');
|
|
||||||
li.innerHTML = `<span>${index + 1}위: ${rank.playerName}</span> <span>${minutes}:${seconds}</span>`;
|
|
||||||
rankingList.appendChild(li);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('랭킹 조회 중 오류 발생:', error);
|
|
||||||
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (★ 수정) user.js의 공통 submitRank 함수를 사용하도록 수정
|
|
||||||
*/
|
|
||||||
submitRankBtn.addEventListener('click', async () => {
|
|
||||||
const userName = document.getElementById('username-input').value.trim();
|
|
||||||
if (!userName) return alert('이름을 입력해주세요.');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// user.js의 공통 submitRank 함수 호출
|
|
||||||
const currentGameType = 'SUDOKU';
|
|
||||||
await submitRank(currentGameType, currentPuzzleId, userName, secondsElapsed, null);
|
|
||||||
|
|
||||||
alert('랭킹이 성공적으로 등록되었습니다!');
|
|
||||||
showRankingModal(); // 랭킹 목록 새로고침
|
|
||||||
submitRankBtn.disabled = true; // 중복 등록 방지
|
|
||||||
} catch (error) {
|
|
||||||
console.error('랭킹 등록 중 오류 발생:', error);
|
|
||||||
alert('랭킹 등록에 실패했습니다. 다시 시도해주세요.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function resetGameView() {
|
function resetGameView() {
|
||||||
setupContainer.classList.remove('hidden');
|
setupContainer.classList.remove('hidden');
|
||||||
gameContainer.classList.add('hidden');
|
boardElement.classList.add('hidden');
|
||||||
numberInputButtons.classList.add('hidden');
|
gameControlsContainer.classList.add('hidden');
|
||||||
clearInterval(timerInterval);
|
clearInterval(timerInterval);
|
||||||
selectedNumber = null;
|
selectedNumber = null;
|
||||||
focusedCell = null;
|
focusedCell = null;
|
||||||
@ -670,11 +610,17 @@
|
|||||||
closeModalBtn.addEventListener('click', () => {
|
closeModalBtn.addEventListener('click', () => {
|
||||||
modalOverlay.classList.add('hidden');
|
modalOverlay.classList.add('hidden');
|
||||||
resetGameView();
|
resetGameView();
|
||||||
|
if (typeof updateGameRanking === 'function') {
|
||||||
|
updateGameRanking(currentGameType, null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
retryBtn.addEventListener('click', () => {
|
retryBtn.addEventListener('click', () => {
|
||||||
gameOverModal.classList.add('hidden');
|
gameOverModal.classList.add('hidden');
|
||||||
resetGameView();
|
resetGameView();
|
||||||
|
if (typeof updateGameRanking === 'function') {
|
||||||
|
updateGameRanking(currentGameType, null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -23,8 +23,10 @@
|
|||||||
const grayscaleImg = document.getElementById('grayscale-reveal');
|
const grayscaleImg = document.getElementById('grayscale-reveal');
|
||||||
const originalImg = document.getElementById('original-reveal');
|
const originalImg = document.getElementById('original-reveal');
|
||||||
|
|
||||||
grayscaleImg.src = currentPuzzleData.grayscaleImage;
|
// [수정] Base64 대신 URL 경로를 사용하도록 변경
|
||||||
originalImg.src = currentPuzzleData.originalImage;
|
grayscaleImg.src = `/puzzle/images/${currentPuzzleData.grayscaleImageFile}`;
|
||||||
|
originalImg.src = `/puzzle/images/${currentPuzzleData.originalImageFile}`;
|
||||||
|
|
||||||
|
|
||||||
puzzleContainer.style.transition = 'opacity 0.5s';
|
puzzleContainer.style.transition = 'opacity 0.5s';
|
||||||
puzzleContainer.style.opacity = '0';
|
puzzleContainer.style.opacity = '0';
|
||||||
@ -150,10 +152,10 @@
|
|||||||
|
|
||||||
deleteBtn.addEventListener('click', async () => {
|
deleteBtn.addEventListener('click', async () => {
|
||||||
if (!currentPuzzleData || !currentPuzzleData.id) {
|
if (!currentPuzzleData || !currentPuzzleData.id) {
|
||||||
alert('삭제할 퍼즐이 선택되지 않았습니다.');
|
showAlert("알림",'삭제할 퍼즐이 선택되지 않았습니다.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!confirm('정말로 이 퍼즐을 삭제하시겠습니까?')) {
|
if (!showConfirm("확인",'정말로 이 퍼즐을 삭제하시겠습니까?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -30,9 +30,11 @@
|
|||||||
<div class="tab-link active" onclick="openTab(event, 'myInfo')">내 정보</div>
|
<div class="tab-link active" onclick="openTab(event, 'myInfo')">내 정보</div>
|
||||||
<div class="tab-link" onclick="openTab(event, 'myPosts')">내가 쓴 글</div>
|
<div class="tab-link" onclick="openTab(event, 'myPosts')">내가 쓴 글</div>
|
||||||
<div class="tab-link" onclick="openTab(event, 'myComments')">내가 쓴 댓글</div>
|
<div class="tab-link" onclick="openTab(event, 'myComments')">내가 쓴 댓글</div>
|
||||||
|
<div class="tab-link" onclick="openTab(event, 'myRanks')">내 게임 랭킹</div>
|
||||||
<th:block sec:authorize="hasRole('ADMIN')">
|
<th:block sec:authorize="hasRole('ADMIN')">
|
||||||
<div class="tab-link" onclick="openTab(event, 'userManagement')">회원 관리</div>
|
<div class="tab-link" onclick="openTab(event, 'userManagement')">회원 관리</div>
|
||||||
<div class="tab-link" onclick="openTab(event, 'contentManagement')">콘텐츠 관리</div>
|
<div class="tab-link" onclick="openTab(event, 'postManagement')">게시물 관리</div>
|
||||||
|
<div class="tab-link" onclick="openTab(event, 'bannerManagement')">배너 관리</div>
|
||||||
</th:block>
|
</th:block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -77,6 +79,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="myRanks" class="tab-content">
|
||||||
|
<div class="box">
|
||||||
|
<ul class="post-list">
|
||||||
|
<li th:if="${#lists.isEmpty(myRanks)}">플레이한 게임 기록이 없습니다.</li>
|
||||||
|
<li th:each="rank : ${myRanks}">
|
||||||
|
<div>
|
||||||
|
<span th:switch="${rank.gameType.name()}">
|
||||||
|
<strong th:case="'GAME_2048'">2048</strong>
|
||||||
|
<strong th:case="'SUDOKU'">스도쿠</strong>
|
||||||
|
<strong th:case="'SPIDER'">스파이더 카드</strong>
|
||||||
|
<strong th:case="'NONOGRAM'">노노그램</strong>
|
||||||
|
<strong th:case="*">기타 게임</strong>
|
||||||
|
</span>
|
||||||
|
<span style="margin-left: 1em;" th:switch="${rank.gameType.name()}">
|
||||||
|
<span th:case="'SUDOKU'" th:text="|기록: ${rank.primaryScore / 60}분 ${rank.primaryScore % 60}초|"></span>
|
||||||
|
<span th:case="'NONOGRAM'" th:text="|기록: ${rank.primaryScore / 60}분 ${rank.primaryScore % 60}초|"></span>
|
||||||
|
<span th:case="'SPIDER'" th:text="|기록: ${rank.primaryScore} moves|"></span>
|
||||||
|
<span th:case="*" th:text="|점수: ${#numbers.formatInteger(rank.primaryScore, 3, 'COMMA')}|"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span th:text="${#temporals.format(rank.timestamp, 'yyyy-MM-dd HH:mm')}"></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
|
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h4>권한 요청</h4>
|
<h4>권한 요청</h4>
|
||||||
@ -105,7 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="contentManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
|
<div id="postManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h4>최신 글 관리</h4>
|
<h4>최신 글 관리</h4>
|
||||||
<ul class="post-list">
|
<ul class="post-list">
|
||||||
@ -120,6 +148,29 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="bannerManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
|
||||||
|
<div class="box">
|
||||||
|
<h4>배너 이미지 관리</h4>
|
||||||
|
<ul class="post-list">
|
||||||
|
<li th:each="image : ${allImages}" th:id="'image-row-' + ${image.id}">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1em;">
|
||||||
|
<img th:src="@{'/api/images/' + ${image.fileName}}" alt="Image Thumbnail" style="width: 100px; height: 60px; object-fit: cover; border-radius: 4px;"/>
|
||||||
|
<div>
|
||||||
|
<strong th:text="${image.fileName}"></strong><br>
|
||||||
|
<span th:if="${image.isBannerCandidate}" style="color: #2a9d8f; font-weight: bold;">(배너로 사용 중)</span>
|
||||||
|
<span th:unless="${image.isBannerCandidate}" style="color: #888;">(배너로 사용 안 함)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button th:if="${!image.isBannerCandidate}" class="button small primary" th:onclick="handleBannerPermission('[[${image.id}]]', 'approve')">배너로 승인</button>
|
||||||
|
<button th:if="${image.isBannerCandidate}" class="button small alt" th:onclick="handleBannerPermission('[[${image.id}]]', 'revoke')">승인 해제</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li th:if="${#lists.isEmpty(allImages)}">업로드된 이미지가 없습니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -211,7 +262,8 @@
|
|||||||
* @param {'block' | 'unblock'} action - 수행할 작업
|
* @param {'block' | 'unblock'} action - 수행할 작업
|
||||||
*/
|
*/
|
||||||
function handleContent(postId, action) {
|
function handleContent(postId, action) {
|
||||||
const url = `/blog/post/${postId}/${action}`;
|
const cleanpostId = postId.replace(/^"|"$/g, '');
|
||||||
|
const url = `/blog/post/${cleanpostId}/${action}`;
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { [csrfHeader]: csrfToken }
|
headers: { [csrfHeader]: csrfToken }
|
||||||
@ -227,6 +279,42 @@
|
|||||||
})
|
})
|
||||||
.catch(error => console.error('Error:', error));
|
.catch(error => console.error('Error:', error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (관리자) 이미지의 배너 사용 권한을 승인하거나 해제하는 함수
|
||||||
|
* @param {string} imageId - 대상 이미지의 ID
|
||||||
|
* @param {'approve' | 'revoke'} action - 수행할 작업
|
||||||
|
*/
|
||||||
|
function handleBannerPermission(imageId, action) {
|
||||||
|
const cleanImageId = imageId.replace(/^"|"$/g, '');
|
||||||
|
|
||||||
|
// [수정] 깨끗하게 정리된 cleanImageId를 URL에 사용합니다.
|
||||||
|
const url = `/api/images/${cleanImageId}/${action === 'approve' ? 'approve-banner' : 'revoke-banner'}`;
|
||||||
|
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { [csrfHeader]: csrfToken }
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Server returned an error.');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data && data.id) {
|
||||||
|
alert(`이미지 상태를 성공적으로 변경했습니다.`);
|
||||||
|
location.reload(); // 페이지를 새로고침하여 상태(버튼, 텍스트)를 즉시 반영
|
||||||
|
} else {
|
||||||
|
alert('작업에 실패했습니다.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('작업 중 오류가 발생했습니다.');
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
@ -13,9 +13,8 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<section class="col-3 col-6-narrower col-12-mobilep">
|
<section class="col-3 col-6-narrower col-12-mobilep">
|
||||||
<h3>Rank of Views</h3>
|
<h3 id="ranking-title">Rank of Views</h3>
|
||||||
<ul class="rank_of_view" >
|
<ul class="rank_of_view" >
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
<section class="col-3 col-6-narrower col-12-mobilep">
|
<section class="col-3 col-6-narrower col-12-mobilep">
|
||||||
@ -64,5 +63,66 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 푸터에 게임 랭킹을 조회하고 표시하는 함수
|
||||||
|
* @param {string} gameType - 게임 종류 (예: 'SUDOKU')
|
||||||
|
* @param {string|null} contextId - 퍼즐 ID나 난이도 같은 특정 컨텍스트
|
||||||
|
*/
|
||||||
|
async function updateGameRanking(gameType, contextId) {
|
||||||
|
const rankingList = document.querySelector('.rank_of_view');
|
||||||
|
const rankingTitle = document.getElementById('ranking-title');
|
||||||
|
if (!rankingList || !rankingTitle) return;
|
||||||
|
|
||||||
|
rankingTitle.textContent = '게임 랭킹'; // 제목을 '게임 랭킹'으로 변경
|
||||||
|
rankingList.innerHTML = '<li>랭킹을 불러오는 중...</li>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 통합 랭킹 API 호출
|
||||||
|
const response = await fetch(`/api/ranks/list?gameType=${gameType}&contextId=${contextId || 'null'}`);
|
||||||
|
if (!response.ok) throw new Error('랭킹 조회 실패');
|
||||||
|
|
||||||
|
const rankings = await response.json();
|
||||||
|
|
||||||
|
rankingList.innerHTML = ''; // 기존 목록 초기화
|
||||||
|
if (rankings.length === 0) {
|
||||||
|
rankingList.innerHTML = '<li>아직 등록된 랭킹이 없습니다.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rankings.forEach((rank, index) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const formattedScore = formatScore(rank.primaryScore, rank.gameType);
|
||||||
|
li.innerHTML = `<span>${index + 1}. ${rank.playerName}</span> <strong>${formattedScore}</strong>`;
|
||||||
|
rankingList.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('게임 랭킹 업데이트 중 오류:', error);
|
||||||
|
rankingList.innerHTML = '<li>랭킹을 불러올 수 없습니다.</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 푸터 메인 로직 ---
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 현재 페이지가 스스로를 게임 페이지로 정의했는지 확인
|
||||||
|
if (window.pageContext && window.pageContext.pageType === 'game') {
|
||||||
|
// 노노그램처럼 페이지 로드 시점에 랭킹 대상을 알 수 있으면 즉시 랭킹 로드
|
||||||
|
if (window.pageContext.gameType && window.pageContext.contextId !== undefined) {
|
||||||
|
updateGameRanking(window.pageContext.gameType, window.pageContext.contextId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// --- ▼ 기존에 사용하시던 블로그 '많이 본 글' 조회 로직을 여기에 넣으세요 ▼ ---
|
||||||
|
// 예시: loadBlogViewRankings();
|
||||||
|
// 지금은 임시 문구로 대체합니다.
|
||||||
|
const rankingList = document.querySelector('.rank_of_view');
|
||||||
|
if(rankingList) rankingList.innerHTML = '<li>블로그 순위가 여기에 표시됩니다.</li>';
|
||||||
|
fetchRankOfViews();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</th:block>
|
</th:block>
|
||||||
</html>
|
</html>
|
||||||
@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<link th:href="@{/css/main.css}" rel="stylesheet" />
|
<link th:href="@{/css/main.css}" rel="stylesheet" />
|
||||||
<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_parameter" th:content="${_csrf.parameterName}"/>
|
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/>
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
/*
|
/*
|
||||||
|
|||||||
@ -9,6 +9,25 @@
|
|||||||
<title th:text="${pageTitle ?: 'Bum''s'}">Bum's</title>
|
<title th:text="${pageTitle ?: 'Bum''s'}">Bum's</title>
|
||||||
<th:block th:replace="~{fragments/includes :: includes}"></th:block>
|
<th:block th:replace="~{fragments/includes :: includes}"></th:block>
|
||||||
<th:block layout:fragment="head"></th:block>
|
<th:block layout:fragment="head"></th:block>
|
||||||
|
<script th:inline="javascript" sec:authorize="isAuthenticated()">
|
||||||
|
/*<![CDATA[*/
|
||||||
|
// 로그인한 사용자의 정보를 전역 currentUser 객체에 저장
|
||||||
|
window.currentUser = {
|
||||||
|
isLoggedIn: true,
|
||||||
|
username: /*[[${#authentication.principal.username}]]*/ 'user'
|
||||||
|
};
|
||||||
|
/*]]>*/
|
||||||
|
</script>
|
||||||
|
<script th:inline="javascript" sec:authorize="isAnonymous()">
|
||||||
|
/*<![CDATA[*/
|
||||||
|
// 비로그인 상태 정의
|
||||||
|
window.currentUser = {
|
||||||
|
isLoggedIn: false,
|
||||||
|
username: null
|
||||||
|
};
|
||||||
|
/*]]>*/
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="is-preload">
|
<body class="is-preload">
|
||||||
<div id="page-wrapper">
|
<div id="page-wrapper">
|
||||||
@ -54,6 +73,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="unified-game-success-modal" class="pop_layer">
|
||||||
|
<div class="pop_container"> <div class="pop_conts"> <h2 id="ugsm-title">🎉 성공! 🎉</h2>
|
||||||
|
<p id="ugsm-message">여기에 성공 메시지가 표시됩니다.</p>
|
||||||
|
<div style="text-align: left; margin: 15px 0;">
|
||||||
|
<h4>🏆 현재 랭킹</h4>
|
||||||
|
<ol id="ugsm-ranking-list" style="list-style-position: inside; padding-left: 0;">
|
||||||
|
<li>로딩 중...</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div id="ugsm-guest-ranking">
|
||||||
|
<input type="text" id="ugsm-player-name" placeholder="이름을 입력하세요">
|
||||||
|
<button id="ugsm-save-score-btn" class="button primary">점수 저장</button>
|
||||||
|
</div>
|
||||||
|
<div id="ugsm-user-ranking" style="display:none;">
|
||||||
|
<p style="font-weight: bold; color: #4CAF50;">로그인 계정으로 자동 등록되었습니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn_r">
|
||||||
|
<a href="#" class="btn_layerClose">닫기</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<th:block layout:fragment="head"></th:block>
|
<th:block layout:fragment="head"></th:block>
|
||||||
<th:block th:replace="~{fragments/footer :: footer}"></th:block>
|
<th:block th:replace="~{fragments/footer :: footer}"></th:block>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user