This commit is contained in:
lunaticbum 2025-09-15 17:18:44 +09:00
parent cc43ea8e0a
commit 1ab12cb6d9
29 changed files with 4951 additions and 2912 deletions

View File

@ -1,6 +1,26 @@
import com.github.jk1.license.render.*
import com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter
import com.github.jk1.license.filter.LicenseBundleNormalizer
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import com.github.jk1.license.render.InventoryMarkdownReportRenderer
import org.jsoup.Jsoup
//import org.gradle.internal.impldep.org.jsoup.Jsoup
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath ("org.jsoup:jsoup:1.18.1")
// 빌드 스크립트에서 commonmark 라이브러리를 사용할 수 있도록 추가합니다.
classpath("org.commonmark:commonmark:0.18.0")
}
}
plugins {
kotlin("jvm") version "1.9.25"
@ -8,6 +28,7 @@ plugins {
id("org.springframework.boot") version "3.3.4"
id("io.spring.dependency-management") version "1.1.6"
id("com.github.jk1.dependency-license-report") version "2.0"
}
group = "kr.lunaticbum.back"
@ -135,20 +156,72 @@ tasks.jar {
})
}
// ✅ licenseReport는 이전과 동일하게 Markdown을 생성하도록 둡니다.
licenseReport {
// 라이센스 고지 파일을 반환할 경로 default는 $projectDir/reports/dependency-license
outputDir = "$projectDir/build/licenses"
renderers = arrayOf(InventoryMarkdownReportRenderer())
// filters = arrayOf(com.github.jk1.license.filter.LicenseBundleNormalizer(), com.github.jk1.license.filter.ExcludeTransitiveDependenciesFilter())
}
// markdown 생성
// renderers = listOf(InventoryMarkdownReportRenderer()).toTypedArray()
tasks.register("updateLicensePage") {
dependsOn("generateLicenseReport")
// html 생성
renderers = listOf(InventoryHtmlReportRenderer()).toTypedArray()
doLast {
// file("$projectDir/build/licenses").listFiles().forEach {
// println("${it.absolutePath}: ${it.name}")
// }
// ... 로그 출력 로직은 그대로 유지 ...
println("🚀 'updateLicensePage' 태스크를 시작합니다.")
// xml 생성
// renderers = [new XmlReportRenderer()]
val licenseMarkdownFile = file("$projectDir/build/licenses/licenses.md")
val targetHtmlFile = file("src/main/resources/templates/content/licenses.html")
// 보고서에 첫 번째 수준 종속성만 표기
filters = listOf(LicenseBundleNormalizer(), ExcludeTransitiveDependenciesFilter()).toTypedArray()
}
println(" - 원본 마크다운 파일: ${licenseMarkdownFile.path}")
println(" - 대상 HTML 파일: ${targetHtmlFile.path}")
if (!licenseMarkdownFile.exists()) {
throw GradleException("❌ 라이선스 마크다운 파일이 생성되지 않았습니다. '${licenseMarkdownFile.path}'")
}
val licenseMarkdown = licenseMarkdownFile.readText()
println(" - 마크다운 파일을 성공적으로 읽었습니다. (내용 길이: ${licenseMarkdown.length})")
val parser = Parser.builder().build()
val renderer = HtmlRenderer.builder().build()
val licenseHtml = renderer.render(parser.parse(licenseMarkdown))
println(" - 마크다운을 HTML로 변환했습니다. (HTML 길이: ${licenseHtml.length})")
// ✅ Jsoup으로 HTML 파일을 파싱합니다.
val doc = Jsoup.parse(targetHtmlFile, "UTF-8")
// ✅ CSS 선택자를 이용해 ID가 'license-content-container'인 태그를 선택하고
// 그 내부 HTML을 생성된 라이선스 내용으로 교체합니다.
doc.selectFirst("#license-content-container")?.html(licenseHtml)
println(" - HTML 파일 내 placeholder div의 내용을 교체했습니다.")
// ✅ 변경된 HTML 내용을 파일에 다시 씁니다.
targetHtmlFile.writeText(doc.outerHtml())
println("✅ 라이선스 정보(HTML)가 '${targetHtmlFile.name}' 파일에 성공적으로 업데이트되었습니다.")
}
}
// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
// [수정 전] tasks.build { dependsOn(tasks.getByName("updateLicensePage")) }
tasks.named("build") { // [수정 후] 'build' 태스크를 더 안전하게 참조합니다.
dependsOn(tasks.named("updateLicensePage"))
}
tasks.named("bootJar") { // [수정 후] 'build' 태스크를 더 안전하게 참조합니다.
dependsOn(tasks.named("updateLicensePage"))
}
//
//// 'build' 태스크 실행 시 이 작업이 자동으로 수행되도록 연결
//tasks.build {
// dependsOn(tasks.getByName("updateLicensePage"))
//}

View File

@ -15,28 +15,27 @@ import org.springframework.http.MediaType
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.AuthenticationException
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.web.AuthenticationEntryPoint
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.access.AccessDeniedHandler
import org.springframework.security.web.authentication.RememberMeServices
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository
import org.springframework.web.ErrorResponse
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import javax.sql.DataSource
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize 어노테이션을 사용하기 위해 추가
class SecurityConfig(
private val userManager: UserManager,
private val bCryptPasswordEncoder: BCryptPasswordEncoder,
@ -47,95 +46,89 @@ class SecurityConfig(
@Bean
fun webSecurityCustomizer(): WebSecurityCustomizer {
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다. (권한 검사 불필요)
// 이미지 경로는 Spring Security 필터 체인 자체를 무시하도록 설정합니다.
return WebSecurityCustomizer { web ->
web.ignoring().requestMatchers("/blog/post/images/**")
web.ignoring().requestMatchers("/api/images/**", "/images/**")
}
}
// RememberMeServices를 Bean으로 생성하고 필드에 할당하거나, 생성자 주입을 할 수 있음
@Bean
fun rememberMeServices(): RememberMeServices {
val key = "your-remember-me-key"
return PersistentTokenBasedRememberMeServices(key, userManager,
tokenRepository as PersistentTokenRepository?
).apply {
setParameter("remember-me") // 기본 파라미터명
setTokenValiditySeconds(86400) // 토큰 유효시간 설정
// 필요시 setAlwaysRemember(true) 등 추가 설정 가능
println("CALLED rememberMeServices")
setParameter("remember-me")
setTokenValiditySeconds(86400 * 7) // 7일
}
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val configuration = CorsConfiguration()
configuration.allowedOrigins = listOf("*") // 모든 도메인 허용
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") // 모든 HTTP 메서드 허용
configuration.allowedHeaders = listOf("*") // 모든 헤더 허용
configuration.allowedOrigins = listOf("*")
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
configuration.allowedHeaders = listOf("*")
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration) // 모든 경로에 이 설정 적용
source.registerCorsConfiguration("/**", configuration)
return source
}
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
// ★★★ 1. CORS 설정을 적용하도록 .cors {} 를 추가합니다. ★★★
http.cors { }
.csrf { csrf ->
// [수정] 사용자의 요구사항대로 공개 POST API 목록을 CSRF 예외에 다시 추가합니다.
csrf.ignoringRequestMatchers(
"/user/login.bjx", "/user/joinUser.bjx", "/tlg/repotToMe.bjx",
"/blog/post/imageUpload.bjx", "/blog/post.bjx",
"/puzzle/**", // ★ 게임 관련 API (전체)
"/puzzle/play/**", // ★
"/bums/save/**", // ★ 위치 저장 API
"/rank/**", // ★ 랭킹 API
"/sudoku/**" // ★
"/api/ranks/submit" // 통합 랭킹 API
)
}.authorizeHttpRequests { auth ->
auth
// 1. 정적 리소스 = permitAll (변경 없음)
// 1. 정적 리소스 = permitAll
.requestMatchers(
"/webfonts/**", "/css/**", "/js/**", "/images/**",
"/webjars/**", "/assets/**"
"/webfonts/**", "/css/**", "/js/**", "/assets/**", "/webjars/**"
).permitAll()
// 2. 공개 GET API 및 페이지 = permitAll
.requestMatchers(HttpMethod.GET,
"/", "/home.bs", "/bums/where.bs",
"/user/login.bs", "/user/signup.bs",
"/blog/viewer/**", "/blog/posts", "/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx",
"/blog/comments/{commentId}/replies.bjx",
"/blog/posts/{postId}/comments.bjx",
"/puzzle/play", "/puzzle/2048", "/puzzle/sudoku", "/puzzle/spider",
"/puzzle/**", // ★ 게임 GET 요청 전체 허용
"/rank/**", // ★ 랭킹 GET 요청 전체 허용
"/sudoku/**" // ★
"/user/login.bs", "/user/join.bs",
"/blog/viewer/**", "/blog/posts",
"/blog/rankOfViews.bjx", "/blog/recentOfPost.bjx",
"/blog/posts/{postId}/comments.bjx", "/blog/comments/{commentId}/replies.bjx",
"/blog/categories.bjx", "/blog/hashtags.bjx",
"/puzzle/**", "/api/ranks/list", "/licenses"
).permitAll()
// 3. 공개 POST API = permitAll (요구사항 반영)
// 3. 공개 POST API = permitAll
.requestMatchers(HttpMethod.POST,
"/user/login.bjx",
"/user/joinUser.bjx",
"/tlg/repotToMe.bjx",
"/bums/save/loc.api", // ★ 위치 저장 POST 공개
"/puzzle/spider/**", // ★ 스파이더 POST API(deal, undo 등) 전체 공개
"/rank/**", // ★ 랭킹 제출 POST API 전체 공개
"/sudoku/**" // ★ 스도쿠 POST API 전체 공개
"/user/login.bjx", "/user/joinUser.bjx",
"/api/ranks/submit",
// [수정] 와일드카드를 사용하여 모든 게시물의 좋아요/싫어요 허용
"/blog/post/*/like.bjx",
"/blog/post/*/unlike.bjx"
).permitAll()
// [중요] 블로그 '좋아요', '댓글 작성' 등은 위 permitAll 목록에 없으므로
// 여전히 '4. 나머지 요청'으로 분류되어 인증 + CSRF 보호를 받습니다.
// 4. 'WRITE' 또는 'ADMIN' 권한이 필요한 요청
.requestMatchers(
"/blog/edit/**",
"/blog/post.bjx",
"/blog/post/imageUpload.bjx"
).hasAnyRole("WRITE", "ADMIN")
// 4. 나머지 모든 요청 = authenticated (변경 없음)
// 5. 'ADMIN' 권한이 필요한 요청 (my_info.html의 관리자 기능)
.requestMatchers(
"/user/approve-writer/**", "/user/reject-writer/**",
"/blog/post/*/block", "/blog/post/*/unblock"
).hasRole("ADMIN")
// 6. 나머지 모든 요청 = authenticated (인증 필요)
.anyRequest().authenticated()
}.formLogin { form ->
// (이하 formLogin, rememberMe, logout 설정은 동일)
form.loginPage("/user/login.bs")
form.loginPage("/home.bs?action=login")
.loginProcessingUrl("/user/login.bs") // 로그인 처리 URL 명시 (선택사항)
.defaultSuccessUrl("/", true)
.permitAll()
}.rememberMe { rememberMe ->
@ -156,9 +149,10 @@ class SecurityConfig(
authenticationManagerBuilder
.userDetailsService(userManager)
.passwordEncoder(bCryptPasswordEncoder)
return authenticationManagerBuilder.build() // .and() 없이 직접 build() 호출
return authenticationManagerBuilder.build()
}
private val unauthorizedEntryPoint =
AuthenticationEntryPoint { request: HttpServletRequest?, response: HttpServletResponse, authException: AuthenticationException? ->
val fail: ErrorResponse = ErrorResponse.create( Throwable("아직 못들어와"),

View File

@ -1,262 +1,262 @@
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
}
}
//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
// }
//
//}

View File

@ -1,6 +1,7 @@
package kr.lunaticbum.back.lun.controllers
import com.google.gson.Gson
import com.google.protobuf.LazyStringArrayList.emptyList
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
@ -13,27 +14,38 @@ import kr.lunaticbum.back.lun.utils.JwtUtil
import kr.lunaticbum.back.lun.utils.LogService
import kr.lunaticbum.back.lun.utils.extractModelData
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.domain.PageRequest
import org.springframework.http.MediaType
import org.springframework.http.ResponseCookie
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.web.bind.annotation.*
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.web.authentication.RememberMeServices
import org.springframework.security.web.context.HttpSessionSecurityContextRepository
import reactor.core.publisher.Mono
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.*
import javax.naming.AuthenticationException
import kotlin.collections.emptyList
@RestController
@RequestMapping("/user")
class UserController(
private val rememberMeServices: RememberMeServices
private val rememberMeServices: RememberMeServices,
private val userManager: UserManager, // 의존성 주입 추가
private val postManager: PostManager, // 의존성 주입 추가
private val commentService: CommentService // 의존성 주입 추가
) {
@ -43,8 +55,6 @@ class UserController(
@Autowired
lateinit var logService: LogService
@Autowired
lateinit var userManager: UserManager
@GetMapping("join.bs")
fun hello(httpServletRequest: HttpServletRequest): ResultMV {
@ -223,4 +233,80 @@ class UserController(
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
/**
* [신규 추가] ' 정보' 페이지를 위한 핸들러
*/
@GetMapping("/info")
suspend fun myInfoPage(@AuthenticationPrincipal userDetails: UserDetails?): ResultMV {
if (userDetails == null) {
return ResultMV("redirect:/home.bs?action=login")
}
val vm = ResultMV("content/user/my_info")
val username = userDetails.username
// 1. 기본 유저 정보 조회
val user = userManager.findById(username)?.block()
if (user != null) {
// 가입일을 보기 좋은 형식으로 변환하여 모델에 추가
val joinDate = Instant.ofEpochMilli(user.user_join)
.atZone(ZoneId.systemDefault())
.toLocalDate()
vm.modelMap["user"] = user
vm.modelMap["joinDate"] = joinDate.format(DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"))
}
// 2. 내가 쓴 글 목록 조회 (최신 10개)
val myPosts = postManager.findPostsByWriter(username, PageRequest.of(0, 10)).collectList().block()
vm.modelMap["myPosts"] = myPosts ?: emptyList()
// 3. 내가 쓴 댓글 목록 조회 (최신 10개)
val myComments = commentService.findCommentsByWriter(username, PageRequest.of(0, 10)).collectList().block()
vm.modelMap["myComments"] = myComments ?: emptyList()
vm.modelMap["pageTitle"] = "내 정보" // 동적 페이지 제목 설정
val isAdmin = userDetails?.authorities?.any { it.authority == "ROLE_ADMIN" } == true
vm.modelMap["isAdmin"] = isAdmin
if (isAdmin) {
// 관리자일 경우, 추가 정보 조회
vm.modelMap["allUsers"] = userManager.findAllUsers().collectList().block()
vm.modelMap["permissionRequests"] = userManager.findUsersRequestingWritePermission().collectList().block()
vm.modelMap["allRecentPosts"] = postManager.findAllVersionsPaginated(PageRequest.of(0, 20)).block() // 모든 글 조회
}
return vm
}
// [신규] 글쓰기 권한 요청 API
@PostMapping("/request-write")
@ResponseBody
fun requestWrite(@AuthenticationPrincipal userDetails: UserDetails?): Mono<ResponseEntity<String>> {
if (userDetails == null) return Mono.just(ResponseEntity.status(401).build())
return userManager.requestWritePermission(userDetails.username)
.map { ResponseEntity.ok("요청이 완료되었습니다.") }
.defaultIfEmpty(ResponseEntity.status(404).body("사용자를 찾을 수 없습니다."))
}
// [신규] 글쓰기 권한 승인 API (관리자 전용)
@PostMapping("/approve-writer/{userId}")
@PreAuthorize("hasRole('ADMIN')")
@ResponseBody
fun approveWriter(@PathVariable userId: String): Mono<ResponseEntity<User>> {
return userManager.approveWritePermission(userId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
// [신규] 글쓰기 권한 거절 API (관리자 전용)
@PostMapping("/reject-writer/{userId}")
@PreAuthorize("hasRole('ADMIN')")
@ResponseBody
fun rejectWriter(@PathVariable userId: String): Mono<ResponseEntity<User>> {
return userManager.rejectWritePermission(userId)
.map { ResponseEntity.ok(it) }
.defaultIfEmpty(ResponseEntity.notFound().build())
}
}

View File

@ -1,14 +1,18 @@
package kr.lunaticbum.back.lun.model
import com.fasterxml.jackson.databind.ObjectMapper
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import kr.lunaticbum.back.lun.utils.LogService
import lombok.AllArgsConstructor
import lombok.Data
import lombok.Getter
import lombok.NoArgsConstructor
import okio.Timeout
import org.bson.BsonType
import org.bson.codecs.pojo.annotations.BsonId
import org.bson.codecs.pojo.annotations.BsonRepresentation
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.annotation.Id
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
@ -31,60 +35,57 @@ import java.time.Duration
import org.springframework.data.mongodb.core.index.CompoundIndex // [신규 추가]
import org.springframework.data.mongodb.core.index.IndexDirection // [신규 추가]
import org.springframework.data.mongodb.core.index.Indexed // [신규 추가]
import java.util.Base64
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "Post")
@CompoundIndex(name = "origin_time_desc_idx", def = "{'originId': 1, 'modifyTime': -1}")
class Post {
data class Post(
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var id: String? = null
var id: String? = null,
var originId: String? = null
var originId: String? = null,
var title : String? = null
var content : String? = null
var category : String? = null
var tags : String? = null
var title : String? = null,
var content : String? = null,
var category : String? = null,
var tags : String? = null,
var html : String? = null
var image : String? = null
var thumb : String? = null
var html : String? = null,
var image : String? = null,
var thumb : String? = null,
var writer : String? = null
var writeTime : Long = 0
var posting : Boolean = false
var firstPostLat : Double = 0.0
var firstPostLon : Double = 0.0
var firstAddress = ""
var modifyAddress = ""
var writer : String? = null,
var writeTime : Long = 0,
var posting : Boolean = false,
var firstPostLat : Double = 0.0,
var firstPostLon : Double = 0.0,
var firstAddress : String = "",
var modifyAddress : String = "",
// [수정] 모든 정렬(Sort) 쿼리의 핵심이므로 내림차순 인덱스 추가
@Indexed(direction = IndexDirection.DESCENDING)
var modifyTime : Long = 0
var modifyLat : Double = 0.0
var modifyLon : Double = 0.0
var modifyTime : Long = 0,
var modifyLat : Double = 0.0,
var modifyLon : Double = 0.0,
// [수정] 인기글(rankOfViews) 조회 쿼리를 위한 내림차순 인덱스 추가
@Indexed(direction = IndexDirection.DESCENDING)
var readCount : Long = 0
var voteCount : Long = 0
var unlikeCount : Long = 0
}
var readCount : Long = 0,
var voteCount : Long = 0,
var unlikeCount : Long = 0,
var isBlocked: Boolean = false
)
@Document(collection = "Comment")
class Comment {
data class Comment (
@BsonId
var id: String? = null
var postId: String? = null // 댓글이 달린 포스트의 id
var parentId: String? = null // 대댓글이면 상위 댓글의 id, 최상위 댓글이면 null
var writer: String? = null
var content: String? = null
var writeTime: Long? = null
var id: String? = null,
var postId: String? = null , // 댓글이 달린 포스트의 id
var parentId: String? = null ,// 대댓글이면 상위 댓글의 id, 최상위 댓글이면 null
var writer: String? = null,
var content: String? = null,
var writeTime: Long? = null,
var mentions: List<String>? = null // 언급된 유저 아이디(선택)
}
)
@Data
@NoArgsConstructor
@ -115,6 +116,7 @@ data class AggregationCount(val totalCount: Long)
interface CommentRepository : ReactiveMongoRepository<Comment, String> {
fun findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId: String): Flux<Comment> // 최상위 댓글
fun findByParentIdOrderByWriteTimeAsc(parentId: String): Flux<Comment>
fun findByWriterOrderByWriteTimeDesc(writer: String, pageable: Pageable): Flux<Comment> // [신규 추가]
}
@Service
@ -130,7 +132,9 @@ class CommentService(private val commentRepository: CommentRepository) {
fun getCommentsForPost(postId: String): Flux<Comment> {
return commentRepository.findByPostIdAndParentIdIsNullOrderByWriteTimeAsc(postId)
}
fun findCommentsByWriter(writer: String, pageable: Pageable): Flux<Comment> { // [신규 추가]
return commentRepository.findByWriterOrderByWriteTimeDesc(writer, pageable)
}
// 기타: 대댓글 불러오기, 신고/삭제, 멘션 알림 등 확장 가능
}
@ -143,6 +147,28 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
fun findTop5ByOrderByReadCountDesc(): Flux<Post>
fun findTop5ByOrderByModifyTimeDesc(): Flux<Post>
// [신규 추가] 익명 사용자용 인기글 (공개된 고유 포스트 대상)
@Aggregation(pipeline = [
"{ \$match: { posting: true, isBlocked: false } }", // [수정됨]
"{ \$sort: { modifyTime: -1 } }", // 2. 최신 버전이 먼저 오도록 정렬
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }", // 3. 고유 포스트 그룹화
"{ \$replaceRoot: { newRoot: \"\$post\" } }", // 4. 그룹화된 문서를 원래 형태로 복원
"{ \$sort: { readCount: -1 } }", // 5. 조회수 순으로 정렬
"{ \$limit: 5 }" // 6. 상위 5개만 선택
])
fun findTop5UniquePublishedByReadCountDesc(): Flux<Post>
// [신규 추가] 익명 사용자용 최신글 (공개된 고유 포스트 대상)
@Aggregation(pipeline = [
"{ \$match: { posting: true, isBlocked: false } }", // [수정됨]
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
"{ \$sort: { modifyTime: -1 } }", // 최신순으로 다시 정렬
"{ \$limit: 5 }"
])
fun findTop5UniquePublishedByModifyTimeDesc(): Flux<Post>
/**
* 익명 사용자를 위한 '고유 최신 ' 목록을 페이지네이션으로 조회합니다.
@ -166,6 +192,22 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
])
fun countLatestUniqueOrigin(): Mono<AggregationCount> // 헬퍼 클래스로 매핑
@Aggregation(pipeline = [
"{ \$match: { \$or: [ { writer: ?0 }, { posting: true } ] } }",
"{ \$sort: { modifyTime: -1 } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] }, post: { \$first: \"\$\$ROOT\" } } }",
"{ \$replaceRoot: { newRoot: \"\$post\" } }",
"{ \$sort: { \"modifyTime\": -1 } }"
])
fun findLatestUniqueForWriterPaginated(username: String, pageable: Pageable): Flux<Post>
@Aggregation(pipeline = [
"{ \$match: { \$or: [ { writer: ?0 }, { posting: true } ] } }",
"{ \$group: { _id: { \$ifNull: [\"\$originId\", \"\$_id\"] } } }",
"{ \$count: \"totalCount\" }"
])
fun countLatestUniqueForWriter(username: String): Mono<AggregationCount>
/**
* 익명 사용자를 위한 '고유 최신 ' 목록을 페이지네이션으로 조회합니다.
@ -194,6 +236,9 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
"{ \$count: \"totalCount\" }"
])
fun countLatestUniquePublished(): Mono<AggregationCount> // 메서드 이름 변경
fun findByWriterOrderByModifyTimeDesc(writer: String, pageable: Pageable): Flux<Post> // [신규 추가]
}
@ -208,6 +253,47 @@ class PostManager(
@Autowired
private lateinit var bCryptPasswordEncoder: PasswordEncoder
// [신규] 게시물 차단
fun blockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = true
postRepository.save(post)
}
}
// [신규] 게시물 차단 해제
fun unblockPost(postId: String): Mono<Post> {
return postRepository.findById(postId).flatMap { post ->
post.isBlocked = false
postRepository.save(post)
}
}
fun findById(id: String): Mono<Post> {
return postRepository.findById(id)
}
/**
* [신규 추가] '글쓰기' 권한 사용자를 위한 메서드 (고유 최신 + 자신의 페이지네이션 조회)
*/
fun findLatestUniqueForWriter(username: String, pageable: Pageable) : Mono<List<Post>> {
return postRepository.findLatestUniqueForWriterPaginated(username, pageable)
.collectList()
}
fun findPostsByWriter(writer: String, pageable: Pageable): Flux<Post> { // [신규 추가]
return postRepository.findByWriterOrderByModifyTimeDesc(writer, pageable)
}
/**
* [신규 추가] '글쓰기' 권한 사용자가 보는 글의 개수
*/
fun countLatestUniqueForWriter(username: String): Mono<Long> {
return postRepository.countLatestUniqueForWriter(username)
.map { it.totalCount }
.switchIfEmpty(Mono.just(0L))
}
fun getPost(id: String): Mono<Post> {
val query = Query.query(Criteria.where("id").`is`(id))
val update = Update().inc("readCount", 1)
@ -334,5 +420,147 @@ class PostManager(
}
}
// [기존] 로그인 사용자용 인기글 (메서드 이름 명확화: getTop5 -> getTop5AllVersions)
fun getTop5AllVersionsByViews(): Flux<Post> {
return postRepository.findTop5ByOrderByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
}
// [기존] 로그인 사용자용 최신글 (메서드 이름 명확화)
fun getRecent5AllVersions(): Flux<Post> {
return postRepository.findTop5ByOrderByModifyTimeDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [신규 추가] 익명 사용자용 인기글
fun getTop5UniquePublishedByViews(): Flux<Post> {
return postRepository.findTop5UniquePublishedByReadCountDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
// [신규 추가] 익명 사용자용 최신글
fun getRecent5UniquePublished(): Flux<Post> {
return postRepository.findTop5UniquePublishedByModifyTimeDesc().map { p ->
p.title = URLDecoder.decode(p.title, "UTF-8")
if (p.title?.isEmpty() == true) {
p.title = "무제(無題)"
}
p
}
}
}
/**
* common.js에서 Base64 인코딩 전송하는 데이터의 구조와 일치하는 DTO입니다.
* @param data 난독화된 실제 데이터 문자열
* @param key 데이터를 재조합하는 사용되는
* @param type 난독화 방식을 결정하는 타입
*/
data class EncryptedPayload(
val data: String = "",
val key: String = "",
val type: String = ""
)
@Getter
class RequestModel {
var type : String? = null
var key : String? = null
var data : String? = null
fun getKeyword() = key ?: ""
fun extractData() : String {
data?.let {
val reqString = data?.split(GlobalEnvironment.padding(getKeyword()))
val nb = arrayListOf<String>()
val na = arrayListOf<String>()
reqString?.get(0)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { na.addAll(it) }
reqString?.get(1)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { nb.addAll(it) }
val max = nb.size + na.size
val fullData = arrayListOf<String>()
for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } }
return fullData.joinToString("")
}
return ""
}
}
@Getter
class ReportModel {
var name : String? = null
var email : String? = null
var message : String? = null
}
object PayloadDecoder {
/**
* common.js의 unformat() 함수와 대칭되는 복호화 로직입니다.
* @param data 난독화된 데이터
* @param key 분리 기준이 되는
* @param type 복호화 방식을 결정하는 타입
* @return 원본 데이터 문자열
*/
private fun format(data: String, key: String, type: String): String {
val divider = "|*-*|$key|*-*|"
var (odd, even) = data.split(divider).let { it[0] to it[1] }
// 타입에 따라 unformat에서 적용된 reverse()를 다시 reverse()하여 원상복구
when (type) {
"T1" -> odd = odd.reversed()
"T2" -> even = even.reversed()
"T3" -> {
odd = odd.reversed()
even = even.reversed()
}
}
// odd와 even 문자열을 다시 조합하여 원본 데이터 생성
val result = StringBuilder()
val maxLength = maxOf(odd.length, even.length)
for (i in 0 until maxLength) {
if (i < even.length) result.append(even[i])
if (i < odd.length) result.append(odd[i])
}
return result.toString()
}
/**
* Base64로 인코딩된 전체 payload를 디코딩하고, 최종적으로 원하는 객체 타입으로 변환합니다.
* @param payload 컨트롤러가 받은 원시 Base64 문자열
* @param clazz 변환하고자 하는 최종 클래스 타입 (: Post::class.java)
* @return 변환된 객체
*/
fun <T> decode(payload: String, clazz: Class<T>, objectMapper: ObjectMapper): T {
// 1. Base64 디코딩 -> { "data": ..., "key": ..., "type": ... } 형태의 JSON 문자열이 됨
val b64Decoded = String(Base64.getDecoder().decode(payload))
// 2. 외부 JSON을 EncryptedPayload DTO로 변환
val encryptedPayload = objectMapper.readValue(b64Decoded, EncryptedPayload::class.java)
// 3. 내부의 난독화된 데이터를 복호화하여 원본 JSON 문자열을 얻음
val originalJson = format(encryptedPayload.data, encryptedPayload.key, encryptedPayload.type)
// 4. 원본 JSON을 최종 목표 객체로 변환하여 반환
return objectMapper.readValue(originalJson, clazz)
}
}

View File

@ -1,36 +0,0 @@
package kr.lunaticbum.back.lun.model
import kr.lunaticbum.back.lun.configs.GlobalEnvironment
import lombok.Getter
@Getter
class RequestModel {
var type : String? = null
var key : String? = null
var data : String? = null
fun getKeyword() = key ?: ""
fun extractData() : String {
data?.let {
val reqString = data?.split(GlobalEnvironment.padding(getKeyword()))
val nb = arrayListOf<String>()
val na = arrayListOf<String>()
reqString?.get(0)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { na.addAll(it) }
reqString?.get(1)?.replace(GlobalEnvironment.padding(getKeyword()),"")?.split("")?.toList()?.let { nb.addAll(it) }
val max = nb.size + na.size
val fullData = arrayListOf<String>()
for (idx in 0..max) { if (idx % 2 == 0) { if (nb.size > 0) { fullData.add(nb.removeLast()) } } else { if (na.size > 0) { fullData.add(na.removeLast()) } } }
return fullData.joinToString("")
}
return ""
}
}
@Getter
class ReportModel {
var name : String? = null
var email : String? = null
var message : String? = null
}

View File

@ -16,6 +16,7 @@ import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Repository
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import java.time.Duration
@ -24,27 +25,27 @@ import java.time.Duration
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "User")
class User {
data class User (
@BsonId
@BsonRepresentation(BsonType.OBJECT_ID)
var userId: String? = null
var userId: String? = null,
@Id
var user_id: String? = null
var user_pw: String? = null
var user_pw_check: String? = null
var user_id: String? = null,
var user_pw: String? = null,
var user_pw_check: String? = null,
var user_email: String? = null
var user_email: String? = null,
@CreatedDate
var user_join: Long = 0L
var user_join: Long = 0L,
// var user_name: String? = null
var isAccept : String? = null
var isAdmin : String? = null
var rememberMe : Boolean? = false
var isAccept : String? = null,
var isAdmin : String? = null,
var rememberMe : Boolean? = false,
var writePermissionRequested: Boolean = false) {
fun checkValid() : Boolean {
if (
((user_id?.length ?: 0) > 5) &&
@ -116,7 +117,7 @@ interface UserRepository : ReactiveMongoRepository<User, String> {
// @Query("{user_email :?0}")
// fun findByEmail(user_email: String): Mono<User>
fun findByWritePermissionRequested(requested: Boolean): Flux<User> // [신규 추가]
fun save(user: User): Mono<User>
}
@ -148,6 +149,22 @@ class UserManager(
return userRepository.findById(id)
}
// [신규] 글쓰기 권한 승인
fun approveWritePermission(userId: String): Mono<User> {
return userRepository.findById(userId).flatMap { user ->
user.isAccept = "Y"
user.writePermissionRequested = false
userRepository.save(user)
}
}
// [신규] 글쓰기 권한 요청 거절
fun rejectWritePermission(userId: String): Mono<User> {
return userRepository.findById(userId).flatMap { user ->
user.writePermissionRequested = false
userRepository.save(user)
}
}
fun save(user: User): Mono<User> {
@ -164,10 +181,33 @@ class UserManager(
override fun loadUserByUsername(username: String?): UserDetails {
logService.log("username ${username}")
var user = findById(username!!)?.blockOptional(Duration.ofMillis(5000L))?.get() ?: User()
// user.hashPassword(passwordEncoder)
val userRole = user.getRole().name // "READ", "WRITE", 또는 "ADMIN"
return org.springframework.security.core.userdetails.User.builder()
.username(user.user_id ?: "")
.password(user.user_pw)
.roles(if ("Y".equals(user.isAdmin)) Role.ADMIN.name else {Role.USER.name}).build()
.roles(userRole)
.build()
}
// [신규] 모든 사용자 목록 조회
fun findAllUsers(): Flux<User> {
return userRepository.findAll()
}
// [신규] 글쓰기 권한을 요청한 사용자 목록 조회
fun findUsersRequestingWritePermission(): Flux<User> {
return userRepository.findByWritePermissionRequested(true)
}
// [신규] 글쓰기 권한 요청
fun requestWritePermission(userId: String): Mono<User> {
return userRepository.findById(userId).flatMap { user ->
user.writePermissionRequested = true
userRepository.save(user)
}
}
}

View File

@ -1,551 +0,0 @@
/*
* MODIFIED COMMON.CSS
* This file is refactored to inherit styles and variables from main.css.
* It styles custom components (popups, editor, etc.) to match the Arcana theme.
*/
/* * Removed conflicting global 'html, body' styles.
* The site will now correctly use the font and background from main.css.
*/
/*
* --- POPUP AND DIM LAYER STYLES ---
* Reworked to use variables and styles from main.css for a consistent look.
*/
.pop_layer {
display: none;
position: fixed; /* Use fixed for proper viewport centering */
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Modern centering method */
width: 450px;
max-width: 90%; /* Ensure it's responsive on small screens */
height: auto;
background-color: var(--pure-white, #fff); /* Use theme's white variable */
border: 1px solid #e0e0e0; /* Use theme's standard border color */
box-shadow: 0 0 15px rgba(0, 0, 0, 0.15); /* Add a subtle shadow */
z-index: 1001; /* Ensure it's above the dim layer */
border-radius: 5px; /* Use theme's standard border-radius */
}
.pop_layer .pop_container {
padding: 2em;
}
.pop_layer .pop_conts h2 {
font-size: 1.75em; /* Match theme's h2 style */
margin-bottom: 1em;
text-align: center;
}
.pop_layer p.ctxt {
color: var(--font-color_default, #474747); /* Use theme's text color */
line-height: 1.65em;
}
.pop_layer .btn_r {
width: 100%;
margin-top: 1.5em;
padding-top: 1em;
border-top: 1px solid #e0e0e0; /* Use theme's divider color */
text-align: right;
}
/* Style the close button to match the theme's alternate button style */
a.btn_layerClose {
display: inline-block;
padding: 0 1.5em;
line-height: 2.75em;
text-decoration: none;
font-weight: 600;
border-radius: 5px;
cursor: pointer;
background-color: var(--button-alt-default, #555);
color: var(--pure-white, #fff);
transition: background-color 0.2s ease-in-out;
}
a.btn_layerClose:hover {
background-color: var(--button-alt-hover, #626262);
color: var(--pure-white, #fff) !important; /* Ensure hover color override */
}
.dim_layer {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.4); /* Slightly softer dim */
}
/* * --- LOGIN FORM STYLES ---
* Adapted to use the default form input styles from main.css.
*/
#loginFormElement input[type="text"],
#loginFormElement input[type="password"] {
margin-bottom: 1em; /* Add spacing between fields */
}
#loginFormElement button {
margin-top: 1em;
width: 100%;
}
/* Custom Checkbox Styling */
#loginFormElement span {
vertical-align: middle;
margin-left: 0.5em;
}
#rememberMe {
vertical-align: middle;
width: 22px;
height: 22px;
appearance: none;
-webkit-appearance: none;
border: 1px solid #e0e0e0;
border-radius: 5px;
background-color: var(--pure-white, #fff);
cursor: pointer;
position: relative;
top: -2px;
}
#rememberMe:checked {
background-color: var(--point-color, #FFA500);
border-color: var(--point-color, #FFA500);
}
#rememberMe:checked::after {
content: '';
position: absolute;
top: 2px;
left: 7px;
width: 6px;
height: 12px;
border: solid var(--pure-white, #fff);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* * --- BLOG POST & EDITOR STYLES ---
*/
/* Control box below the editor in viewer.html */
.write_controllbox {
margin-top: 2em;
padding: 0;
display: flex;
flex-direction: row; /* [수정] 아이템을 가로(row)로 정렬합니다. */
align-items: stretch; /* [수정] 모든 박스가 동일한 높이를 갖도록 stretch로 변경 */
list-style: none;
gap: 15px; /* Use gap for spacing */
}
.write_option {
display: flex;
flex-direction: column; /* [수정] 자식(제목, 내용)을 세로로 쌓습니다. */
justify-content: flex-start; /* 내용을 상단부터 표시합니다. */
align-items: flex-start; /* 모든 내용을 왼쪽 정렬합니다. */
flex: 1 1 0%; /* [핵심] 1:1:1 비율로 동일한 너비를 갖도록 설정 */
min-width: 0; /* [추가] flex item이 내용 래핑을 위해 축소될 수 있도록 허용 */
padding: 0.75em;
margin: 0;
background: var(--pure-white, #fff);
border: solid 1px #e0e0e0;
border-radius: 5px;
color: inherit;
min-height: 48px;
}
/* * --- QUILL EDITOR THEME OVERRIDE ---
* Modified to blend with the Arcana theme's light background.
*/
/* Define custom fonts for Quill to match the site */
.ql-font-source-sans-pro { font-family: 'Source Sans Pro', sans-serif; }
/* Add any other fonts you've whitelisted in common.js */
.ql-toolbar.ql-snow {
background: var(--almost-white, #f7f7f7); /* Use theme's light gray */
border: 1px solid #e0e0e0;
border-bottom: none; /* Connect toolbar to editor visually */
border-radius: 5px 5px 0 0;
padding: 12px 8px;
}
.ql-container.ql-snow {
min-width: unset;
background: var(--pure-white, #fff);
border: 1px solid #e0e0e0;
border-radius: 0 0 5px 5px;
color: var(--font-color_default, #474747);
}
/* * --- QUILL TOOLBAR SEPARATOR (구분선) ---
* 기본 'margin-right: 15px' 대신 패딩과 보더로 구분선을 추가합니다.
*/
.ql-toolbar.ql-snow .ql-formats {
margin-right: unset;
}
.ql-toolbar.ql-snow .ql-formats {
min-width: unset;
margin-right: 8px; /* 기본 마진 감소 */
padding-right: 8px; /* 보더 오른쪽에 패딩 추가 */
border-right: 1px solid #e0e0e0; /* 테마의 보더 색상으로 구분선 추가 */
}
/* 툴바의 마지막 그룹에는 구분선과 불필요한 여백을 제거합니다. */
.ql-toolbar.ql-snow .ql-formats:last-child {
min-width: unset;
margin-right: 0;
padding-right: 0;
border-right: none;
}
/* Ensure editor content uses the theme's default font and size */
.ql-editor {
font-family: 'Source Sans Pro', sans-serif;
font-size: 1rem; /* Base size */
line-height: 1.65em;
}
.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button {
background: none;
border: none;
cursor: pointer;
display: inline-block;
float: left;
height: 24px;
padding: 3px 5px;
min-width: unset;
}
.ql-snow .ql-picker-label {
cursor: pointer;
display: inline-block;
height: 100%;
padding-left: unset;
padding-right: unset;
position: relative;
width: 100%;
}
/*
* --- COMMENT SECTION ---
* Styled to fit the main theme.
*/
.comment-section {
margin-top: 3em;
padding-top: 2em;
border-top: 1px solid #e0e0e0;
}
#comment-form-container {
display: flex;
flex-direction: column;
margin-bottom: 2em;
}
/* Inherits styles from main.css 'textarea' selector */
#comment-input {
min-height: 100px;
margin-bottom: 1em;
}
#comments-list .comment {
background: var(--almost-white, #f7f7f7);
border-radius: 5px;
padding: 1em 1.5em;
margin-bottom: 1em;
border: 1px solid #e0e0e0;
}
.comment-header {
font-weight: 600;
color: var(--font-color_default, #474747);
margin-bottom: 0.5em;
}
.comment-time {
font-size: 0.9em;
color: #999;
margin-left: 0.5em;
}
.comment-content {
line-height: 1.65em;
white-space: pre-wrap;
word-wrap: break-word;
}
/* 컨트롤 박스 내부 공통 제목 스타일 */
.write_option .tag-title {
font-weight: 600;
margin-bottom: 0.35em; /* [수정] 제목과 내용 사이에 하단 여백을 줍니다. */
color: #555;
font-size: 0.85em; /* [추가] 제목 라벨 폰트를 살짝 작게 */
text-transform: uppercase; /* [추가] 라벨처럼 보이도록 영문 대문자 처리 */
/* 태그 제목(HASHTAGS)이 태그 아이템과 동일한 스타일을 공유할 경우 대비 (선택 사항) */
display: block;
width: 100%;
}
/* 컨트롤 박스 내부 공통 태그 아이템 스타일 (뷰어 모드용) */
.write_option .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
border: 1px solid #e0e0e0;
border-radius: 15px; /* 둥근 태그 모양 */
padding: 0.2em 0.8em;
margin-right: 0.5em;
font-size: 0.9em;
color: var(--font-color_default, #474747);
}
/* 읽기 모드에서는 커서 모양을 기본으로 변경 */
.write_option.controlbox-category:not(.btn-example),
.write_option.controlbox-hashtag:not(.btn-example) {
cursor: default;
}
/* [추가] 태그/내용을 감싸는 래퍼 (줄바꿈 담당) */
.tag-content-wrapper {
display: flex; /* 내부 아이템(태그 등)을 가로로 배치 */
flex-wrap: wrap; /* 태그가 많으면 자동 줄바꿈 (기본값) */
gap: 4px 6px; /* 태그 사이의 간격 (세로 4px, 가로 6px) */
line-height: 1.4; /* 내용 줄간격 */
width: 100%; /* 부모 박스(write_option) 너비를 채움 */
}
/* --- 컨트롤 박스 공통 스타일 (카테고리, 해시태그, 위치) --- */
/* [수정] 3개 박스에 공통 스타일을 적용합니다. */
.write_option.controlbox-category,
.write_option.controlbox-hashtag,
.write_option.controlbox-location {
/* 모든 박스는 .write_option의 flex: 1 1 0% 규칙을 따릅니다. */
box-sizing: border-box;
min-height: 50px; /* 모든 박스의 최소 높이 통일 */
}
/* --- 컨트롤 박스 내부 태그 아이템 공통 스타일 (팝업 편집용) --- */
/* (카테고리와 해시태그 박스 내부의 태그 아이템만 이 스타일을 공유) */
.write_option.controlbox-location .tag-item,
.write_option.controlbox-category .tag-item,
.write_option.controlbox-hashtag .tag-item {
background-color: var(--highlight-bg-color, #e9e9e9);
color: var(--highlight-text-color, #333);
border-radius: 5px;
padding: 3px 8px;
font-size: 0.9em;
white-space: normal;
margin-right: 0; /* .tag-content-wrapper의 gap이 간격을 제어하므로 마진 제거 */
}
/* --- 컨트롤 박스: 카테고리 (개별 내부 스타일) --- */
.write_option.controlbox-category .tag-title {
font-weight: bold;
color: #555;
/* margin-right: 8px; <-- 세로 레이아웃으로 변경되어 불필요 */
white-space: nowrap;
}
/* --- 컨트롤 박스: 해시태그 (개별 내부 스타일) --- */
.write_option.controlbox-hashtag {
/* 태그 사이의 내부 간격 (래퍼가 대신 처리) */
/* gap: 8px 10px; */
}
.write_option.controlbox-hashtag .tag-title {
font-weight: bold;
color: #555;
margin-right: 0px;
white-space: nowrap;
}
/* --- 컨트롤 박스: 콘텐츠 래퍼 동작 정의 --- */
/* 해시태그는 .tag-content-wrapper의 기본값(flex-wrap: wrap)을 그대로 사용합니다. */
/* [추가/그룹화] 카테고리와 위치 박스는 내용이 줄바꿈되지 않도록 공통 처리합니다. */
/*.write_option.controlbox-category .tag-content-wrapper,*/
/*.write_option.controlbox-location .tag-content-wrapper {*/
/* white-space: nowrap; !* 내용이 길어도 한 줄로 표시 (줄바꿈 방지) *!*/
/* overflow: hidden;*/
/* text-overflow: ellipsis;*/
/*}*/
/* 추가: 컨트롤 박스 자체의 레이아웃 (이전 CSS에서 남음. 현재 구조에서는 영향 없음) */
#main_container .write_option {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
/* === POPUP TAG LIST STYLES (신규 추가) === */
.pop_conts .tag-list {
display: flex; /* 태그들을 가로로 나열 */
flex-wrap: wrap; /* 공간이 부족하면 다음 줄로 자동 줄바꿈 */
gap: 8px; /* 태그 아이템 사이의 간격을 8px로 지정 */
margin-bottom: 1.5em; /* 태그 목록과 하단 입력창 사이의 간격 */
padding-bottom: 1.5em; /* 태그 목록 하단 패딩 */
border-bottom: 1px solid #e0e0e0; /* 입력창과 목록을 구분하는 선 */
}
.pop_conts .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
border: 1px solid #e0e0e0;
border-radius: 15px; /* 둥근 알약 모양 */
padding: 0.3em 0.9em; /* 내부 여백 */
font-size: 0.9em;
cursor: pointer; /* 클릭 가능하도록 커서 변경 */
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.pop_conts .tag-item:hover {
background-color: #e0e0e0; /* 호버 시 배경색 변경 */
border-color: #ccc;
}
/* === (신규 추가) POPUP STAGING AREA STYLES === */
.pop_conts .tag-list-label {
font-weight: 600;
font-size: 0.9em;
color: #777;
margin-top: 1.2em; /* 각 섹션 상단 여백 */
margin-bottom: 0.5em;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
/* 선택된 항목을 담는 스테이징 영역 스타일 */
.pop_conts .staging-area {
min-height: 40px;
padding: 0.5em;
background: #fdfdfd;
border: 1px dashed #ccc; /* 점선 테두리로 구분 */
border-radius: 5px;
}
/* 스테이징 영역 내부의 태그에서 'X' (삭제) 버튼 스타일 */
.staging-area .tag-item .remove-tag {
font-family: "Courier New", monospace; /* 'X' 버튼 글꼴 */
font-weight: bold;
color: #cc0000; /* 어두운 빨간색 */
margin-left: 8px;
cursor: pointer; /* 클릭 가능하도록 */
display: inline-block;
padding: 0 2px;
}
.staging-area .tag-item .remove-tag:hover {
color: #ff0000; /* 밝은 빨간색 */
background-color: #eee;
}
/* 카테고리 스테이징 영역은 flex가 아니므로, 내부 태그 마진을 별도 지정 */
#selected-category-area .tag-item {
margin: 0;
}
#selected-category-area i, #selected-hashtags-area i {
color: #999;
}
/* === 대댓글 기능 CSS (신규 추가) === */
/* 1. 대댓글 목록 컨테이너 (들여쓰기) */
.reply-list {
margin-left: 40px;
padding-left: 15px;
border-left: 2px solid #eee;
}
/* 2. 댓글 헤더 (작성자/날짜/답글 버튼) */
.comment-header {
border-bottom: 1px dotted #ddd;
padding-bottom: 5px;
margin-bottom: 10px;
/* Flexbox를 사용해 요소를 양쪽으로 정렬 */
display: flex;
justify-content: space-between;
align-items: center;
}
.comment-author-info {
font-size: 0.9em;
}
.comment-author-info strong {
margin-right: 8px;
}
.comment-author-info .comment-date {
font-size: 0.9em;
color: #888;
}
/* 3. 답글 달기 버튼 */
.btn-reply {
font-size: 0.8em;
padding: 3px 8px;
cursor: pointer;
border: 1px solid #ccc;
background: #f9f9f9;
border-radius: 4px;
color: #555;
}
.btn-reply:hover {
background: #eee;
}
/* 4. 답글 상태 표시줄 (숨겨진 UI) */
#reply-status-bar {
display: none; /* JS로 제어 */
background: #f0f8ff;
border: 1px solid #bde0ff;
padding: 8px 12px;
margin-bottom: 10px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
#reply-status-text {
font-size: 0.9em;
color: #333;
font-weight: 500;
}
#btn-cancel-reply {
background: none;
border: none;
color: #E63946; /* 빨간색 계열 */
cursor: pointer;
font-size: 0.85em;
font-weight: bold;
}
@media screen and (max-width: 480px) {
.vote-controls {
display: flex; /* Flexbox 컨테이너로 변경 */
gap: 1em; /* 버튼 사이 간격 (기존 margin-left 대체) */
}
.vote-controls .button {
width: auto; /* main.css의 width: 100% 덮어쓰기 */
display: inline-block; /* main.css의 display: block 덮어쓰기 */
flex: 1 1 0; /* 1:1 비율로 공간을 나눠 갖도록 설정 */
margin-left: 0 !important; /* 혹시 모를 인라인 스타일 제거 */
}
}

View File

@ -3424,15 +3424,6 @@ button.small,
padding: 5px;
}
/*.login_form button {*/
/* width: 100%;*/
/* padding: 10px;*/
/* background-color: #4CAF50;*/
/* color: white;*/
/* border: none;*/
/* cursor: pointer;*/
/*}*/
.login_close {
position: absolute;
top: 10px;
@ -3441,3 +3432,654 @@ button.small,
cursor: pointer;
}
/*
* --- POPUP AND DIM LAYER STYLES ---
* Reworked to use variables and styles from main.css for a consistent look.
*/
.pop_layer {
display: none;
position: fixed; /* Use fixed for proper viewport centering */
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Modern centering method */
width: 450px;
max-width: 90%; /* Ensure it's responsive on small screens */
height: auto;
background-color: var(--pure-white, #fff); /* Use theme's white variable */
border: 1px solid #e0e0e0; /* Use theme's standard border color */
box-shadow: 0 0 15px rgba(0, 0, 0, 0.15); /* Add a subtle shadow */
z-index: 1001; /* Ensure it's above the dim layer */
border-radius: 5px; /* Use theme's standard border-radius */
}
.pop_layer .pop_container {
padding: 2em;
}
.pop_layer .pop_conts h2 {
font-size: 1.75em; /* Match theme's h2 style */
margin-bottom: 1em;
text-align: center;
}
.pop_layer p.ctxt {
color: var(--font-color_default, #474747); /* Use theme's text color */
line-height: 1.65em;
}
.pop_layer .btn_r {
width: 100%;
margin-top: 1.5em;
padding-top: 1em;
border-top: 1px solid #e0e0e0; /* Use theme's divider color */
text-align: right;
}
/* Style the close button to match the theme's alternate button style */
a.btn_layerClose {
display: inline-block;
padding: 0 1.5em;
line-height: 2.75em;
text-decoration: none;
font-weight: 600;
border-radius: 5px;
cursor: pointer;
background-color: var(--button-alt-default, #555);
color: var(--pure-white, #fff);
transition: background-color 0.2s ease-in-out;
}
a.btn_layerClose:hover {
background-color: var(--button-alt-hover, #626262);
color: var(--pure-white, #fff) !important; /* Ensure hover color override */
}
.dim_layer {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background-color: rgba(0, 0, 0, 0.4); /* Slightly softer dim */
}
/* * --- LOGIN FORM STYLES ---
* Adapted to use the default form input styles from main.css.
*/
#loginFormElement input[type="text"],
#loginFormElement input[type="password"] {
margin-bottom: 1em; /* Add spacing between fields */
}
#loginFormElement button {
margin-top: 1em;
width: 100%;
}
/* Custom Checkbox Styling */
#loginFormElement span {
vertical-align: middle;
margin-left: 0.5em;
}
#rememberMe {
vertical-align: middle;
width: 22px;
height: 22px;
appearance: none;
-webkit-appearance: none;
border: 1px solid #e0e0e0;
border-radius: 5px;
background-color: var(--pure-white, #fff);
cursor: pointer;
position: relative;
top: -2px;
}
#rememberMe:checked {
background-color: var(--point-color, #FFA500);
border-color: var(--point-color, #FFA500);
}
#rememberMe:checked::after {
content: '';
position: absolute;
top: 2px;
left: 7px;
width: 6px;
height: 12px;
border: solid var(--pure-white, #fff);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* * --- BLOG POST & EDITOR STYLES ---
*/
/* Control box below the editor in viewer.html */
.write_controllbox {
margin-top: 2em;
padding: 0;
display: flex;
flex-direction: row; /* [수정] 아이템을 가로(row)로 정렬합니다. */
align-items: stretch; /* [수정] 모든 박스가 동일한 높이를 갖도록 stretch로 변경 */
list-style: none;
gap: 15px; /* Use gap for spacing */
}
.write_option {
display: flex;
flex-direction: column; /* [수정] 자식(제목, 내용)을 세로로 쌓습니다. */
justify-content: flex-start; /* 내용을 상단부터 표시합니다. */
align-items: flex-start; /* 모든 내용을 왼쪽 정렬합니다. */
flex: 1 1 0%; /* [핵심] 1:1:1 비율로 동일한 너비를 갖도록 설정 */
min-width: 0; /* [추가] flex item이 내용 래핑을 위해 축소될 수 있도록 허용 */
padding: 0.75em;
margin: 0;
background: var(--pure-white, #fff);
border: solid 1px #e0e0e0;
border-radius: 5px;
color: inherit;
min-height: 48px;
}
/* * --- QUILL EDITOR THEME OVERRIDE ---
* Modified to blend with the Arcana theme's light background.
*/
/* Define custom fonts for Quill to match the site */
.ql-font-source-sans-pro { font-family: 'Source Sans Pro', sans-serif; }
/* Add any other fonts you've whitelisted in common.js */
.ql-toolbar.ql-snow {
background: var(--almost-white, #f7f7f7); /* Use theme's light gray */
border: 1px solid #e0e0e0;
border-bottom: none; /* Connect toolbar to editor visually */
border-radius: 5px 5px 0 0;
padding: 12px 8px;
}
.ql-container.ql-snow {
min-width: unset;
background: var(--pure-white, #fff);
border: 1px solid #e0e0e0;
border-radius: 0 0 5px 5px;
color: var(--font-color_default, #474747);
}
/* * --- QUILL TOOLBAR SEPARATOR (구분선) ---
* 기본 'margin-right: 15px' 대신 패딩과 보더로 구분선을 추가합니다.
*/
.ql-toolbar.ql-snow .ql-formats {
margin-right: unset;
}
.ql-toolbar.ql-snow .ql-formats {
min-width: unset;
margin-right: 8px; /* 기본 마진 감소 */
padding-right: 8px; /* 보더 오른쪽에 패딩 추가 */
border-right: 1px solid #e0e0e0; /* 테마의 보더 색상으로 구분선 추가 */
}
/* 툴바의 마지막 그룹에는 구분선과 불필요한 여백을 제거합니다. */
.ql-toolbar.ql-snow .ql-formats:last-child {
min-width: unset;
margin-right: 0;
padding-right: 0;
border-right: none;
}
/* Ensure editor content uses the theme's default font and size */
.ql-editor {
font-family: 'Source Sans Pro', sans-serif;
font-size: 1rem; /* Base size */
line-height: 1.65em;
}
.ql-snow.ql-toolbar button, .ql-snow .ql-toolbar button {
background: none;
border: none;
cursor: pointer;
display: inline-block;
float: left;
height: 24px;
padding: 3px 5px;
min-width: unset;
}
.ql-snow .ql-picker-label {
cursor: pointer;
display: inline-block;
height: 100%;
padding-left: unset;
padding-right: unset;
position: relative;
width: 100%;
}
/*
* --- COMMENT SECTION ---
* Styled to fit the main theme.
*/
.comment-section {
margin-top: 3em;
padding-top: 2em;
border-top: 1px solid #e0e0e0;
}
#comment-form-container {
display: flex;
flex-direction: column;
margin-bottom: 2em;
}
/* Inherits styles from main.css 'textarea' selector */
#comment-input {
min-height: 100px;
margin-bottom: 1em;
}
#comments-list .comment {
background: var(--almost-white, #f7f7f7);
border-radius: 5px;
padding: 1em 1.5em;
margin-bottom: 1em;
border: 1px solid #e0e0e0;
}
.comment-header {
font-weight: 600;
color: var(--font-color_default, #474747);
margin-bottom: 0.5em;
}
.comment-time {
font-size: 0.9em;
color: #999;
margin-left: 0.5em;
}
.comment-content {
line-height: 1.65em;
white-space: pre-wrap;
word-wrap: break-word;
}
/* 컨트롤 박스 내부 공통 제목 스타일 */
.write_option .tag-title {
font-weight: 600;
margin-bottom: 0.35em; /* [수정] 제목과 내용 사이에 하단 여백을 줍니다. */
color: #555;
font-size: 0.85em; /* [추가] 제목 라벨 폰트를 살짝 작게 */
text-transform: uppercase; /* [추가] 라벨처럼 보이도록 영문 대문자 처리 */
/* 태그 제목(HASHTAGS)이 태그 아이템과 동일한 스타일을 공유할 경우 대비 (선택 사항) */
display: block;
width: 100%;
}
/* 컨트롤 박스 내부 공통 태그 아이템 스타일 (뷰어 모드용) */
.write_option .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
border: 1px solid #e0e0e0;
border-radius: 15px; /* 둥근 태그 모양 */
padding: 0.2em 0.8em;
margin-right: 0.5em;
font-size: 0.9em;
color: var(--font-color_default, #474747);
}
/* 읽기 모드에서는 커서 모양을 기본으로 변경 */
.write_option.controlbox-category:not(.btn-example),
.write_option.controlbox-hashtag:not(.btn-example) {
cursor: default;
}
/* [추가] 태그/내용을 감싸는 래퍼 (줄바꿈 담당) */
.tag-content-wrapper {
display: flex; /* 내부 아이템(태그 등)을 가로로 배치 */
flex-wrap: wrap; /* 태그가 많으면 자동 줄바꿈 (기본값) */
gap: 4px 6px; /* 태그 사이의 간격 (세로 4px, 가로 6px) */
line-height: 1.4; /* 내용 줄간격 */
width: 100%; /* 부모 박스(write_option) 너비를 채움 */
}
/* --- 컨트롤 박스 공통 스타일 (카테고리, 해시태그, 위치) --- */
/* [수정] 3개 박스에 공통 스타일을 적용합니다. */
.write_option.controlbox-category,
.write_option.controlbox-hashtag,
.write_option.controlbox-location {
/* 모든 박스는 .write_option의 flex: 1 1 0% 규칙을 따릅니다. */
box-sizing: border-box;
min-height: 50px; /* 모든 박스의 최소 높이 통일 */
}
/* --- 컨트롤 박스 내부 태그 아이템 공통 스타일 (팝업 편집용) --- */
/* (카테고리와 해시태그 박스 내부의 태그 아이템만 이 스타일을 공유) */
.write_option.controlbox-location .tag-item,
.write_option.controlbox-category .tag-item,
.write_option.controlbox-hashtag .tag-item {
background-color: var(--highlight-bg-color, #e9e9e9);
color: var(--highlight-text-color, #333);
border-radius: 5px;
padding: 3px 8px;
font-size: 0.9em;
white-space: normal;
margin-right: 0; /* .tag-content-wrapper의 gap이 간격을 제어하므로 마진 제거 */
}
/* --- 컨트롤 박스: 카테고리 (개별 내부 스타일) --- */
.write_option.controlbox-category .tag-title {
font-weight: bold;
color: #555;
/* margin-right: 8px; <-- 세로 레이아웃으로 변경되어 불필요 */
white-space: nowrap;
}
/* --- 컨트롤 박스: 해시태그 (개별 내부 스타일) --- */
.write_option.controlbox-hashtag {
/* 태그 사이의 내부 간격 (래퍼가 대신 처리) */
/* gap: 8px 10px; */
}
.write_option.controlbox-hashtag .tag-title {
font-weight: bold;
color: #555;
margin-right: 0px;
white-space: nowrap;
}
/* --- 컨트롤 박스: 콘텐츠 래퍼 동작 정의 --- */
/* 해시태그는 .tag-content-wrapper의 기본값(flex-wrap: wrap)을 그대로 사용합니다. */
/* [추가/그룹화] 카테고리와 위치 박스는 내용이 줄바꿈되지 않도록 공통 처리합니다. */
/*.write_option.controlbox-category .tag-content-wrapper,*/
/*.write_option.controlbox-location .tag-content-wrapper {*/
/* white-space: nowrap; !* 내용이 길어도 한 줄로 표시 (줄바꿈 방지) *!*/
/* overflow: hidden;*/
/* text-overflow: ellipsis;*/
/*}*/
/* 추가: 컨트롤 박스 자체의 레이아웃 (이전 CSS에서 남음. 현재 구조에서는 영향 없음) */
#main_container .write_option {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
/* === POPUP TAG LIST STYLES (신규 추가) === */
.pop_conts .tag-list {
display: flex; /* 태그들을 가로로 나열 */
flex-wrap: wrap; /* 공간이 부족하면 다음 줄로 자동 줄바꿈 */
gap: 8px; /* 태그 아이템 사이의 간격을 8px로 지정 */
margin-bottom: 1.5em; /* 태그 목록과 하단 입력창 사이의 간격 */
padding-bottom: 1.5em; /* 태그 목록 하단 패딩 */
border-bottom: 1px solid #e0e0e0; /* 입력창과 목록을 구분하는 선 */
}
.pop_conts .tag-item {
display: inline-block;
background-color: var(--almost-white, #f7f7f7);
border: 1px solid #e0e0e0;
border-radius: 15px; /* 둥근 알약 모양 */
padding: 0.3em 0.9em; /* 내부 여백 */
font-size: 0.9em;
cursor: pointer; /* 클릭 가능하도록 커서 변경 */
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.pop_conts .tag-item:hover {
background-color: #e0e0e0; /* 호버 시 배경색 변경 */
border-color: #ccc;
}
/* === (신규 추가) POPUP STAGING AREA STYLES === */
.pop_conts .tag-list-label {
font-weight: 600;
font-size: 0.9em;
color: #777;
margin-top: 1.2em; /* 각 섹션 상단 여백 */
margin-bottom: 0.5em;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
/* 선택된 항목을 담는 스테이징 영역 스타일 */
.pop_conts .staging-area {
min-height: 40px;
padding: 0.5em;
background: #fdfdfd;
border: 1px dashed #ccc; /* 점선 테두리로 구분 */
border-radius: 5px;
}
/* 스테이징 영역 내부의 태그에서 'X' (삭제) 버튼 스타일 */
.staging-area .tag-item .remove-tag {
font-family: "Courier New", monospace; /* 'X' 버튼 글꼴 */
font-weight: bold;
color: #cc0000; /* 어두운 빨간색 */
margin-left: 8px;
cursor: pointer; /* 클릭 가능하도록 */
display: inline-block;
padding: 0 2px;
}
.staging-area .tag-item .remove-tag:hover {
color: #ff0000; /* 밝은 빨간색 */
background-color: #eee;
}
/* 카테고리 스테이징 영역은 flex가 아니므로, 내부 태그 마진을 별도 지정 */
#selected-category-area .tag-item {
margin: 0;
}
#selected-category-area i, #selected-hashtags-area i {
color: #999;
}
/* === 대댓글 기능 CSS (신규 추가) === */
/* 1. 대댓글 목록 컨테이너 (들여쓰기) */
.reply-list {
margin-left: 40px;
padding-left: 15px;
border-left: 2px solid #eee;
}
/* 2. 댓글 헤더 (작성자/날짜/답글 버튼) */
.comment-header {
border-bottom: 1px dotted #ddd;
padding-bottom: 5px;
margin-bottom: 10px;
/* Flexbox를 사용해 요소를 양쪽으로 정렬 */
display: flex;
justify-content: space-between;
align-items: center;
}
.comment-author-info {
font-size: 0.9em;
}
.comment-author-info strong {
margin-right: 8px;
}
.comment-author-info .comment-date {
font-size: 0.9em;
color: #888;
}
/* 3. 답글 달기 버튼 */
.btn-reply {
font-size: 0.8em;
padding: 3px 8px;
cursor: pointer;
border: 1px solid #ccc;
background: #f9f9f9;
border-radius: 4px;
color: #555;
}
.btn-reply:hover {
background: #eee;
}
/* 4. 답글 상태 표시줄 (숨겨진 UI) */
#reply-status-bar {
display: none; /* JS로 제어 */
background: #f0f8ff;
border: 1px solid #bde0ff;
padding: 8px 12px;
margin-bottom: 10px;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
#reply-status-text {
font-size: 0.9em;
color: #333;
font-weight: 500;
}
#btn-cancel-reply {
background: none;
border: none;
color: #E63946; /* 빨간색 계열 */
cursor: pointer;
font-size: 0.85em;
font-weight: bold;
}
@media screen and (max-width: 480px) {
.vote-controls {
display: flex; /* Flexbox 컨테이너로 변경 */
gap: 1em; /* 버튼 사이 간격 (기존 margin-left 대체) */
}
.vote-controls .button {
width: auto; /* main.css의 width: 100% 덮어쓰기 */
display: inline-block; /* main.css의 display: block 덮어쓰기 */
flex: 1 1 0; /* 1:1 비율로 공간을 나눠 갖도록 설정 */
margin-left: 0 !important; /* 혹시 모를 인라인 스타일 제거 */
}
}
/*private*/
/* 개별 위치 로그 아이템 스타일 */
.location-item {
background-color: #fcfcfc;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 20px;
padding: 15px 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease-in-out;
}
.location-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* 헤더 (날짜 및 경과 시간) */
.location-header {
display: flex; /* 가로로 배치 */
justify-content: space-between; /* 양 끝 정렬 */
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.location-time {
font-weight: bold;
color: #555;
font-size: 1.1em;
}
.location-between {
font-size: 0.9em;
color: #888;
background-color: #eef;
padding: 3px 8px;
border-radius: 4px;
}
/* 본문 (주소 및 좌표) */
.location-body {
font-size: 0.95em;
}
.location-address {
margin-top: 0;
margin-bottom: 8px;
color: #444;
word-break: break-all; /* 긴 주소 자동 줄바꿈 */
}
.location-coords {
margin: 0;
color: #666;
font-style: italic;
display: flex; /* 위도, 경도, 국가명 가로 배치 */
gap: 15px; /* 각 요소 사이 간격 */
flex-wrap: wrap; /* 화면 좁아지면 줄바꿈 */
}
.location-coords b {
color: #333;
font-weight: bold;
}
.location-country {
color: #777;
font-size: 0.9em;
}
/* 페이지네이션 스타일 */
.pagination {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.pagination a, .pagination span {
display: inline-block;
padding: 8px 15px;
margin: 0 4px;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #007bff;
background-color: #f8f9fa;
transition: all 0.2s ease;
}
.pagination a:hover {
background-color: #e9ecef;
border-color: #007bff;
color: #0056b3;
}
.pagination span { /* 현재 페이지 또는 비활성화된 버튼 */
color: #6c757d;
background-color: #e2e6ea;
border-color: #e2e6ea;
cursor: default;
}
/* 기존 CSS에서 불필요한 부분 제거 또는 통합 */
.where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */
/* display: none; */
}

View File

@ -1,128 +0,0 @@
/* 기본 레이아웃 및 폰트 */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f4f7f6;
color: #333;
line-height: 1.6;
}
#main_layer {
max-width: 900px;
margin: 30px auto;
background-color: #ffffff;
padding: 25px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.layer {
padding: 10px 0;
}
/* 개별 위치 로그 아이템 스타일 */
.location-item {
background-color: #fcfcfc;
border: 1px solid #e0e0e0;
border-radius: 6px;
margin-bottom: 20px;
padding: 15px 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease-in-out;
}
.location-item:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
/* 헤더 (날짜 및 경과 시간) */
.location-header {
display: flex; /* 가로로 배치 */
justify-content: space-between; /* 양 끝 정렬 */
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 10px;
}
.location-time {
font-weight: bold;
color: #555;
font-size: 1.1em;
}
.location-between {
font-size: 0.9em;
color: #888;
background-color: #eef;
padding: 3px 8px;
border-radius: 4px;
}
/* 본문 (주소 및 좌표) */
.location-body {
font-size: 0.95em;
}
.location-address {
margin-top: 0;
margin-bottom: 8px;
color: #444;
word-break: break-all; /* 긴 주소 자동 줄바꿈 */
}
.location-coords {
margin: 0;
color: #666;
font-style: italic;
display: flex; /* 위도, 경도, 국가명 가로 배치 */
gap: 15px; /* 각 요소 사이 간격 */
flex-wrap: wrap; /* 화면 좁아지면 줄바꿈 */
}
.location-coords b {
color: #333;
font-weight: bold;
}
.location-country {
color: #777;
font-size: 0.9em;
}
/* 페이지네이션 스타일 */
.pagination {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.pagination a, .pagination span {
display: inline-block;
padding: 8px 15px;
margin: 0 4px;
border: 1px solid #ddd;
border-radius: 5px;
text-decoration: none;
color: #007bff;
background-color: #f8f9fa;
transition: all 0.2s ease;
}
.pagination a:hover {
background-color: #e9ecef;
border-color: #007bff;
color: #0056b3;
}
.pagination span { /* 현재 페이지 또는 비활성화된 버튼 */
color: #6c757d;
background-color: #e2e6ea;
border-color: #e2e6ea;
cursor: default;
}
/* 기존 CSS에서 불필요한 부분 제거 또는 통합 */
.where_item { /* 이전 코드의 클래스. 새 클래스로 대체되었으므로 필요 없으면 제거 */
/* display: none; */
}

View File

@ -52,8 +52,63 @@ var baseData = {
*/
window.addEventListener('DOMContentLoaded', () => {
const urlParams = new URLSearchParams(window.location.search);
const action = urlParams.get('action');
if (action === 'login') {
const loginPopup = document.getElementById('loginPopup');
if (loginPopup) {
// openPopup 함수는 특정 버튼(element)을 필요로 하므로,
// 팝업 div 자체에 임시로 'to' 속성을 부여하여 재사용합니다.
loginPopup.setAttribute('to', '#loginPopup');
openPopup(loginPopup);
}
} else if (action === 'signup') {
const signupPopup = document.getElementById('signupPopup');
if (signupPopup) {
signupPopup.setAttribute('to', '#signupPopup');
openPopup(signupPopup);
}
}
const openSignupBtn = document.getElementById('openSignupBtnFromLogin');
if (openSignupBtn) {
openSignupBtn.addEventListener('click', () => {
// 1. 현재 열려있는 로그인 팝업을 닫습니다.
closePopup();
// 2. 회원가입 팝업을 찾아서 엽니다.
const signupPopup = document.getElementById('signupPopup');
if (signupPopup) {
// openPopup 함수를 재사용하기 위해 'to' 속성을 설정합니다.
signupPopup.setAttribute('to', '#signupPopup');
openPopup(signupPopup);
}
});
}
console.log("DOM Loaded: Attaching Vanilla JS event listeners.");
const logoutButton = document.querySelector('a[href="javascript:logout()"]');
const isLoggedIn = !!logoutButton; // true 또는 false
const commentForm = document.querySelector('.comment-form-wrapper');
if (commentForm) {
if (!isLoggedIn) {
// 비로그인 상태면, 입력 필드와 버튼을 비활성화합니다.
const commentInput = commentForm.querySelector('#comment-input');
const commentSubmitBtn = commentForm.querySelector('#submit-comment');
if(commentInput) {
commentInput.disabled = true;
commentInput.placeholder = '댓글을 작성하려면 로그인이 필요합니다.';
}
if(commentSubmitBtn) {
commentSubmitBtn.disabled = true;
}
}
}
// --- 1. 사이드바 목록 가져오기 (이 기능은 이미 바닐라 JS였습니다) ---
if (document.querySelector(".rank_of_view")) {
fetchRankOfViews();
@ -107,8 +162,13 @@ window.addEventListener('DOMContentLoaded', () => {
const commentSubmitBtn = document.getElementById('submit-comment');
if (commentSubmitBtn) {
commentSubmitBtn.addEventListener('click', (e) => {
e.preventDefault(); // 기본 버튼 동작 방지
submitComment(); // 댓글 제출 함수 호출
e.preventDefault();
// isLoggedIn 변수를 사용하여 추가적인 클라이언트 측 방어
if (!isLoggedIn) {
alert('로그인이 필요합니다.');
return;
}
submitComment();
});
}
@ -311,7 +371,9 @@ async function uploadImage(blob) {
const formData = new FormData();
formData.append('file', blob);
let uploadUrl = getMainPath() + "/blog/post/imageUpload.bjx";
let imageUrlBase = getMainPath() + '/blog/post/images/';
// let imageUrlBase = getMainPath() + '/blog/post/images/';
// [수정] 이미지 URL의 기본 경로를 새로운 API 경로로 변경합니다.
let imageUrlBase = getMainPath() + '/api/images/'; // '/blog/post/images/' -> '/api/images/'
try {
// CSRF 토큰을 <meta> 태그에서 직접 읽어옵니다. (includes.html에 정의되어 있음)
@ -1331,3 +1393,5 @@ async function fetchRanks(gameType, contextId = null) {
}
return response.json();
}

View File

@ -1,67 +0,0 @@
/*
Arcana by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
*/
(function($) {
var $window = $(window),
$body = $('body');
// Breakpoints.
breakpoints({
wide: [ '1281px', '1680px' ],
normal: [ '981px', '1280px' ],
narrow: [ '841px', '980px' ],
narrower: [ '737px', '840px' ],
mobile: [ '481px', '736px' ],
mobilep: [ null, '480px' ]
});
// Play initial animations on page load.
$window.on('load', function() {
window.setTimeout(function() {
$body.removeClass('is-preload');
}, 100);
});
// Dropdowns.
$('#nav > ul').dropotron({
offsetY: -15,
hoverDelay: 0,
alignment: 'center'
});
// Nav.
// Bar.
$(
'<div id="titleBar">' +
'<a href="#navPanel" class="toggle"></a>' +
'<span class="title" onclick="javascript:gotoHome()">' + $('#logo').html() + '</span>' +
'</div>'
)
.appendTo($body);
// Panel.
$(
'<div id="navPanel">' +
'<nav>' +
$('#nav').navList() +
'</nav>' +
'</div>'
)
.appendTo($body)
.panel({
delay: 500,
hideOnClick: true,
hideOnSwipe: true,
resetScroll: true,
resetForms: true,
side: 'left',
target: $body,
visibleClass: 'navPanel-visible'
});
})(jQuery);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,411 @@
/*
* Arcana by HTML5 UP (html5up.net | @ajlkn)
* Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
*
* This file contains the theme's utility scripts (panel, polyfills, etc.)
*/
(function($) {
/**
* Converts a navigation menu (ul) into a flat list of links for use in a mobile panel.
* @return {jQuery} jQuery object
*/
$.fn.navList = function() {
var $this = $(this),
$a = $this.find('a'),
b = [];
$a.each(function() {
var $this = $(this),
indent = Math.max(0, $this.parents('li').length - 1),
href = $this.attr('href'),
target = $this.attr('target'),
// [Modified] Get the class and 'to' attribute of the original link to open the popup.
originalClass = $this.attr('class'),
toAttr = $this.attr('to');
// [Modified] Include both the default class and the original class for the mobile panel link.
var classes = 'link depth-' + indent;
if (typeof originalClass !== 'undefined' && originalClass != '') {
classes += ' ' + originalClass;
}
b.push(
'<a ' +
'class="' + classes + '"' + // Apply modified classes
( (typeof target !== 'undefined' && target != '') ? ' target="' + target + '"' : '') +
( (typeof href !== 'undefined' && href != '') ? ' href="' + href + '"' : '') +
// [Modified] Add the 'to' attribute to specify the popup target.
( (typeof toAttr !== 'undefined' && toAttr != '') ? ' to="' + toAttr + '"' : '') +
'>' +
'<span class="indent-' + indent + '"></span>' +
$this.text() +
'</a>'
);
});
return b.join('');
};
/**
* Transforms a specific element into a slide-out panel.
* @param {object} userConfig User settings object
* @return {jQuery} jQuery object
*/
$.fn.panel = function(userConfig) {
// Return if no element
if (this.length == 0)
return $this;
// If multiple elements, recursively call for each
if (this.length > 1) {
for (var i=0; i < this.length; i++)
$(this[i]).panel(userConfig);
return $this;
}
// Set variables
var $this = $(this),
$body = $('body'),
$window = $(window),
id = $this.attr('id'),
config;
// Merge default and user settings
config = $.extend({
delay: 0, // Delay time
hideOnClick: false, // Whether to hide the panel on link click
hideOnEscape: false, // Whether to hide the panel on ESC key press
hideOnSwipe: false, // Whether to hide the panel on swipe
resetScroll: false, // Whether to reset scroll on hide
resetForms: false, // Whether to reset forms on hide
side: null, // Which side the panel appears on (top, bottom, left, right)
target: $this, // The target to which the class is applied when the panel is visible
visibleClass: 'visible' // The class name to apply when the panel is visible
}, userConfig);
// Internal function to hide the panel
$this._hide = function(event) {
if (!config.target.hasClass(config.visibleClass))
return;
if (event) {
event.preventDefault();
event.stopPropagation();
}
config.target.removeClass(config.visibleClass);
window.setTimeout(function() {
if (config.resetScroll)
$this.scrollTop(0);
if (config.resetForms)
$this.find('form').each(function() {
this.reset();
});
}, config.delay);
};
// CSS settings for browser compatibility
$this
.css('-ms-overflow-style', '-ms-autohiding-scrollbar')
.css('-webkit-overflow-scrolling', 'touch');
// Event handler to hide the panel on link click
if (config.hideOnClick) {
$this.find('a')
.css('-webkit-tap-highlight-color', 'rgba(0,0,0,0)');
$this
.on('click', 'a', function(event) {
var $a = $(this),
href = $a.attr('href'),
target = $a.attr('target');
// --- ⬇️ Modified Logic Start ⬇️ ---
// 1. First check if it is a popup link.
// If the link has the 'open-login-popup' class, close the menu, and this handler's action ends here.
// (The action to open the popup is handled by another event handler in common.js.)
if ($a.hasClass('open-login-popup')) {
$this._hide();
return;
}
// 2. The existing logic for regular links (page navigation) remains unchanged.
if (!href || href == '#' || href == '' || href == '#' + id)
return;
// --- ⬆️ Modified Logic End ⬆️ ---
// Cancel original event.
event.preventDefault();
event.stopPropagation();
// Hide panel.
$this._hide();
// Redirect to href.
window.setTimeout(function() {
if (target == '_blank')
window.open(href);
else
window.location.href = href;
}, config.delay + 10);
});
}
// Touch and swipe event handlers
$this.on('touchstart', function(event) {
$this.touchPosX = event.originalEvent.touches[0].pageX;
$this.touchPosY = event.originalEvent.touches[0].pageY;
});
$this.on('touchmove', function(event) {
if ($this.touchPosX === null || $this.touchPosY === null) return;
var diffX = $this.touchPosX - event.originalEvent.touches[0].pageX,
diffY = $this.touchPosY - event.originalEvent.touches[0].pageY,
th = $this.outerHeight(),
ts = ($this.get(0).scrollHeight - $this.scrollTop());
if (config.hideOnSwipe) {
var result = false, boundary = 20, delta = 50;
switch (config.side) {
case 'left': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX > delta); break;
case 'right': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX < (-1 * delta)); break;
case 'top': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY > delta); break;
case 'bottom': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY < (-1 * delta)); break;
default: break;
}
if (result) {
$this.touchPosX = null;
$this.touchPosY = null;
$this._hide();
return false;
}
}
if (($this.scrollTop() < 0 && diffY < 0) || (ts > (th - 2) && ts < (th + 2) && diffY > 0)) {
event.preventDefault();
event.stopPropagation();
}
});
// Prevent events inside the panel from propagating upwards
$this.on('click touchend touchstart touchmove', function(event) {
event.stopPropagation();
});
// Hide panel on click of a link pointing to the panel ID
$this.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.removeClass(config.visibleClass);
});
// Hide panel on body click
$body.on('click touchend', function(event) {
$this._hide(event);
});
// Event handler for the link (toggle) that opens the panel
$body.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.toggleClass(config.visibleClass);
});
// Hide panel on ESC key press
if (config.hideOnEscape) {
$window.on('keydown', function(event) {
if (event.keyCode == 27) $this._hide(event);
});
}
return $this;
};
/**
* Polyfill for the 'placeholder' attribute of input elements in older browsers.
* @return {jQuery} jQuery object
*/
$.fn.placeholder = function() {
if (typeof (document.createElement('input')).placeholder != 'undefined')
return $(this);
if (this.length == 0) return $this;
if (this.length > 1) {
for (var i=0; i < this.length; i++) $(this[i]).placeholder();
return $this;
}
var $this = $(this);
$this.find('input[type=text],textarea').each(function() {
var i = $(this);
if (i.val() == '' || i.val() == i.attr('placeholder'))
i.addClass('polyfill-placeholder').val(i.attr('placeholder'));
}).on('blur', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/)) return;
if (i.val() == '')
i.addClass('polyfill-placeholder').val(i.attr('placeholder'));
}).on('focus', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/)) return;
if (i.val() == i.attr('placeholder'))
i.removeClass('polyfill-placeholder').val('');
});
$this.find('input[type=password]').each(function() {
var i = $(this);
var x = $($('<div>').append(i.clone()).remove().html().replace(/type="password"/i, 'type="text"').replace(/type=password/i, 'type=text'));
if (i.attr('id') != '') x.attr('id', i.attr('id') + '-polyfill-field');
if (i.attr('name') != '') x.attr('name', i.attr('name') + '-polyfill-field');
x.addClass('polyfill-placeholder').val(x.attr('placeholder')).insertAfter(i);
if (i.val() == '') i.hide(); else x.hide();
i.on('blur', function(event) {
event.preventDefault();
var x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') { i.hide(); x.show(); }
});
x.on('focus', function(event) {
event.preventDefault();
var i = x.parent().find('input[name=' + x.attr('name').replace('-polyfill-field', '') + ']');
x.hide();
i.show().focus();
}).on('keypress', function(event) {
event.preventDefault();
x.val('');
});
});
$this.on('submit', function() {
$this.find('input[type=text],input[type=password],textarea').each(function(event) {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/)) i.attr('name', '');
if (i.val() == i.attr('placeholder')) {
i.removeClass('polyfill-placeholder');
i.val('');
}
});
}).on('reset', function(event) {
event.preventDefault();
$this.find('select').val($('option:first').val());
$this.find('input,textarea').each(function() {
var i = $(this), x;
i.removeClass('polyfill-placeholder');
switch (this.type) {
case 'submit': case 'reset': break;
case 'password':
i.val(i.attr('defaultValue'));
x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') { i.hide(); x.show(); } else { i.show(); x.hide(); }
break;
case 'checkbox': case 'radio': i.attr('checked', i.attr('defaultValue')); break;
case 'text': case 'textarea':
i.val(i.attr('defaultValue'));
if (i.val() == '') { i.addClass('polyfill-placeholder'); i.val(i.attr('placeholder')); }
break;
default: i.val(i.attr('defaultValue')); break;
}
});
});
return $this;
};
/**
* Moves elements to the front of their parent or back to their original position based on a condition.
* (Mainly used for changing element positions in responsive layouts.)
* @param {jQuery} $elements Elements to move
* @param {bool} condition If true, move to the front; if false, move to the original position
*/
$.prioritize = function($elements, condition) {
var key = '__prioritize';
if (typeof $elements != 'jQuery') $elements = $($elements);
$elements.each(function() {
var $e = $(this), $p, $parent = $e.parent();
if ($parent.length == 0) return;
if (!$e.data(key)) {
if (!condition) return;
$p = $e.prev();
if ($p.length == 0) return;
$e.prependTo($parent);
$e.data(key, $p);
} else {
if (condition) return;
$p = $e.data(key);
$e.insertAfter($p);
$e.removeData(key);
}
});
};
})(jQuery);
/*
Arcana by HTML5 UP
html5up.net | @ajlkn
Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
*/
(function($) {
var $window = $(window),
$body = $('body');
// Breakpoints.
breakpoints({
wide: [ '1281px', '1680px' ],
normal: [ '981px', '1280px' ],
narrow: [ '841px', '980px' ],
narrower: [ '737px', '840px' ],
mobile: [ '481px', '736px' ],
mobilep: [ null, '480px' ]
});
// Play initial animations on page load.
$window.on('load', function() {
window.setTimeout(function() {
$body.removeClass('is-preload');
}, 100);
});
// Dropdowns.
$('#nav > ul').dropotron({
offsetY: -15,
hoverDelay: 0,
alignment: 'center'
});
// Nav.
// Bar.
$(
'<div id="titleBar">' +
'<a href="#navPanel" class="toggle"></a>' +
'<span class="title" onclick="javascript:gotoHome()">' + $('#logo').html() + '</span>' +
'</div>'
)
.appendTo($body);
// Panel.
$(
'<div id="navPanel">' +
'<nav>' +
$('#nav').navList() +
'</nav>' +
'</div>'
)
.appendTo($body)
.panel({
delay: 500,
hideOnClick: true,
hideOnSwipe: true,
resetScroll: true,
resetForms: true,
side: 'left',
target: $body,
visibleClass: 'navPanel-visible'
});
})(jQuery);

View File

@ -1 +0,0 @@

File diff suppressed because one or more lines are too long

View File

@ -1,342 +0,0 @@
/*
* Arcana by HTML5 UP (html5up.net | @ajlkn)
* Free for personal and commercial use under the CCA 3.0 license (html5up.net/license)
*
* 파일은 테마의 유틸리티 스크립트를 포함합니다. (패널, پلی필 )
*/
(function($) {
/**
* 네비게이션 메뉴(ul) 모바일 패널에서 사용할 있는 평면 링크 목록으로 변환합니다.
* @return {jQuery} jQuery 객체
*/
$.fn.navList = function() {
var $this = $(this),
$a = $this.find('a'),
b = [];
$a.each(function() {
var $this = $(this),
indent = Math.max(0, $this.parents('li').length - 1),
href = $this.attr('href'),
target = $this.attr('target'),
// [수정된 부분] 팝업을 열기 위해 원본 링크의 class와 'to' 속성을 가져옵니다.
originalClass = $this.attr('class'),
toAttr = $this.attr('to');
// [수정된 부분] 모바일 패널용 링크에 기본 클래스와 원본 클래스를 모두 포함시킵니다.
var classes = 'link depth-' + indent;
if (typeof originalClass !== 'undefined' && originalClass != '') {
classes += ' ' + originalClass;
}
b.push(
'<a ' +
'class="' + classes + '"' + // 수정된 클래스 적용
( (typeof target !== 'undefined' && target != '') ? ' target="' + target + '"' : '') +
( (typeof href !== 'undefined' && href != '') ? ' href="' + href + '"' : '') +
// [수정된 부분] 팝업 대상을 지정하는 'to' 속성을 추가합니다.
( (typeof toAttr !== 'undefined' && toAttr != '') ? ' to="' + toAttr + '"' : '') +
'>' +
'<span class="indent-' + indent + '"></span>' +
$this.text() +
'</a>'
);
});
return b.join('');
};
/**
* 특정 요소를 슬라이드 아웃 패널로 변환합니다.
* @param {object} userConfig 사용자 설정 객체
* @return {jQuery} jQuery 객체
*/
$.fn.panel = function(userConfig) {
// 요소가 없으면 반환
if (this.length == 0)
return $this;
// 여러 요소에 적용될 경우, 각각에 대해 재귀적으로 호출
if (this.length > 1) {
for (var i=0; i < this.length; i++)
$(this[i]).panel(userConfig);
return $this;
}
// 변수 설정
var $this = $(this),
$body = $('body'),
$window = $(window),
id = $this.attr('id'),
config;
// 기본 설정과 사용자 설정을 병합
config = $.extend({
delay: 0, // 지연 시간
hideOnClick: false, // 링크 클릭 시 패널 숨김 여부
hideOnEscape: false, // ESC 키 누를 시 패널 숨김 여부
hideOnSwipe: false, // 스와이프 시 패널 숨김 여부
resetScroll: false, // 숨길 때 스크롤 리셋 여부
resetForms: false, // 숨길 때 폼 리셋 여부
side: null, // 패널이 나타날 위치 (top, bottom, left, right)
target: $this, // 패널이 보일 때 클래스가 적용될 대상
visibleClass: 'visible' // 패널이 보일 때 적용될 클래스 이름
}, userConfig);
// 패널 숨기기 내부 함수
$this._hide = function(event) {
if (!config.target.hasClass(config.visibleClass))
return;
if (event) {
event.preventDefault();
event.stopPropagation();
}
config.target.removeClass(config.visibleClass);
window.setTimeout(function() {
if (config.resetScroll)
$this.scrollTop(0);
if (config.resetForms)
$this.find('form').each(function() {
this.reset();
});
}, config.delay);
};
// 브라우저 호환성을 위한 CSS 설정
$this
.css('-ms-overflow-style', '-ms-autohiding-scrollbar')
.css('-webkit-overflow-scrolling', 'touch');
// 링크 클릭 시 패널 숨기기 이벤트 핸들러
if (config.hideOnClick) {
$this.find('a')
.css('-webkit-tap-highlight-color', 'rgba(0,0,0,0)');
$this
.on('click', 'a', function(event) {
var $a = $(this),
href = $a.attr('href'),
target = $a.attr('target');
// --- ⬇️ 수정된 로직 시작 ⬇️ ---
// 1. 팝업 링크인지 먼저 확인합니다.
// 링크에 'open-login-popup' 클래스가 있으면 메뉴를 닫고, 이 핸들러의 동작은 여기서 종료합니다.
// (팝업을 여는 동작은 common.js에 있는 다른 이벤트 핸들러가 처리합니다.)
if ($a.hasClass('open-login-popup')) {
$this._hide();
return;
}
// 2. 기존의 일반 링크(페이지 이동) 처리 로직은 그대로 둡니다.
if (!href || href == '#' || href == '' || href == '#' + id)
return;
// --- ⬆️ 수정된 로직 끝 ⬆️ ---
// Cancel original event.
event.preventDefault();
event.stopPropagation();
// Hide panel.
$this._hide();
// Redirect to href.
window.setTimeout(function() {
if (target == '_blank')
window.open(href);
else
window.location.href = href;
}, config.delay + 10);
});
}
// 터치 및 스와이프 이벤트 핸들러
$this.on('touchstart', function(event) {
$this.touchPosX = event.originalEvent.touches[0].pageX;
$this.touchPosY = event.originalEvent.touches[0].pageY;
});
$this.on('touchmove', function(event) {
if ($this.touchPosX === null || $this.touchPosY === null) return;
var diffX = $this.touchPosX - event.originalEvent.touches[0].pageX,
diffY = $this.touchPosY - event.originalEvent.touches[0].pageY,
th = $this.outerHeight(),
ts = ($this.get(0).scrollHeight - $this.scrollTop());
if (config.hideOnSwipe) {
var result = false, boundary = 20, delta = 50;
switch (config.side) {
case 'left': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX > delta); break;
case 'right': result = (diffY < boundary && diffY > (-1 * boundary)) && (diffX < (-1 * delta)); break;
case 'top': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY > delta); break;
case 'bottom': result = (diffX < boundary && diffX > (-1 * boundary)) && (diffY < (-1 * delta)); break;
default: break;
}
if (result) {
$this.touchPosX = null;
$this.touchPosY = null;
$this._hide();
return false;
}
}
if (($this.scrollTop() < 0 && diffY < 0) || (ts > (th - 2) && ts < (th + 2) && diffY > 0)) {
event.preventDefault();
event.stopPropagation();
}
});
// 패널 내부에서 발생하는 이벤트가 상위로 전파되는 것을 방지
$this.on('click touchend touchstart touchmove', function(event) {
event.stopPropagation();
});
// 패널 ID를 가리키는 링크 클릭 시 패널 숨기기
$this.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.removeClass(config.visibleClass);
});
// body 클릭 시 패널 숨기기
$body.on('click touchend', function(event) {
$this._hide(event);
});
// 패널을 여는 링크(토글)에 대한 이벤트 핸들러
$body.on('click', 'a[href="#' + id + '"]', function(event) {
event.preventDefault();
event.stopPropagation();
config.target.toggleClass(config.visibleClass);
});
// ESC 키 누를 시 패널 숨기기
if (config.hideOnEscape) {
$window.on('keydown', function(event) {
if (event.keyCode == 27) $this._hide(event);
});
}
return $this;
};
/**
* 구형 브라우저에서 input의 'placeholder' 속성을 지원하기 위한 پلی필(Polyfill)입니다.
* @return {jQuery} jQuery 객체
*/
$.fn.placeholder = function() {
if (typeof (document.createElement('input')).placeholder != 'undefined')
return $(this);
if (this.length == 0) return $this;
if (this.length > 1) {
for (var i=0; i < this.length; i++) $(this[i]).placeholder();
return $this;
}
var $this = $(this);
$this.find('input[type=text],textarea').each(function() {
var i = $(this);
if (i.val() == '' || i.val() == i.attr('placeholder'))
i.addClass('polyfill-placeholder').val(i.attr('placeholder'));
}).on('blur', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/)) return;
if (i.val() == '')
i.addClass('polyfill-placeholder').val(i.attr('placeholder'));
}).on('focus', function() {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/)) return;
if (i.val() == i.attr('placeholder'))
i.removeClass('polyfill-placeholder').val('');
});
$this.find('input[type=password]').each(function() {
var i = $(this);
var x = $($('<div>').append(i.clone()).remove().html().replace(/type="password"/i, 'type="text"').replace(/type=password/i, 'type=text'));
if (i.attr('id') != '') x.attr('id', i.attr('id') + '-polyfill-field');
if (i.attr('name') != '') x.attr('name', i.attr('name') + '-polyfill-field');
x.addClass('polyfill-placeholder').val(x.attr('placeholder')).insertAfter(i);
if (i.val() == '') i.hide(); else x.hide();
i.on('blur', function(event) {
event.preventDefault();
var x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') { i.hide(); x.show(); }
});
x.on('focus', function(event) {
event.preventDefault();
var i = x.parent().find('input[name=' + x.attr('name').replace('-polyfill-field', '') + ']');
x.hide();
i.show().focus();
}).on('keypress', function(event) {
event.preventDefault();
x.val('');
});
});
$this.on('submit', function() {
$this.find('input[type=text],input[type=password],textarea').each(function(event) {
var i = $(this);
if (i.attr('name').match(/-polyfill-field$/)) i.attr('name', '');
if (i.val() == i.attr('placeholder')) {
i.removeClass('polyfill-placeholder');
i.val('');
}
});
}).on('reset', function(event) {
event.preventDefault();
$this.find('select').val($('option:first').val());
$this.find('input,textarea').each(function() {
var i = $(this), x;
i.removeClass('polyfill-placeholder');
switch (this.type) {
case 'submit': case 'reset': break;
case 'password':
i.val(i.attr('defaultValue'));
x = i.parent().find('input[name=' + i.attr('name') + '-polyfill-field]');
if (i.val() == '') { i.hide(); x.show(); } else { i.show(); x.hide(); }
break;
case 'checkbox': case 'radio': i.attr('checked', i.attr('defaultValue')); break;
case 'text': case 'textarea':
i.val(i.attr('defaultValue'));
if (i.val() == '') { i.addClass('polyfill-placeholder'); i.val(i.attr('placeholder')); }
break;
default: i.val(i.attr('defaultValue')); break;
}
});
});
return $this;
};
/**
* 특정 조건에 따라 요소의 순서를 부모 요소의 앞으로 이동시키거나 원래 위치로 되돌립니다.
* (주로 반응형 레이아웃에서 요소의 위치를 변경할 사용됩니다.)
* @param {jQuery} $elements 이동시킬 요소
* @param {bool} condition true이면 앞으로, false이면 원래 위치로 이동
*/
$.prioritize = function($elements, condition) {
var key = '__prioritize';
if (typeof $elements != 'jQuery') $elements = $($elements);
$elements.each(function() {
var $e = $(this), $p, $parent = $e.parent();
if ($parent.length == 0) return;
if (!$e.data(key)) {
if (!condition) return;
$p = $e.prev();
if ($p.length == 0) return;
$e.prependTo($parent);
$e.data(key, $p);
} else {
if (condition) return;
$p = $e.data(key);
$e.insertAfter($p);
$e.removeData(key);
}
});
};
})(jQuery);

File diff suppressed because it is too large Load Diff

View File

@ -23,13 +23,12 @@
</a>
<div class="inner">
<h3 style="display: flex; justify-content: space-between; align-items: center;">
<span>
<span th:if="${!post.posting}" style="background-color: #888; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;">
비공개
</span>
<span th:text="${post.title != null and not #strings.isEmpty(post.title)} ? ${post.title} : 'untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')} + ']'"></span>
</span>
<span>
<span th:if="${!post.posting}" style="background-color: #888; color: white; font-size: 0.7em; padding: 2px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;">
비공개
</span>
<span th:text="${post.title != null and not #strings.isEmpty(post.title)} ? ${post.title} : 'untitled[' + ${#temporals.format(T(java.time.Instant).ofEpochMilli(post.writeTime).atZone(T(java.time.ZoneId).systemDefault()).toLocalDateTime(), 'yyyy-MM-dd HH:mm')} + ']'"></span>
</span>
<span style="font-size: 0.75em; color: #888; font-weight: normal; white-space: nowrap; margin-left: 1em;">
(읽음: <span th:text="${post.readCount}">0</span>)
</span>
@ -40,7 +39,9 @@
</div>
<footer sec:authorize="isAuthenticated()" style="text-align: right; margin-top: 1em;">
<footer sec:authorize="isAuthenticated()"
th:if="${#authentication.name == post.writer or #authorization.expression('hasRole(''ADMIN'')')}"
style="text-align: right; margin-top: 1em;">
<a th:href="@{/blog/edit/{postId}(postId=${post.id})}" class="button small alt">수정</a>
</footer>
</div>

View File

@ -5,7 +5,6 @@
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head">
<link th:href="@{/css/private.css}" rel="stylesheet" />
</th:block>
<th:block layout:fragment="content" id="content">
<div id="main_layer">

View File

@ -0,0 +1,232 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
layout:decorate="~{layout/default_layout}">
<th:block layout:fragment="head">
<style>
.tabs { display: flex; border-bottom: 2px solid #ddd; margin-bottom: 1.5em; flex-wrap: wrap; }
.tab-link { padding: 10px 20px; cursor: pointer; border: 1px solid transparent; border-bottom: 0; color: #777; }
.tab-link.active { border-color: #ddd; border-bottom-color: white; background: white; margin-bottom: -2px; font-weight: bold; color: #333; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.user-list, .post-list { list-style: none; padding-left: 0; }
.user-list li, .post-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; }
.user-list li:last-child, .post-list li:last-child { border-bottom: none; }
.button.small { margin-left: 0.5em; }
</style>
</th:block>
<th:block layout:fragment="content">
<section class="wrapper style1">
<div class="container">
<header class="major">
<h2 th:text="${isAdmin} ? '관리자 대시보드' : (${user.user_id} + '님의 정보')"></h2>
<p>가입일: <span th:text="${joinDate}"></span></p>
</header>
<div class="tabs">
<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, 'myComments')">내가 쓴 댓글</div>
<th:block sec:authorize="hasRole('ADMIN')">
<div class="tab-link" onclick="openTab(event, 'userManagement')">회원 관리</div>
<div class="tab-link" onclick="openTab(event, 'contentManagement')">콘텐츠 관리</div>
</th:block>
</div>
<div id="myInfo" class="tab-content active">
<div class="box">
<h3>기본 정보</h3>
<ul>
<li><strong>아이디:</strong> <span th:text="${user.user_id}"></span></li>
<li><strong>이메일:</strong> <span th:text="${user.user_email}"></span></li>
<li>
<strong>현재 권한:</strong> <span th:id="'user-role-status-' + ${user.user_id}" th:text="${user.getRole().name()}"></span>
<th:block th:if="${user.getRole().name() == 'READ' and !user.writePermissionRequested}">
<a href="javascript:requestWritePermission()" id="request-perm-btn" class="button small alt" style="margin-left: 1em;">글쓰기 권한 요청</a>
</th:block>
<span th:if="${user.writePermissionRequested}" id="request-perm-status" style="margin-left: 1em; color: #007bff;">(권한 요청 처리 중)</span>
</li>
</ul>
</div>
</div>
<div id="myPosts" class="tab-content">
<div class="box">
<ul class="post-list">
<li th:each="post : ${myPosts}">
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">게시물 제목</a>
<span th:text="${#dates.format(post.modifyTime, 'yyyy-MM-dd HH:mm')}"></span>
</li>
<li th:if="${#lists.isEmpty(myPosts)}">작성한 글이 없습니다.</li>
</ul>
</div>
</div>
<div id="myComments" class="tab-content">
<div class="box">
<ul class="post-list">
<li th:each="comment : ${myComments}">
<span th:text="${comment.content}">댓글 내용</span>
<a th:href="@{'/blog/viewer/' + ${comment.postId} + '#comment-' + ${comment.id}}">원문보기</a>
</li>
<li th:if="${#lists.isEmpty(myComments)}">작성한 댓글이 없습니다.</li>
</ul>
</div>
</div>
<div id="userManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box">
<h4>권한 요청</h4>
<ul id="permission-requests-list" class="user-list">
<li th:each="reqUser : ${permissionRequests}" th:id="'request-row-' + ${reqUser.user_id}">
<span>
<strong th:text="${reqUser.user_id}"></strong> (<span th:text="${reqUser.user_email}"></span>)
</span>
<div>
<button class="button small primary" th:onclick="handlePermission('[[${reqUser.user_id}]]', 'approve')">승인</button>
<button class="button small alt" th:onclick="handlePermission('[[${reqUser.user_id}]]', 'reject')">거절</button>
</div>
</li>
<li th:if="${#lists.isEmpty(permissionRequests)}">새로운 권한 요청이 없습니다.</li>
</ul>
</div>
<div class="box" style="margin-top: 2em;">
<h4>전체 회원</h4>
<ul id="all-users-list" class="user-list">
<li th:each="u : ${allUsers}">
<span>
<strong th:text="${u.user_id}"></strong> - 현재 권한: <span th:id="'user-role-' + ${u.user_id}" th:text="${u.getRole().name()}"></span>
</span>
</li>
</ul>
</div>
</div>
<div id="contentManagement" class="tab-content" sec:authorize="hasRole('ADMIN')">
<div class="box">
<h4>최신 글 관리</h4>
<ul class="post-list">
<li th:each="post : ${allRecentPosts}" th:id="'post-row-' + ${post.id}">
<a th:href="@{'/blog/viewer/' + ${post.id}}" th:text="${post.title}">게시물 제목</a>
<div>
<span th:if="${post.isBlocked}" style="color: red; margin-right: 1em;">(차단됨)</span>
<button th:if="${!post.isBlocked}" class="button small alt" th:onclick="handleContent('[[${post.id}]]', 'block')">차단</button>
<button th:if="${post.isBlocked}" class="button small" th:onclick="handleContent('[[${post.id}]]', 'unblock')">차단 해제</button>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
<script th:inline="javascript">
// CSRF 토큰을 meta 태그에서 읽어옴 (POST 요청 시 필요)
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
/**
* 탭 클릭 시 해당 탭 콘텐츠를 보여주는 함수
*/
function openTab(evt, tabName) {
// 모든 탭 콘텐츠 숨기기
document.querySelectorAll('.tab-content').forEach(tab => tab.style.display = 'none');
// 모든 탭 링크에서 'active' 클래스 제거
document.querySelectorAll('.tab-link').forEach(link => link.classList.remove('active'));
// 클릭된 탭 콘텐츠 보이기
document.getElementById(tabName).style.display = 'block';
// 클릭된 탭 링크에 'active' 클래스 추가
evt.currentTarget.classList.add('active');
}
/**
* '글쓰기 권한 요청'을 서버에 전송하는 함수
*/
function requestWritePermission() {
if (!confirm('글쓰기 권한을 요청하시겠습니까? 관리자 승인 후 적용됩니다.')) return;
fetch('/user/request-write', {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
})
.then(response => {
if (response.ok) {
alert('요청이 성공적으로 접수되었습니다.');
// 버튼을 '처리 중' 텍스트로 변경
document.getElementById('request-perm-btn').style.display = 'none';
const statusSpan = document.getElementById('request-perm-status');
if(statusSpan) {
statusSpan.innerText = '(권한 요청 처리 중)';
} else {
// span이 없는 경우 새로 만들어 추가
const newStatusSpan = document.createElement('span');
newStatusSpan.id = 'request-perm-status';
newStatusSpan.innerText = '(권한 요청 처리 중)';
newStatusSpan.style = 'margin-left: 1em; color: #007bff;';
document.getElementById('request-perm-btn').parentElement.appendChild(newStatusSpan);
}
} else {
alert('요청에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
})
.catch(error => console.error('Error:', error));
}
/**
* (관리자) 사용자 권한 요청을 처리하는 함수
* @param {string} userId - 대상 사용자 ID
* @param {'approve' | 'reject'} action - 수행할 작업
*/
function handlePermission(userId, action) {
const url = `/user/${action}-writer/${userId}`;
fetch(url, {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
})
.then(response => response.json())
.then(data => {
if (data && data.user_id) {
alert(`'${userId}'님의 권한 요청을 ${action === 'approve' ? '승인' : '거절'}했습니다.`);
// UI에서 해당 항목 제거
document.getElementById(`request-row-${userId}`).remove();
// 전체 사용자 목록의 권한 정보 업데이트
const userRoleSpan = document.getElementById(`user-role-${userId}`);
if (userRoleSpan) {
userRoleSpan.innerText = data.role.name;
}
} else {
alert('작업에 실패했습니다.');
}
})
.catch(error => console.error('Error:', error));
}
/**
* (관리자) 콘텐츠(게시물)를 차단하거나 해제하는 함수
* @param {string} postId - 대상 게시물 ID
* @param {'block' | 'unblock'} action - 수행할 작업
*/
function handleContent(postId, action) {
const url = `/blog/post/${postId}/${action}`;
fetch(url, {
method: 'POST',
headers: { [csrfHeader]: csrfToken }
})
.then(response => response.json())
.then(data => {
if (data && data.id) {
alert(`게시물을 ${action === 'block' ? '차단' : '차단 해제'}했습니다.`);
location.reload(); // 페이지를 새로고침하여 상태 업데이트
} else {
alert('작업에 실패했습니다.');
}
})
.catch(error => console.error('Error:', error));
}
</script>
</th:block>
</html>

View File

@ -17,7 +17,14 @@
<th:block layout:fragment="content" id="content">
<section class="wrapper style2">
<div class="container" sec:authorize="isAuthenticated()" onclick="loadEditor()" style="cursor: pointer;" title="클릭하여 수정하기">
<div class="container"
th:with="isAdmin=${#authorization.expression('hasRole(''ADMIN'')')}, isWriter=${#authentication.name == srcPost.writer}"
th:attr="
onclick=${(isAdmin or isWriter) ? 'loadEditor()' : ''},
style=${(isAdmin or isWriter) ? 'cursor: pointer;' : ''},
title=${(isAdmin or isWriter) ? '클릭하여 수정하기' : ''}
"
sec:authorize="isAuthenticated()">
<header class="major">
<h2 id="title_layer">
<span th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</span>

View File

@ -15,29 +15,17 @@
<section class="col-3 col-6-narrower col-12-mobilep">
<h3>Rank of Views</h3>
<ul class="rank_of_view" >
<li><a href="#">Mattis et quis rutrum</a></li>
<li><a href="#">Suspendisse amet varius</a></li>
<li><a href="#">Sed et dapibus quis</a></li>
<li><a href="#">Rutrum accumsan dolor</a></li>
<li><a href="#">Mattis rutrum accumsan</a></li>
<li><a href="#">Suspendisse varius nibh</a></li>
<li><a href="#">Sed et dapibus mattis</a></li>
</ul>
</section>
<section class="col-3 col-6-narrower col-12-mobilep">
<h3>Recent of Posts</h3>
<ul class="recent_posts">
<li><a href="#">Duis neque nisi dapibus</a></li>
<li><a href="#">Sed et dapibus quis</a></li>
<li><a href="#">Rutrum accumsan sed</a></li>
<li><a href="#">Mattis et sed accumsan</a></li>
<li><a href="#">Duis neque nisi sed</a></li>
<li><a href="#">Sed et dapibus quis</a></li>
<li><a href="#">Rutrum amet varius</a></li>
</ul>
</section>
<section class="col-6 col-12-narrower">
<h3>Get In Touch</h3>
<h3>SEND TO ME(TELEGRAM BOT)</h3>
<div id="tlg_form" >
<div class="row gtr-50">
<div class="col-6 col-12-mobilep">

View File

@ -43,19 +43,23 @@
<!-- <li><a href="#">Veroeros feugiat</a></li>-->
</ul>
</li>
<li><a th:href="@{/licenses}">Licenses</a></li>
</ul>
</li>
<th:block sec:authorize="isAuthenticated()">
<li><a th:href="@{/user/info}">내 정보</a></li>
</th:block>
<th:block sec:authorize="!isAuthenticated()">
<li id="menu_login">
<a class="open-login-popup" to="#loginPopup">LOGIN</a>
<a class="open-login-popup" to="#loginPopup">Login</a>
</li>
</th:block>
<th:block sec:authorize="isAuthenticated()">
<li>
<a href="javascript:logout()" style="margin-left:10px;">로그아웃</a>
<a href="javascript:logout()" style="margin-left:10px;">Logout</a>
<li>
</th:block>
<li><a th:href="@{/licenses}">Licenses</a></li>
</ul>
</nav>

View File

@ -12,34 +12,33 @@
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<script async th:src="@{https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716}" crossorigin="anonymous"></script>
<link th:href="@{/css/common.css}" rel="stylesheet" />
<link th:href="@{/css/main.css}" rel="stylesheet" />
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_parameter" th:content="${_csrf.parameterName}"/>
<script th:inline="javascript">
/*
* [수정됨] 이 객체는 Post.kt 모델의 모든 필드를 포함해야 합니다.
* 여기서 누락된 필드는 편집 후 저장 시 서버에서 null 또는 0으로 초기화됩니다.
* (이 블록은 <head>에 남아있어도 괜찮습니다. 전역 변수를 정의하는 역할입니다.)
* [Modified] This object must include all fields from the Post.kt model.
* Any fields missing here will be initialized as null or 0 on the server upon saving after an edit.
* (This block can remain in the <head>; it defines a global variable.)
*/
var serverData = {
// --- Key IDs ---
id: [[${srcPost?.id}]], // (Thymeleaf 3.x의 안전 탐색 연산자 '?' 사용)
id: [[${srcPost?.id}]], // (Using Thymeleaf 3.x's safe navigation operator '?')
originId: [[${srcPost?.originId}]],
// --- Core Content (컨트롤러에서 이미 디코딩됨) ---
// --- Core Content (already decoded in the controller) ---
title: /*[[${srcPost?.title ?: ''}]]*/,
content: /*[[${srcPost?.content ?: ''}]]*/,
// === (수정) 치명적으로 누락되었던 필드들 ===
// === (Modified) Critically missing fields ===
category: /*[[${srcPost?.category ?: 'none'}]]*/,
tags: /*[[${srcPost?.tags ?: ''}]]*/,
// --- Timestamps (데이터 보존을 위해 필수) ---
// --- Timestamps (required for data preservation) ---
writeTime: [[${srcPost?.writeTime ?: 0}]],
modifyTime: [[${srcPost?.modifyTime ?: 0}]],
// --- Location Data (데이터 보존을 위해 필수) ---
// --- Location Data (required for data preservation) ---
firstPostLat: [[${srcPost?.firstPostLat ?: 0.0}]],
firstPostLon: [[${srcPost?.firstPostLon ?: 0.0}]],
firstAddress: /*[[${srcPost?.firstAddress ?: ''}]]*/,
@ -47,13 +46,13 @@
modifyLon: [[${srcPost?.modifyLon ?: 0.0}]],
modifyAddress: /*[[${srcPost?.modifyAddress ?: ''}]]*/,
// --- Metadata (데이터 보존을 위해 필수) ---
// --- Metadata (required for data preservation) ---
writer: /*[[${srcPost?.writer ?: ''}]]*/,
posting: [[${srcPost?.posting ?: false}]],
readCount: [[${srcPost?.readCount ?: 0}]],
voteCount: [[${srcPost?.voteCount ?: 0}]],
unlikeCount: [[${srcPost?.unlikeCount ?: 0}]],
// --- Page-specific (모델 데이터 아님) ---
// --- Page-specific (not model data) ---
enc: /*[[${enc ?: ''}]]*/,
keyword: /*[[${keyword ?: ''}]]*/
};

View File

@ -1,6 +1,6 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="title">
<title th:text="${title}">Bum's</title>
</th:block>
</html>
<!--<!DOCTYPE html>-->
<!--<html xmlns:th="http://www.thymeleaf.org">-->
<!--<th:block th:fragment="title">-->
<!-- <title th:text="${title}">Bum's</title>-->
<!--</th:block>-->
<!--</html>-->

View File

@ -6,8 +6,9 @@
xmlns="http://www.w3.org/1999/html">
<head>
<base th:href="@{/}" />
<title th:text="${pageTitle ?: 'Bum''s'}">Bum's</title>
<th:block th:replace="~{fragments/includes :: includes}"></th:block>
<th:block th:replace="~{fragments/title :: title}"></th:block>
<th:block layout:fragment="head"></th:block>
</head>
<body class="is-preload">
<div id="page-wrapper">
@ -17,7 +18,6 @@
<div class="dimBg"></div>
<th:block layout:fragment="popup_layer"></th:block>
<!-- 로그인 팝업 -->
<div id="loginPopup" class="pop_layer">
<div class="pop_container">
<div class="pop_conts">
@ -28,7 +28,10 @@
<input type="checkbox" id="rememberMe" class="custom-checkbox"/>
<label for="rememberMe" class="custom-label"></label>
<span>자동로그인</span>
<button type="submit" class="button">로그인</button>
<div>
<button type="submit" class="button">로그인</button>
<button type="button" class="button alt" id="openSignupBtnFromLogin" >회원가입</button>
</div>
</form>
<div class="btn_r">
<a href="#" class="btn_layerClose" onclick="closePopup()">닫기</a>
@ -37,7 +40,6 @@
</div>
</div>
<!-- 회원가입 팝업 -->
<div id="signupPopup" class="pop_layer">
<div class="pop_container">
<div class="pop_conts">
@ -62,7 +64,5 @@
<script th:src="@{/js/jquery.dropotron.min.js}"></script>
<script th:src="@{/js/browser.min.js}"></script>
<script th:src="@{/js/breakpoints.min.js}"></script>
<script th:src="@{/js/util.js}"></script>
<script th:src="@{/js/main.js}"></script>
</body>
<script th:src="@{/js/template.js}"></script> </body>
</html>