...
This commit is contained in:
parent
cc43ea8e0a
commit
1ab12cb6d9
@ -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"))
|
||||
//}
|
||||
@ -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("아직 못들어와"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
// }
|
||||
//
|
||||
//}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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; /* 혹시 모를 인라인 스타일 제거 */
|
||||
}
|
||||
}
|
||||
@ -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; */
|
||||
}
|
||||
@ -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; */
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
411
src/main/resources/static/js/template.js
Normal file
411
src/main/resources/static/js/template.js
Normal 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);
|
||||
@ -1 +0,0 @@
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -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
@ -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>
|
||||
@ -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">
|
||||
|
||||
232
src/main/resources/templates/content/user/my_info.html
Normal file
232
src/main/resources/templates/content/user/my_info.html
Normal 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>
|
||||
@ -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>
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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 ?: ''}]]*/
|
||||
};
|
||||
|
||||
@ -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>-->
|
||||
@ -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>
|
||||
Loading…
x
Reference in New Issue
Block a user