no message

This commit is contained in:
lunaticbum 2025-09-26 14:56:56 +09:00
parent 74e88d7d89
commit d2a1f37f39
15 changed files with 399 additions and 385 deletions

View File

@ -94,7 +94,7 @@ class BlogController(
data class GibberishRequest(val content: String)
/**
* [신규 추가] 게시물을 영구적으로 삭제하는 API입니다.
* 게시물을 영구적으로 삭제하는 API입니다.
* 작성자 또는 관리자만 작업을 수행할 있습니다.
*/
@DeleteMapping("/blog/post/{postId}")
@ -443,32 +443,52 @@ class BlogController(
@GetMapping("/blog/posts")
suspend fun postsList(
@RequestParam(value = "page", defaultValue = "0") page: Int,
@AuthenticationPrincipal userDetails: UserDetails? // 현재 로그인한 사용자 정보 주입
@RequestParam(required = false) category: String?, // [추가] 카테고리 필터
@RequestParam(required = false) tag: String?, // [추가] 태그 필터
@AuthenticationPrincipal userDetails: UserDetails?
): ResultMV {
val vm = ResultMV("content/posts")
val pageable = PageRequest.of(page, 8)
val roles = userDetails?.authorities?.map { it.authority } ?: emptyList()
val username = userDetails?.username
// [추가] 뷰에 필터링 정보 전달
vm.modelMap["currentCategory"] = category
vm.modelMap["currentTag"] = tag
val posts: List<Post>
val total: Long
// [수정] 필터링 조건에 따라 분기 처리
when {
// 1. ADMIN 역할: 모든 글 (비공개, 모든 버전 포함)
roles.contains("ROLE_ADMIN") -> {
posts = postManager.findAllVersionsPaginated(pageable).awaitSingle()
total = postManager.countAllVersions().awaitSingle()
// 1. 카테고리 필터링이 적용된 경우
!category.isNullOrBlank() -> {
posts = postManager.findPostsByCategory(category, pageable).awaitSingle()
total = postManager.countPostsByCategory(category).awaitSingle()
vm.modelMap["filterTitle"] = "'${category}' 카테고리의 글"
}
// 2. WRITE 역할: 공개된 모든 글 + 자신의 비공개 글
roles.contains("ROLE_WRITE") && username != null -> {
posts = postManager.findLatestUniqueForWriter(username, pageable).awaitSingle()
total = postManager.countLatestUniqueForWriter(username).awaitSingle()
// 2. 태그 필터링이 적용된 경우
!tag.isNullOrBlank() -> {
posts = postManager.findPostsByTag(tag, pageable).awaitSingle()
total = postManager.countPostsByTag(tag).awaitSingle()
vm.modelMap["filterTitle"] = "'#${tag}' 태그가 포함된 글"
}
// 3. 그 외 (익명, READ 역할): 공개된 글만
// 3. 필터링이 없는 경우 (기존 로직)
else -> {
posts = postManager.findLatestUniquePaginated(pageable).awaitSingle()
total = postManager.countLatestUnique().awaitSingle()
val roles = userDetails?.authorities?.map { it.authority } ?: emptyList()
val username = userDetails?.username
when {
roles.contains("ROLE_ADMIN") -> {
posts = postManager.findAllVersionsPaginated(pageable).awaitSingle()
total = postManager.countAllVersions().awaitSingle()
}
roles.contains("ROLE_WRITE") && username != null -> {
posts = postManager.findLatestUniqueForWriter(username, pageable).awaitSingle()
total = postManager.countLatestUniqueForWriter(username).awaitSingle()
}
else -> {
posts = postManager.findLatestUniquePaginated(pageable).awaitSingle()
total = postManager.countLatestUnique().awaitSingle()
}
}
}
}
@ -636,17 +656,19 @@ class BlogController(
@GetMapping("/blog/categories.bjx")
@ResponseBody
fun getCategories(): Mono<TagResponse> {
// TODO: 향후 DB에서 고유 카테고리 목록을 조회하도록 수정해야 합니다.
val sampleCategories = listOf("일상", "기술", "여행", "요리")
return Mono.just(TagResponse(tags = sampleCategories))
// [수정] 샘플 데이터 대신 DB에서 고유 카테고리 목록을 조회
return postManager.findAllDistinctCategories()
.collectList()
.map { categories -> TagResponse(tags = categories) }
}
@GetMapping("/blog/hashtags.bjx")
@ResponseBody
fun getHashtags(): Mono<TagResponse> {
// TODO: 향후 DB에서 고유 해시태그 목록을 조회하도록 수정해야 합니다.
val sampleTags = listOf("Spring", "Kotlin", "React", "MongoDB", "여행")
return Mono.just(TagResponse(tags = sampleTags))
// [수정] 샘플 데이터 대신 DB에서 고유 해시태그 목록을 조회
return postManager.findAllDistinctTags()
.collectList()
.map { tags -> TagResponse(tags = tags) }
}
@ -670,6 +692,17 @@ class BlogController(
val incomingPost = PayloadDecoder.decode(rawPayload, Post::class.java, objectMapper)
// ======================= [수정 시작] =======================
// DB에 저장하기 전, 클라이언트에서 인코딩된 데이터를 모두 디코딩합니다.
incomingPost.title = URLDecoder.decode(incomingPost.title ?: "", "UTF-8")
incomingPost.content = URLDecoder.decode(incomingPost.content ?: "", "UTF-8")
incomingPost.category = URLDecoder.decode(incomingPost.category ?: "none", "UTF-8")
incomingPost.tags = URLDecoder.decode(incomingPost.tags ?: "", "UTF-8")
incomingPost.firstAddress = URLDecoder.decode(incomingPost.firstAddress ?: "", "UTF-8")
incomingPost.modifyAddress = URLDecoder.decode(incomingPost.modifyAddress ?: "", "UTF-8")
// ======================= [수정 끝] =========================
return if (incomingPost.id.isNullOrBlank()) {
// 새 글 작성
val isAdmin = user.authorities.any { it.authority == "ROLE_ADMIN" }
@ -682,6 +715,7 @@ class BlogController(
incomingPost.writeTime = System.currentTimeMillis()
incomingPost.modifyTime = incomingPost.writeTime
val savedPost = postManager.save(incomingPost).awaitSingle()
PostSaveResponse(0, "Success", PostIdData(savedPost.id!!))
@ -698,19 +732,29 @@ class BlogController(
if (!isAdmin && !isWriter) {
return PostSaveResponse(403, "Permission denied to update post", null)
}
incomingPost.writer = user.username
// 2. 원본 데이터를 기반으로 PostHistory 객체를 생성합니다.
val history = PostHistory(
postId = originalPost.id!!,
title = originalPost.title,
content = originalPost.content,
category = originalPost.category,
tags = originalPost.tags,
writer = originalPost.writer,
writeTime = originalPost.writeTime,
posting = originalPost.posting,
firstPostLat = originalPost.firstPostLat,
firstPostLon = originalPost.firstPostLon,
firstAddress = originalPost.firstAddress,
modifyAddress = originalPost.modifyAddress,
modifyTime = originalPost.modifyTime,
modifyLat = originalPost.modifyLat,
modifyLon = originalPost.modifyLon,
readCount = originalPost.readCount,
voteCount = originalPost.voteCount,
unlikeCount = originalPost.unlikeCount,
isBlocked = originalPost.isBlocked,
postType = originalPost.postType,
// ... originalPost의 모든 필드를 복사 ...
modifyTime = originalPost.modifyTime
)
// 3. 히스토리를 DB에 저장합니다.
@ -723,7 +767,12 @@ class BlogController(
posting = incomingPost.posting, // ★ 변경된 공개 상태 적용
category = incomingPost.category,
tags = incomingPost.tags,
modifyTime = System.currentTimeMillis() // 수정 시간 갱신
modifyTime = System.currentTimeMillis(), // 수정 시간 갱신
writeTime = incomingPost.writeTime, // 사용자가 수정한 작성 시간을 반영합니다.
modifyAddress = incomingPost.modifyAddress, // 수동으로 입력된 주소를 반영합니다.
modifyLat = incomingPost.modifyLat, // 주소가 수동 입력되면 0.0으로 초기화됩니다.
modifyLon = incomingPost.modifyLon,
writer = incomingPost.writer,
// ... 기타 수정 가능한 필드들 ...
)

View File

@ -63,11 +63,6 @@ class Telegram {
return "hello1212"
}
@Autowired
lateinit var rssDataService: RssDataService
val keyworkd = arrayListOf("I0Z","dcBEW", "TGyG", "U=Qu", "Bm=s")
val keyworkd2 = arrayListOf("x-n", "Y_D", "u", "uoo", "dfhZ", "gSKY")

View File

@ -98,7 +98,7 @@ class ImageMetaService(
// application.properties의 업로드 경로 주입
/**
* [신규 추가] Spring Boot가 준비되었을 (부팅 완료) 실행되는 리스너
* Spring Boot가 준비되었을 (부팅 완료) 실행되는 리스너
*/
@Profile("!local")
@EventListener(ApplicationReadyEvent::class)
@ -110,7 +110,7 @@ class ImageMetaService(
}
/**
* [신규 추가] 동기화 작업을 비동기(Coroutine) 실행하는 런처 (잠금 처리)
* 동기화 작업을 비동기(Coroutine) 실행하는 런처 (잠금 처리)
* BlogController에서도 함수를 호출할 있습니다.
*/
fun launchSyncTask() {
@ -136,7 +136,7 @@ class ImageMetaService(
}
/**
* [신규 추가] 실제 파일 시스템과 DB를 동기화하는 핵심 로직
* 실제 파일 시스템과 DB를 동기화하는 핵심 로직
*/
// ImageMetaService.kt의 runFileSystemSync 함수를 아래 내용으로 교체하세요.
private suspend fun runFileSystemSync() {

View File

@ -364,6 +364,25 @@ interface PostRepository : ReactiveMongoRepository<Post, String> {
])
fun findRandomPublishedPostByType(postType: String): Mono<Post>
// --- [신규 추가] 필터링을 위한 Repository 메소드 ---
fun findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category: String, pageable: Pageable): Flux<Post>
fun countByCategoryAndPostingIsTrue(category: String): Mono<Long>
fun findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(tag: String, pageable: Pageable): Flux<Post>
fun countByTagsRegexAndPostingIsTrue(tag: String): Mono<Long>
// [추가] MongoDB Aggregation을 사용해 고유 태그 목록을 효율적으로 조회
@Aggregation(pipeline = [
// 1. tags 필드가 null이면 빈 문자열로 만든 후 "," 기준으로 잘라 배열로 변환
"{ \$project: { tags: { \$split: [ { \$ifNull: [ \"\$tags\", \"\" ] }, \",\" ] } } }",
// 2. 생성된 tags 배열을 개별 문서로 분리 (예: ["a","b"] -> {tags:"a"}, {tags:"b"})
"{ \$unwind: \"\$tags\" }",
// 3. 각 태그의 앞뒤 공백 제거
"{ \$project: { tag: { \$trim: { input: \"\$tags\" } } } }",
// 4. 공백이 제거된 태그로 그룹화하여 고유한 값만 추출
"{ \$group: { _id: \"\$tag\" } }",
// 5. 그룹화 결과 중 빈 값("")은 제외
"{ \$match: { _id: { \$ne: \"\" } } }"
])
fun findDistinctTags(): Flux<org.bson.Document> // 반환 타입을 Document로 변경
}
@ -427,6 +446,38 @@ class PostManager(
}
}
// --- [신규 추가] 카테고리/태그 관련 서비스 메소드 ---
fun findAllDistinctCategories(): Flux<String> {
// 'category' 필드가 null이 아니고 비어있지 않은 문서들을 대상으로 distinct 연산 수행
val query = Query.query(Criteria.where("category").ne(null).ne(""))
return reactiveMongoTemplate.findDistinct(query, "category", "Post", String::class.java)
}
fun findAllDistinctTags(): Flux<String> {
return postRepository.findDistinctTags()
.mapNotNull { doc -> doc.getString("_id") } // Document에서 실제 태그 문자열("_id" 필드)을 추출
.filter { it.isNotBlank() } // 만약을 위해 한 번 더 빈 값 필터링
}
// --- [신규 추가] 필터링된 게시물 목록 조회 서비스 메소드 ---
fun findPostsByCategory(category: String, pageable: Pageable): Mono<List<Post>> {
return postRepository.findByCategoryAndPostingIsTrueOrderByModifyTimeDesc(category, pageable).collectList()
}
fun countPostsByCategory(category: String): Mono<Long> {
return postRepository.countByCategoryAndPostingIsTrue(category)
}
fun findPostsByTag(tag: String, pageable: Pageable): Mono<List<Post>> {
// [수정] 한글 및 다국어를 지원하는 정규식으로 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.findByTagsRegexAndPostingIsTrueOrderByModifyTimeDesc(regex, pageable).collectList()
}
fun countPostsByTag(tag: String): Mono<Long> {
// [수정] 위와 동일하게 정규식 변경
val regex = "(^|,)${Regex.escape(tag)}(,|$)"
return postRepository.countByTagsRegexAndPostingIsTrue(regex)
}
// [신규 추가] 랜덤 Gibberish 포스트를 가져오는 서비스 메소드
fun findRandomGibberish(): Mono<Post> {
return postRepository.findRandomPublishedPostByType(PostType.GIBBERISH.name)
@ -464,13 +515,6 @@ class PostManager(
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)
@ -484,14 +528,6 @@ class PostManager(
}
}
// /**
// * [신규 추가] '글쓰기' 권한 사용자가 보는 글의 총 개수
// */
// 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))
@ -505,7 +541,6 @@ class PostManager(
/**
* [이름 변경] find20 -> findAllVersionsPaginated
* 인증된 사용자를 위한 메서드 (모든 버전 조회)
* [FIX]: Change return type to Mono<List<Post>> and remove the blocking call.
*/
@ -552,7 +587,6 @@ class PostManager(
// }
/**
* [신규 추가]
* 좋아요 카운트를 1 증가시키고, JS에서 즉시 업데이트할 있도록 *업데이트된* 문서를 반환합니다.
*/
fun incrementVote(postId: String): Mono<Post> {
@ -564,7 +598,6 @@ class PostManager(
}
/**
* [신규 추가]
* 싫어요 카운트를 1 증가시키고, *업데이트된* 문서를 반환합니다.
*/
fun incrementUnlike(postId: String): Mono<Post> {
@ -600,7 +633,6 @@ class PostManager(
/**
* [로직 수정]
* 화면은 이제 "익명 사용자용 최신 글" 0 페이지, 8 아이템을 명시적으로 요청합니다.
* [FIX]: Return Mono<List<Post>> to match the updated callee.
*/
@ -611,10 +643,6 @@ class PostManager(
return this.findLatestUniquePaginated(pageRequest) // <-- 4. This now correctly returns the Mono
}
fun find20() : List<Post> {
return postRepository.findAllByModifyTime(0).takeLast(20).buffer(20).blockLast(Duration.ofSeconds(30)) ?: listOf()
}
fun save(post: Post): Mono<Post> {
println("saved user before ${post}")
// user.hashPassword(bCryptPasswordEncoder)
@ -947,260 +975,6 @@ class LocationLogService : LocationService {
}
}
interface RssDataInterface {
fun title() : String
fun thumbnailUrl() : String
fun originPage() : String
fun description() : String
fun pubDate() : Long
fun category() : RssDataType
fun getCho() : String?
}
enum class RssDataType {
NO_DATA,
YOUTUBE,
NewsFeed,
GURU,
Most,
TAGS,
REDDIT,
REDDIT_nsfw,
Dotax,
FmKorae,
DcInside,
RuliWeb,
Clien,
TheQoo,
Arca;
// fun getResId() = when (this) {
// YOUTUBE -> R.drawable.youtube
// REDDIT, REDDIT_nsfw -> R.drawable.reddit
// Dotax -> R.drawable.daum
// FmKorae -> R.drawable.fmk
// DcInside -> R.drawable.dcinside
// Arca -> R.drawable.arca
// else -> {
// 0
// }
// }
fun defaultImgSize() = when (this) {
YOUTUBE -> 200
REDDIT_nsfw,GURU,Most -> 360
else -> { 120 }
}
// fun getDefaultVisibiliy() = when (this) {
// REDDIT_nsfw,GURU,Most,NewsFeed -> View.GONE
// else -> { View.VISIBLE }
// }
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "RssData")
class RssData : RssDataInterface {
@Id
var originPage : String? = null
var title : String? = null
var description : String? = null
var thumbnail : String? = null
var pubDate : Long = 0L
var category : String? = null
var chosung : String? = null
@BsonIgnore
var mRssDataType : RssDataType? = null
override fun title(): String {
return when(category()){
RssDataType.NewsFeed -> {
if(title?.length ?: 0 > 30) title?.substring(0,30).plus("...") else title ?: ""
}
else -> title ?: ""
}.apply {
// chosung = JamoUtils.split(this).joinToString("")
}
}
override fun thumbnailUrl(): String {
return thumbnail ?: ""
}
override fun originPage(): String {
return originPage ?: ""
}
override fun description(): String {
return when(category()){
RssDataType.YOUTUBE -> {
if(description?.contains("게시자") == true) description!!.split("게시자")[0] else description ?: ""
}
RssDataType.NewsFeed -> {
category().name
}
else -> description.plus(" / ").plus(category().name)
}
}
override fun pubDate(): Long {
return pubDate
}
override fun category(): RssDataType {
if (mRssDataType == null)
mRssDataType = RssDataType.valueOf(category!!)
return mRssDataType!!
}
override fun getCho(): String? {
return chosung
}
}
val USAGT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15"
fun String.getJ() = Jsoup.connect(this).userAgent(USAGT).get()
object FeedParseManager {
val parsers = listOf<SoInterface>(QVZTb2dpcmw,SkFWTW9zdA)
fun parse(doc : org.jsoup.nodes.Document, service: RssDataService) {
try {
parsers.filter { doc.title().contains(it.getName()) }.first()?.let {
it.parse(doc,service)
}
} catch (e : Exception) {
e.printStackTrace()
}
}
}
interface SoInterface{
fun getName() : String
fun parse(doc : org.jsoup.nodes.Document,service: RssDataService)
}
object QVZTb2dpcmw : SoInterface {
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc : org.jsoup.nodes.Document, service : RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByTag("article").forEach { article ->
val title = article.getElementsByTag("a").get(0).attr("title")
val href = article.getElementsByTag("a").get(0).attr("href")
val img = article.getElementsByTag("img").get(0).attr("data-src")
service.save(RssData().apply {
this.originPage = href
this.title = title
this.description = "Sogirl"
this.thumbnail = img
this.pubDate = Date().time
this.category = RssDataType.GURU.name
}) {
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${img}\n${href}")
// }
}
}
// lists.map {
// service.sendMsg("${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}")
// }
}
}
object SkFWTW9zdA : SoInterface {
var dmy = SimpleDateFormat("dd-MM-yyyy")
override fun getName(): String {
return String(Base64.getMimeDecoder().decode(this.javaClass.simpleName.plus("==").toByteArray()))
}
override fun parse(doc: org.jsoup.nodes.Document, service: RssDataService) {
var lists = arrayListOf<RssData>()
doc.getElementsByClass("card").forEach { card ->
var thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("src") else ""
if (thumb.contains("No+Poster")) thumb = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("data-src") else thumb
var model = if(card.getElementsByTag("img").size > 0) card.getElementsByTag("img").get(0).attr("alt") else ""
if(card.getElementsByClass("card-block").size > 0) if(card.getElementsByClass("card-block").size > 0) {
val link = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("href")
val title = card.getElementsByClass("card-block").get(0).getElementsByTag("a").get(0).attr("title")
val date = card.getElementsByTag("span").get(0).text()
service.save(RssData().apply {
lists.add(this)
description = model
thumbnail = thumb
originPage = link
this.title = title
category = RssDataType.Most.name
try {
pubDate = dmy.parse(date).time
}catch (e : Exception) {e.printStackTrace()}
}){
// CoroutineScope(Dispatchers.IO).launch {
// service.sendMsg("${title}\n${thumb}\n${link}")
// }
}
}
}
// service.sendMsg(lists.map {
// "${it.title}\n${it.description}\n${it.thumbnail}\n${it.originPage}\n"
// }.joinToString(" \n "))
}
}
@Repository
interface RssDataRepository : ReactiveMongoRepository<RssData, String> {
fun findFirstByOriginPageEquals(originPage : String): Mono<RssData>
fun findAllByOrderByPubDate() : Mono<List<RssData>>
fun save(log: RssData): Mono<RssData>
}
@Service
class RssDataService {
@Autowired
private lateinit var logService: LogService
@Autowired
private lateinit var rssDataRepository: RssDataRepository
fun hasItem(originPage : String) {
}
fun getLocationLog() : List<RssData>? {
return rssDataRepository.findAllByOrderByPubDate().block()
}
fun save(log: RssData, callback : (Boolean)->Unit) {
println("saved msg before ${Gson().toJson(log)}")
log.originPage?.let {
if(rssDataRepository.findFirstByOriginPageEquals(it).block() == null) {
rssDataRepository.save(log)
.subscribe({ println("saved msg after ${it}") }, { e -> e.printStackTrace() }, {
println("saved msg comp")
callback(true)
})
} else {
println("있어???")
}
}
}
@Autowired
lateinit var globalEvv : GlobalEnvironment
suspend fun sendMsg(data : String) {
val client = WebClient.create()
client.get()
.uri("https://api.telegram.org/${globalEvv.telegramBotKey}/sendMessage?chat_id=${globalEvv.telegramMyId}&text=${data}")
.retrieve()
.bodyToMono(String::class.java).block() ?: "FAIL"
}
}
enum class Visibility {
PUBLIC, // 전체 공개
MEMBERS, // 회원 공개
@ -1367,7 +1141,6 @@ class WebBookmarkService(private val repository: WebBookmarkRepository,
}
/**
* [신규 추가]
* 북마크의 좋아요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체
@ -1380,7 +1153,6 @@ class WebBookmarkService(private val repository: WebBookmarkRepository,
}
/**
* [신규 추가]
* 북마크의 싫어요 카운트를 1 증가시킵니다.
* @param bookmarkId 대상 북마크의 ID
* @return 업데이트된 WebBookmark 객체

View File

@ -638,7 +638,7 @@ class GameRankService(
}
/**
* [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다.
* 특정 플레이어의 모든 게임 랭킹을 조회합니다.
*/
fun getRanksByPlayer(playerName: String): Flux<GameRank> {
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)

View File

@ -278,6 +278,20 @@ function initEditor(useEditor = false) {
getLocation(); // 위치 정보 가져오기 시작
const manualDateInput = document.getElementById('manual_date');
const manualLatInput = document.getElementById('manual_lat');
const manualLonInput = document.getElementById('manual_lon');
if (manualDateInput) {
const timeToDisplay = baseData.writeTime > 0 ? baseData.writeTime : new Date().getTime();
manualDateInput.value = formatTimestampForInput(timeToDisplay);
}
if (manualLatInput && manualLonInput) {
// 수정 좌표가 있으면 그것을, 없으면 최초 좌표를, 둘 다 없으면 빈 칸으로 설정
manualLatInput.value = baseData.modifyLat || baseData.firstPostLat || '';
manualLonInput.value = baseData.modifyLon || baseData.firstPostLon || '';
}
try {
// Quill 에디터 옵션 및 모듈 설정 (편집/읽기 모드 전환)
var Font = Quill.import('formats/font');
@ -489,16 +503,28 @@ function setupControlBox(mode) {
categoryBox.classList.remove('btn-example');
hashtagBox.classList.remove('btn-example');
// 카테고리 데이터를 HTML로 렌더링
const categoryContent = `<span class="tag-item">${baseData.category || '지정되지 않음'}</span>`;
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
// [수정] 카테고리 데이터를 링크(`<a>`)를 포함한 HTML로 렌더링
if (baseData.category && baseData.category !== 'none') {
const categoryLink = `${getMainPath()}/blog/posts?category=${encodeURIComponent(baseData.category)}`;
const categoryContent = `<a href="${categoryLink}" style="border-bottom: none;"><span class="tag-item">${baseData.category}</span></a>`;
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper">${categoryContent}</div>`;
} else {
categoryBox.innerHTML = `<span class="tag-title">CATEGORY</span><div class="tag-content-wrapper"><span>지정되지 않음</span></div>`;
}
// 해시태그 데이터를 HTML로 렌더링 (태그가 여러 개일 수 있으므로 split/map/join 사용)
// [수정] 해시태그 데이터를 링크(`<a>`)를 포함한 HTML로 렌더링
let hashtagContent = '';
if (baseData.tags && baseData.tags.length > 0) {
hashtagContent = baseData.tags.split(',')
.map(tag => `<span class="tag-item">#${tag.trim()}</span>`)
.join(' '); // 각 태그를 공백으로 구분
.map(tag => {
const trimmedTag = tag.trim();
if (trimmedTag) {
const tagLink = `${getMainPath()}/blog/posts?tag=${encodeURIComponent(trimmedTag)}`;
return `<a href="${tagLink}" style="border-bottom: none;"><span class="tag-item">#${trimmedTag}</span></a>`;
}
return '';
})
.join(' ');
} else {
hashtagContent = '<span>없음</span>';
}
@ -595,6 +621,51 @@ async function save() {
// 1. 원본 baseData를 복사하여 전송용 객체(dataToSend) 생성
let dataToSend = JSON.parse(JSON.stringify(baseData));
// 2. [신규] 수동 입력 필드에서 값 읽기
const manualDateInput = document.getElementById('manual_date');
const manualLatInput = document.getElementById('manual_lat');
const manualLonInput = document.getElementById('manual_lon');
// 2.1. 수동 날짜 처리
if (manualDateInput && manualDateInput.value) {
const manualTimestamp = new Date(manualDateInput.value).getTime();
if (!isNaN(manualTimestamp)) {
dataToSend.writeTime = manualTimestamp;
if (!dataToSend.id) {
dataToSend.modifyTime = manualTimestamp;
}
}
}
// 2.2. 수동 좌표 또는 자동 GPS 위치 처리
const manualLat = parseFloat(manualLatInput.value);
const manualLon = parseFloat(manualLonInput.value);
if (!isNaN(manualLat) && !isNaN(manualLon)) {
// 수동 좌표가 유효한 숫자로 입력된 경우
dataToSend.modifyLat = manualLat;
dataToSend.modifyLon = manualLon;
// 좌표를 직접 입력했으므로, 주소 정보는 초기화
dataToSend.modifyAddress = '';
if (!dataToSend.id) { // 새 글일 경우
dataToSend.firstPostLat = manualLat;
dataToSend.firstPostLon = manualLon;
dataToSend.firstAddress = '';
}
} else {
// 수동 좌표가 없으면, 자동 GPS 위치를 사용
dataToSend.modifyLat = currentLat;
dataToSend.modifyLon = currentLon;
if (!dataToSend.id) { // 새 글일 경우
dataToSend.firstPostLat = currentLat;
dataToSend.firstPostLon = currentLon;
dataToSend.firstAddress = '';
dataToSend.modifyAddress = '';
}
}
// 2. 모든 텍스트 필드를 URL 전송을 위해 인코딩합니다.
if (titleField) {
dataToSend.title = encodeURIComponent(titleField.value);
@ -606,9 +677,14 @@ async function save() {
dataToSend.category = encodeURIComponent(dataToSend.category || 'none');
dataToSend.tags = encodeURIComponent(dataToSend.tags || '');
dataToSend.posting = document.getElementById('post-published-switch').checked
// 3. 현재 위치 좌표를 '수정 좌표'로 업데이트
dataToSend.modifyLat = currentLat;
dataToSend.modifyLon = currentLon;
dataToSend.firstAddress = encodeURIComponent(dataToSend.firstAddress || '');
// dataToSend.modifyAddress = encodeURIComponent(dataToSend.modifyAddress || '');
//
//
// // 3. 현재 위치 좌표를 '수정 좌표'로 업데이트
// dataToSend.modifyLat = currentLat;
// dataToSend.modifyLon = currentLon;
// 4. 새 글일 경우 '최초 좌표'에도 기록
if (dataToSend.firstPostLat === 0.0 || dataToSend.firstPostLat === null) {
@ -646,11 +722,18 @@ async function save() {
* 사용자의 현재 GPS 위치를 가져옵니다. (바닐라 Browser API)
*/
function getLocation() {
// 1. 이미 저장된 좌표가 있으면 해당 좌표로 주소 변환 API 호출
if (baseData.firstPostLat !== 0.0 || baseData.firstPostLon !== 0.0) {
// 1. 표시할 좌표 결정: 수정 좌표(modifyLat) -> 최초 좌표(firstPostLat) 순으로 확인
const latToDisplay = (baseData.modifyLat !== 0.0) ? baseData.modifyLat : baseData.firstPostLat;
const lonToDisplay = (baseData.modifyLon !== 0.0) ? baseData.modifyLon : baseData.firstPostLon;
const locationField = document.getElementById('location_field');
if (!locationField) return; // locationField 요소가 없으면 함수 종료
// 2. 저장된 좌표가 있는 경우 (가장 일반적인 경우)
if (latToDisplay != 0.0 || lonToDisplay != 0.0) {
try {
var requestOptions = { method: 'GET' };
fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+baseData.firstPostLat+"&lon="+baseData.firstPostLon+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions)
fetch("https://api.geoapify.com/v1/geocode/reverse?lat="+latToDisplay+"&lon="+lonToDisplay+"&apiKey=2b37a75bb0754086b5a1c4a7c3173ee8", requestOptions)
.then(response => response.json())
.then(function(result) {
const locationField = document.getElementById('location_field');
@ -658,41 +741,48 @@ function getLocation() {
var inh = `<span class="tag-title">LOCATION</span>`;
inh += `<div class="tag-content-wrapper">`;
inh += `<div class="tag-item">${result.features[0].properties.formatted}</div>`; // 주소
inh += `<div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div>`;
inh += `<div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
inh += `<div class="tag-item">Lat: ${latToDisplay.toFixed(2)}</div>`;
inh += `<div class="tag-item">Lon: ${lonToDisplay.toFixed(2)}</div></div>`;
locationField.innerHTML = inh;
} catch (e) {
// 주소 변환 실패 시 좌표만 표시
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${latToDisplay.toFixed(2)}</div><div class="tag-item">Lon: ${lonToDisplay.toFixed(2)}</div></div>`;
}
})
.catch(error => console.log('error', error));
}catch (e) { }
} else if (navigator.geolocation) {
// 2. 저장된 좌표가 없으면 (새 글 작성 시) 브라우저에 GPS 요청
navigator.geolocation.getCurrentPosition(pos => {
currentLat = pos.coords.latitude;
currentLon = pos.coords.longitude;
if (baseData.firstPostLat === 0.0) baseData.firstPostLat = currentLat;
if (baseData.firstPostLon === 0.0) baseData.firstPostLon = currentLon;
baseData.modifyLat = currentLat;
baseData.modifyLon = currentLon;
}
// 3. 저장된 좌표는 없지만, 브라우저가 GPS를 지원하는 경우 (새 글 작성 시)
else if (navigator.geolocation) {
// 현재 위치를 GPS로 가져옵니다.
navigator.geolocation.getCurrentPosition(
(pos) => { // 성공 콜백
currentLat = pos.coords.latitude;
currentLon = pos.coords.longitude;
// UI에 좌표 표시
const locationField = document.getElementById('location_field');
if (locationField) {
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">Lat: ${baseData.firstPostLat.toFixed(2)}</div><div class="tag-item">Lon: ${baseData.firstPostLon.toFixed(2)}</div></div>`;
// 가져온 GPS 좌표를 location-field에 표시
const inh = `<span class="tag-title">LOCATION</span>
<div class="tag-content-wrapper">
<div class="tag-item">Lat: ${currentLat.toFixed(4)}</div>
<div class="tag-item">Lon: ${currentLon.toFixed(4)}</div>
</div>`;
locationField.innerHTML = inh;
// [개선] 가져온 좌표를 수동 입력 필드의 기본값으로도 설정해 줍니다.
const manualLatInput = document.getElementById('manual_lat');
const manualLonInput = document.getElementById('manual_lon');
if (manualLatInput) manualLatInput.value = currentLat.toFixed(6);
if (manualLonInput) manualLonInput.value = currentLon.toFixed(6);
},
() => { // 실패 콜백
locationField.innerHTML = `<span class="tag-title">LOCATION</span><div class="tag-content-wrapper"><div class="tag-item">GPS를 가져올 수 없습니다.</div></div>`;
}
});
} else {
// 3. GPS 지원 안 하는 브라우저
const locationField = document.getElementById('location_field');
if (locationField) {
locationField.innerHTML = `<span class="tag-title">LOCATION</span>` +
`<div class="tag-content-wrapper"><div class="tag-item">좌표 지원 안함</div></div>`;
}
);
}
// 4. 브라우저가 GPS를 지원하지 않는 경우
else {
locationField.innerHTML = `<span class="tag-title">LOCATION</span><div class="tag-content-wrapper"><div class="tag-item">좌표 지원 안함</div></div>`;
}
}
@ -2229,7 +2319,7 @@ function handleDeletePost(postId) {
}
/**
* [신규 추가] 현재 수정 중인 게시물을 삭제하는 함수
* 현재 수정 중인 게시물을 삭제하는 함수
* @param {string} postId 삭제할 게시물의 ID
*/
function deleteCurrentPost(buttonElement) {
@ -2262,4 +2352,18 @@ function deleteCurrentPost(buttonElement) {
alert('삭제 중 오류가 발생했습니다.');
});
}
}
}
/**
* 밀리초 타임스탬프를 'YYYY-MM-DDTHH:mm' 형식의 문자열로 변환합니다.
* @param {number} ms - 변환할 타임스탬프 (밀리초)
* @returns {string} datetime-local input에 사용할 있는 형식의 문자열
*/
function formatTimestampForInput(ms) {
if (!ms || ms === 0) return '';
const date = new Date(ms);
// 사용자의 로컬 시간대에 맞게 표시하기 위해 타임존 오프셋을 계산하여 적용합니다.
const timezoneOffset = date.getTimezoneOffset() * 60000;
const localDate = new Date(date.getTime() - timezoneOffset);
return localDate.toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
}

View File

@ -41,6 +41,24 @@
</div>
<div class="write_option btn-example controlbox-location" id="location_field"></div>
</div>
<div class="manual-input-section" style="margin-bottom: 2em; padding: 1.5em; border: 1px solid #ddd; border-radius: 4px; background-color: #f9f9f9;">
<h4 style="margin-bottom: 1em; font-size: 1.1em; color: #333;"><i class="icon solid fa-cog" style="margin-right: 0.5em;"></i>수동 설정</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5em;">
<div>
<label for="manual_date" style="font-weight: bold; margin-bottom: 0.5em; display: block;">작성일</label>
<input type="datetime-local" id="manual_date" style="width:100%; box-sizing: border-box;">
</div>
<div>
<label for="manual_lat" style="font-weight: bold; margin-bottom: 0.5em; display: block;">위도 (Latitude)</label>
<input type="number" id="manual_lat" step="any" placeholder="예: 37.5665" style="width:100%; box-sizing: border-box;">
</div>
<div>
<label for="manual_lon" style="font-weight: bold; margin-bottom: 0.5em; display: block;">경도 (Longitude)</label>
<input type="number" id="manual_lon" step="any" placeholder="예: 126.9780" style="width:100%; box-sizing: border-box;">
</div>
</div>
<p style="font-size: 0.8em; color: #777; margin-top: 1em;">* 작성일이나 좌표를 직접 입력하면 자동으로 측정된 GPS 정보 대신 입력된 값이 저장됩니다.</p>
</div>
</div>
</th:block>
</section>

View File

@ -34,21 +34,12 @@
<!-- </section>-->
<section class="wrapper style1">
<div class="container" style="text-align:center;">
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716" data-ad-slot="1234567890" data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
<div class="container">
<div class="row">
<div class="col-12">
<div id="content_inner">
<article>
<section th:each="post : ${Posts}">
<section th:each="post , iterStat : ${Posts}">
<div class="box post" th:id="${post.id}" onclick="goToViewer(this)" style="cursor: pointer;">
<span class="image left">
<img th:src="${post.thumb != null and not #strings.isEmpty(post.thumb)} ? ${apiBaseUrl + post.thumb} : @{/images/pic01.jpg}" alt="Post Thumbnail" />
@ -66,6 +57,22 @@
<p th:text="${#strings.abbreviate(post.html, 80)}" class="ellipsis"></p>
</div>
</div>
<th:block th:if="${iterStat.count % 3 == 0}">
<section>
<div class="box ad-container" style="padding: 2em; text-align: center;">
<p style="margin-bottom: 1em; color: #888;">- Advertisement -</p>
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div>
</section>
</th:block>
</section>
</article>
</div>

View File

@ -16,6 +16,10 @@
<div class="col-12">
<div id="content_inner">
<article>
<header class="major" th:if="${filterTitle}">
<h2 th:text="${filterTitle}">필터링된 게시물</h2>
<p><a th:href="@{/blog/posts}">모든 글 보기</a></p>
</header>
<th:block th:each="post, iterStat : ${postsPage.content}">
<section th:id="'post-section-' + ${post.id}">
<div class="box post" th:id="${post.id}">
@ -48,12 +52,12 @@
<th:block th:if="${iterStat.count % 3 == 0}">
<section>
<div class="box" style="padding: 2em; text-align: center;">
<div class="box ad-container" style="padding: 2em; text-align: center;">
<p style="margin-bottom: 1em; color: #888;">- Advertisement -</p>
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-xxxxxxxxxxxxxxxx"
data-ad-slot="yyyyyyyyyy"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
@ -68,21 +72,21 @@
<nav aria-label="Page navigation" th:if="${postsPage.totalPages > 1}" style="text-align: center; margin-top: 2.5em; font-size: 0.9em;">
<ul class="pagination" style="display: inline-block; padding-left: 0; list-style: none; border-radius: 5px; border: 1px solid #e0e0e0; overflow: hidden;">
<li th:styleappend="${postsPage.isFirst()} ? 'opacity: 0.5; pointer-events: none;' : ''" style="display: inline; float: left;">
<a th:href="${postsPage.isFirst()} ? '#' : @{/blog/posts(page=${postsPage.number - 1})}"
<a th:href="${postsPage.isFirst()} ? '#' : @{/blog/posts(page=${postsPage.number - 1}, category=${currentCategory}, tag=${currentTag})}"
class="button alt small" style="border-radius:0; margin:0; border-right: 1px solid #e0e0e0;">
&laquo; Prev
</a>
</li>
<li th:each="pageNum : ${#numbers.sequence(0, postsPage.totalPages - 1)}"
style="display: inline; float: left; border-right: 1px solid #e0e0e0;">
<a th:href="@{/blog/posts(page=${pageNum})}"
<a th:href="@{/blog/posts(page=${pageNum}, category=${currentCategory}, tag=${currentTag})}"
th:text="${pageNum + 1}"
th:class="${pageNum == postsPage.number} ? 'button small' : 'button alt small'"
style="border-radius:0; margin:0;">
</a>
</li>
<li th:styleappend="${postsPage.isLast()} ? 'opacity: 0.5; pointer-events: none;'" style="display: inline; float: left;">
<a th:href="${postsPage.isLast()} ? '#' : @{/blog/posts(page=${postsPage.number + 1})}"
<a th:href="${postsPage.isLast()} ? '#' : @{/blog/posts(page=${postsPage.number + 1}, category=${currentCategory}, tag=${currentTag})}"
class="button alt small" style="border-radius:0; margin:0;">
Next &raquo;
</a>

View File

@ -164,7 +164,9 @@
<div class="container" style="text-align:center;">
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716" data-ad-slot="1234567890" data-ad-format="auto"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});

View File

@ -328,7 +328,9 @@
<div class="container" style="text-align:center;">
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716" data-ad-slot="1234567890" data-ad-format="auto"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});

View File

@ -409,11 +409,38 @@
canvas.addEventListener('mouseup', handlePointerUp);
canvas.addEventListener('dblclick', handleDoubleClick);
// ▼▼▼ [추가] 터치 이벤트 리스너 등록 ▼▼▼
canvas.addEventListener('touchstart', handlePointerDown);
canvas.addEventListener('touchmove', (e) => {
e.preventDefault(); // 모바일에서 드래그 시 화면이 스크롤되는 것을 방지
handlePointerMove(e);
});
canvas.addEventListener('touchend', handlePointerUp);
function getCanvasCoordinates(event) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width, scaleY = canvas.height / rect.height;
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
// 터치 이벤트와 마우스 이벤트를 모두 처리하기 위한 좌표 변수
let clientX, clientY;
if (event.touches && event.touches.length > 0) {
// 'touchstart', 'touchmove' 이벤트 처리
clientX = event.touches[0].clientX;
clientY = event.touches[0].clientY;
} else if (event.changedTouches && event.changedTouches.length > 0) {
// 'touchend' 이벤트 처리
clientX = event.changedTouches[0].clientX;
clientY = event.changedTouches[0].clientY;
} else {
// 마우스 이벤트 처리 ('mousedown', 'mousemove', 'mouseup')
clientX = event.clientX;
clientY = event.clientY;
}
// clientX 또는 clientY가 undefined인 경우 오류 방지
if (typeof clientX === 'undefined' || typeof clientY === 'undefined') return null;
return { x: (clientX - rect.left) * scaleX / dpr, y: (clientY - rect.top) * scaleY / dpr };
}
@ -456,6 +483,10 @@
// =======================================
function handlePointerDown(event) {
if (isProcessing || isAnimatingCompletion) return;
if (event.type.startsWith('touch')) {
event.preventDefault();
}
const coords = getCanvasCoordinates(event);
const element = findElementAt(coords.x, coords.y);
if (!element) return;
@ -506,6 +537,11 @@
if (!isDragging) { draggedCards = []; return; }
const coords = getCanvasCoordinates(event);
if (!coords) { // coords가 null일 경우를 대비한 방어 코드
isDragging = false;
draggedCards = [];
return;
}
const dropTargetStackId = findStackAt(coords.x, coords.y);
const sourceStackIndex = draggedCards.sourceStackIndex;
@ -827,7 +863,9 @@
<div class="container" style="text-align:center;">
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716" data-ad-slot="1234567890" data-ad-format="auto"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});

View File

@ -300,7 +300,9 @@
<div class="container" style="text-align:center;">
<ins class="adsbygoogle"
style="display:block"
data-ad-client="ca-pub-9504446465764716" data-ad-slot="1234567890" data-ad-format="auto"
data-ad-client="ca-pub-9504446465764716"
data-ad-slot="5334609005"
data-ad-format="auto"
data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});

View File

@ -10,15 +10,14 @@
<th:block layout:fragment="content" id="content">
<section class="wrapper style2">
<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">
<div class="container" sec:authorize="isAuthenticated()">
<header class="major"
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) ? '클릭하여 수정하기' : ''}
">
<h2 id="title_layer">
<span th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</span>
<span style="font-size: 0.8em; color: #888; font-weight: normal; margin-left: 0.5em;">
@ -41,8 +40,8 @@
</div>
</div>
<div class="container open-login-popup" sec:authorize="isAnonymous()" to="#loginPopup" style="cursor: pointer;">
<header class="major">
<div class="container" sec:authorize="isAnonymous()">
<header class="major open-login-popup" to="#loginPopup" style="cursor: pointer;">
<h2 id="title_layer_anon">
<span th:text="${srcPost.title}">게시물 제목이 여기에 표시됩니다</span>
<span style="font-size: 0.8em; color: #888; font-weight: normal; margin-left: 0.5em;">

View File

@ -31,6 +31,28 @@
</head>
<body class="is-preload">
<div id="page-wrapper">
<script>
// 페이지의 모든 리소스(광고 스크립트 포함)가 로드된 후 함수를 실행합니다.
window.onload = function() {
// 구글 광고 스크립트가 실행될 시간을 약간 더 주기 위해 setTimeout을 사용합니다.
setTimeout(function() {
// 'ad-container' 클래스를 가진 모든 요소를 찾습니다.
const adContainers = document.querySelectorAll('.ad-container');
adContainers.forEach(container => {
// 각 컨테이너 내부에서 '.adsbygoogle' 클래스를 가진 광고 슬롯을 찾습니다.
const adSlot = container.querySelector('.adsbygoogle');
// 광고 슬롯이 존재하고, 'data-ad-status' 속성 값이 'unfilled'이면
if (adSlot && adSlot.getAttribute('data-ad-status') === 'unfilled') {
// 광고 컨테이너 전체를 보이지 않게 처리합니다.
console.log('광고가 채워지지 않아 해당 영역을 숨깁니다.');
container.style.display = 'none';
}
});
}, 3000); // 1.5초 후에 실행 (광고 로딩 시간을 고려하여 조절 가능)
};
</script>
<th:block th:replace="~{fragments/header :: header}"></th:block>
<th:block layout:fragment="content"></th:block>
<div class="dim_layer">