no message
This commit is contained in:
parent
74e88d7d89
commit
d2a1f37f39
@ -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,
|
||||
// ... 기타 수정 가능한 필드들 ...
|
||||
)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 객체
|
||||
|
||||
@ -638,7 +638,7 @@ class GameRankService(
|
||||
}
|
||||
|
||||
/**
|
||||
* [신규 추가] 특정 플레이어의 모든 게임 랭킹을 조회합니다.
|
||||
* 특정 플레이어의 모든 게임 랭킹을 조회합니다.
|
||||
*/
|
||||
fun getRanksByPlayer(playerName: String): Flux<GameRank> {
|
||||
return rankRepository.findByPlayerNameOrderByTimestampDesc(playerName)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;">
|
||||
« 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 »
|
||||
</a>
|
||||
|
||||
@ -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({});
|
||||
|
||||
@ -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({});
|
||||
|
||||
@ -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({});
|
||||
|
||||
@ -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({});
|
||||
|
||||
@ -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;">
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user