...
This commit is contained in:
parent
b9fe935e98
commit
84ba6a02aa
@ -126,8 +126,29 @@ class SecurityConfig(
|
|||||||
// handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
|
// handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
|
||||||
handling.accessDeniedHandler(accessDeniedHandler2)
|
handling.accessDeniedHandler(accessDeniedHandler2)
|
||||||
}
|
}
|
||||||
// 모든 API 요청 전에 JWT 토큰을 검증하는 필터 추가
|
|
||||||
.addFilterBefore(JwtAuthenticationFilter(jwtUtil, userManager), UsernamePasswordAuthenticationFilter::class.java)
|
// [수정 포인트] 필터 추가 순서 변경
|
||||||
|
// 1. JWT 필터 인스턴스 생성
|
||||||
|
val jwtFilter = JwtAuthenticationFilter(jwtUtil, userManager)
|
||||||
|
|
||||||
|
// 2. 디버깅 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
|
||||||
|
// (체인 상태: ... -> DebugFilter -> UsernamePasswordAuthenticationFilter)
|
||||||
|
http.addFilterBefore(object : OncePerRequestFilter() {
|
||||||
|
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
|
||||||
|
if (request.requestURI.startsWith("/api/synology")) {
|
||||||
|
val auth = SecurityContextHolder.getContext().authentication
|
||||||
|
println(">>> SECURITY DEBUG: [${request.method}] ${request.requestURI}")
|
||||||
|
println(" User: ${auth?.name ?: "Anonymous"}")
|
||||||
|
println(" Authorities: ${auth?.authorities}")
|
||||||
|
}
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}, UsernamePasswordAuthenticationFilter::class.java)
|
||||||
|
|
||||||
|
// 3. JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
|
||||||
|
// addFilterBefore는 타겟 바로 앞에 끼워넣으므로,
|
||||||
|
// 결과 체인 순서는: DebugFilter -> JwtAuthenticationFilter -> UsernamePasswordAuthenticationFilter 가 됩니다.
|
||||||
|
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
|
||||||
|
|
||||||
return http.build()
|
return http.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,174 +1,170 @@
|
|||||||
package kr.lunaticbum.back.lun.controllers.api
|
package kr.lunaticbum.back.lun.controllers
|
||||||
|
|
||||||
import io.netty.handler.ssl.SslContextBuilder
|
|
||||||
import io.netty.handler.ssl.util.InsecureTrustManagerFactory
|
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.core.io.buffer.DataBuffer
|
import org.springframework.core.io.buffer.DataBuffer
|
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector
|
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.reactive.function.client.WebClient
|
import org.springframework.web.reactive.function.client.WebClient
|
||||||
import org.springframework.web.reactive.function.client.awaitBody
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
import reactor.netty.http.client.HttpClient
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
// DTO 정의
|
||||||
|
data class SynologyListRequest(
|
||||||
|
val nasAddress: String,
|
||||||
|
val sid: String,
|
||||||
|
val offset: Int,
|
||||||
|
val limit: Int
|
||||||
|
)
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/synology")
|
@RequestMapping("/api/synology")
|
||||||
class SynologyProxyController {
|
class SynologyProxyController {
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(SynologyProxyController::class.java)
|
|
||||||
|
|
||||||
// SSL 검증 무시 설정 (HTTPS 사용 시 필요)
|
|
||||||
private val httpClient = HttpClient.create()
|
|
||||||
.secure { t -> t.sslContext(
|
|
||||||
SslContextBuilder.forClient()
|
|
||||||
.trustManager(InsecureTrustManagerFactory.INSTANCE)
|
|
||||||
.build()
|
|
||||||
)}
|
|
||||||
|
|
||||||
// [수정됨] WebClient 생성 시 버퍼 크기 제한 해제 (256KB -> 20MB)
|
|
||||||
private val webClient = WebClient.builder()
|
private val webClient = WebClient.builder()
|
||||||
.clientConnector(ReactorClientHttpConnector(httpClient))
|
|
||||||
.codecs { configurer ->
|
.codecs { configurer ->
|
||||||
configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024) // 20MB로 증설
|
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB 버퍼
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private fun buildBaseUrl(nasAddress: String): String {
|
private fun buildBaseUrl(nasAddress: String): String {
|
||||||
if (nasAddress.startsWith("http://") || nasAddress.startsWith("https://")) {
|
return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress"
|
||||||
return nasAddress
|
|
||||||
}
|
|
||||||
return if (nasAddress.contains(":5000")) "http://$nasAddress" else "https://$nasAddress"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
suspend fun login(@RequestBody payload: Map<String, Any>): String {
|
suspend fun login(@RequestBody request: Map<String, String>): ResponseEntity<Any> {
|
||||||
try {
|
val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress")
|
||||||
val nasAddress = payload["nasAddress"].toString()
|
val username = request["username"] ?: return ResponseEntity.badRequest().body("No username")
|
||||||
val username = payload["username"].toString()
|
val password = request["password"] ?: return ResponseEntity.badRequest().body("No password")
|
||||||
val password = payload["password"].toString()
|
|
||||||
|
|
||||||
val baseUrl = buildBaseUrl(nasAddress)
|
val baseUrl = buildBaseUrl(nasAddress)
|
||||||
val url = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=7&method=login&account=$username&passwd=$password&session=SynologyPhotos&format=sid"
|
val uri = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=$username&passwd=$password&session=Foto&format=cookie"
|
||||||
|
|
||||||
logger.info("Login Request URL: $url")
|
return try {
|
||||||
return webClient.get().uri(url).retrieve().awaitBody()
|
val response = webClient.get()
|
||||||
|
.uri(uri)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String::class.java)
|
||||||
|
.awaitSingle()
|
||||||
|
ResponseEntity.ok(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("Login Error: ", e)
|
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. [수정] list 메서드: additional 제거 (에러 해결)
|
|
||||||
@PostMapping("/list")
|
@PostMapping("/list")
|
||||||
suspend fun list(@RequestBody payload: Map<String, Any>): String {
|
suspend fun getList(
|
||||||
try {
|
@RequestBody request: SynologyListRequest
|
||||||
val nasAddress = payload["nasAddress"].toString()
|
): ResponseEntity<Any> {
|
||||||
val sid = payload["sid"].toString()
|
val baseUrl = buildBaseUrl(request.nasAddress)
|
||||||
val offset = payload["offset"].toString()
|
|
||||||
val limit = payload["limit"].toString()
|
|
||||||
|
|
||||||
val baseUrl = buildBaseUrl(nasAddress)
|
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||||
|
.path("/webapi/entry.cgi")
|
||||||
|
.queryParam("api", "SYNO.Foto.Browse.Item")
|
||||||
|
.queryParam("version", "1")
|
||||||
|
.queryParam("method", "list")
|
||||||
|
.queryParam("offset", request.offset)
|
||||||
|
.queryParam("limit", request.limit)
|
||||||
|
// [중요] 썸네일 키(cache_key)를 받아오기 위해 필수
|
||||||
|
.queryParam("additional", "[\"thumbnail\"]")
|
||||||
|
.queryParam("_sid", request.sid)
|
||||||
|
.build()
|
||||||
|
.toUri()
|
||||||
|
|
||||||
// 순수하게 목록만 요청
|
return try {
|
||||||
val url = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=list&limit=$limit&offset=$offset&_sid=$sid"
|
val response = webClient.get()
|
||||||
|
.uri(uri)
|
||||||
logger.info("List Request URL: $url")
|
.retrieve()
|
||||||
return webClient.get().uri(url).retrieve().awaitBody()
|
.bodyToMono(String::class.java)
|
||||||
|
.awaitSingle()
|
||||||
|
ResponseEntity.ok(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("List Error: ", e)
|
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. [추가] info 메서드: 사진 1장의 상세 정보(날짜, 위치) 조회
|
|
||||||
// [수정] 유효한 additional 항목("exif", "gps")만 요청하도록 변경
|
|
||||||
@PostMapping("/info")
|
|
||||||
suspend fun getInfo(@RequestBody payload: Map<String, Any>): String {
|
|
||||||
try {
|
|
||||||
val nasAddress = payload["nasAddress"].toString()
|
|
||||||
val sid = payload["sid"].toString()
|
|
||||||
val id = payload["id"].toString()
|
|
||||||
|
|
||||||
val baseUrl = buildBaseUrl(nasAddress)
|
|
||||||
|
|
||||||
// [핵심 변경] "address", "taken_time"은 유효하지 않음 -> "exif", "gps"로 변경
|
|
||||||
// ["exif","gps"] -> %5B%22exif%22%2C%22gps%22%5D
|
|
||||||
val additionalEncoded = "%5B%22exif%22%2C%22gps%22%5D"
|
|
||||||
|
|
||||||
val urlString = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=get&id=$id&additional=$additionalEncoded&_sid=$sid"
|
|
||||||
|
|
||||||
val uri = java.net.URI(urlString)
|
|
||||||
|
|
||||||
return webClient.get().uri(uri).retrieve().awaitBody()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.error("Info Error: ", e)
|
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/geocode")
|
@GetMapping("/geocode")
|
||||||
suspend fun geocode(
|
suspend fun reverseGeocode(
|
||||||
@RequestParam lat: String,
|
@RequestParam lat: Double,
|
||||||
@RequestParam lon: String
|
@RequestParam lon: Double
|
||||||
): String {
|
): ResponseEntity<Any> {
|
||||||
// OpenStreetMap API URL
|
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
||||||
val url = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
|
||||||
|
|
||||||
// Nominatim은 User-Agent 헤더가 필수입니다.
|
return try {
|
||||||
return webClient.get()
|
val response = webClient.get()
|
||||||
.uri(url)
|
.uri(uri)
|
||||||
.header("User-Agent", "MyNasSlideshow/1.0 (contact@example.com)") // 임의의 식별 문자열
|
.header("User-Agent", "SynoPhotoSlideshow/1.0")
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.awaitBody()
|
.bodyToMono(String::class.java)
|
||||||
|
.awaitSingle()
|
||||||
|
|
||||||
|
ResponseEntity.ok(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ResponseEntity.ok("{}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/image")
|
@GetMapping("/image")
|
||||||
suspend fun getImage(
|
fun getImage(
|
||||||
@RequestParam nasAddress: String,
|
@RequestParam nasAddress: String,
|
||||||
@RequestParam sid: String,
|
@RequestParam sid: String,
|
||||||
@RequestParam id: String
|
@RequestParam id: String,
|
||||||
): ResponseEntity<ByteArray> {
|
@RequestParam(defaultValue = "download") mode: String,
|
||||||
|
@RequestParam(required = false) cacheKey: String?
|
||||||
|
): ResponseEntity<StreamingResponseBody> {
|
||||||
val baseUrl = buildBaseUrl(nasAddress)
|
val baseUrl = buildBaseUrl(nasAddress)
|
||||||
|
|
||||||
// [핵심] 문자열 더하기 대신 UriComponentsBuilder 사용
|
val rawUrl = if (mode == "thumbnail") {
|
||||||
// Spring이 '[' 문자를 자동으로 알맞게(%5B) 인코딩해줍니다. (이중 인코딩 방지)
|
val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else ""
|
||||||
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid"
|
||||||
.path("/webapi/entry.cgi")
|
} else {
|
||||||
.queryParam("api", "SYNO.Foto.Download")
|
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
|
||||||
.queryParam("version", "1")
|
}
|
||||||
.queryParam("method", "download")
|
val uri = java.net.URI.create(rawUrl)
|
||||||
.queryParam("force_download", "true")
|
|
||||||
.queryParam("item_id", "[$id]") // 여기에 대괄호 []를 직접 넣습니다.
|
|
||||||
.queryParam("_sid", sid)
|
|
||||||
.build()
|
|
||||||
.toUri()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val response = webClient.get()
|
// [해결] retrieve() + toEntityFlux() 사용
|
||||||
.uri(uri) // String url 대신 URI 객체를 전달
|
// exchangeToMono의 복잡한 스코프 문제를 피하고, 헤더와 바디 스트림을 안전하게 가져옵니다.
|
||||||
.accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG, MediaType.ALL)
|
val responseEntity = webClient.get()
|
||||||
|
.uri(uri)
|
||||||
|
.accept(MediaType.ALL)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.toEntity(ByteArray::class.java)
|
.toEntityFlux(DataBuffer::class.java)
|
||||||
.awaitSingle()
|
.block() ?: return ResponseEntity.notFound().build()
|
||||||
|
|
||||||
val contentType = response.headers.contentType
|
val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM
|
||||||
|
val contentLength = responseEntity.headers.contentLength
|
||||||
|
|
||||||
// 에러 체크
|
// 1. 에러 체크 (JSON 응답이 오면 에러로 간주)
|
||||||
if (contentType != null && !contentType.toString().startsWith("image")) {
|
if (contentType.includes(MediaType.APPLICATION_JSON)) {
|
||||||
val errorBody = String(response.body ?: ByteArray(0))
|
// 스트림을 문자열로 읽어서 로그 출력
|
||||||
logger.error("NAS returned non-image content ($contentType): $errorBody")
|
val errorBody = responseEntity.body?.collectList()?.block()?.joinToString("") {
|
||||||
logger.error("Requested URI: $uri") // 디버깅을 위해 요청한 URI도 출력
|
it.toString(StandardCharsets.UTF_8)
|
||||||
|
} ?: "Unknown Error"
|
||||||
|
|
||||||
|
println(">>> NAS Error (ID: $id): $errorBody")
|
||||||
|
return ResponseEntity.notFound().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
// 2. 정상 스트리밍 (DataBufferUtils.write 사용)
|
||||||
.contentType(contentType ?: MediaType.IMAGE_JPEG)
|
// Flux<DataBuffer>를 OutputStream에 바로 씁니다. (메모리 효율 최적)
|
||||||
.body(response.body)
|
val streamingBody = StreamingResponseBody { outputStream ->
|
||||||
|
val flux = responseEntity.body ?: Flux.empty()
|
||||||
|
DataBufferUtils.write(flux, outputStream)
|
||||||
|
.blockLast() // 쓰기가 완료될 때까지 대기
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = ResponseEntity.ok().contentType(contentType)
|
||||||
|
if (contentLength > 0) {
|
||||||
|
builder.contentLength(contentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.body(streamingBody)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("Image Fetch Error: ", e)
|
e.printStackTrace()
|
||||||
return ResponseEntity.notFound().build()
|
return ResponseEntity.notFound().build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,53 +6,208 @@
|
|||||||
<title>BUM's NAS Slideshow</title>
|
<title>BUM's NAS Slideshow</title>
|
||||||
|
|
||||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716" crossorigin="anonymous"></script>
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-9504446465764716" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/exif-js"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
<script th:src="@{/js/exif-js.js}"></script>
|
|
||||||
<style>
|
<style>
|
||||||
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color: #000; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; }
|
@import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&family=Noto+Sans+KR:wght@300;400;700&display=swap');
|
||||||
.exit-btn { position: absolute; top: 20px; right: 20px; z-index: 100; color: rgba(255,255,255,0.7); text-decoration: none; background: rgba(0,0,0,0.5); padding: 8px 15px; border-radius: 30px; font-size: 0.9em; transition: 0.3s; }
|
|
||||||
.exit-btn:hover { background: rgba(228, 76, 101, 0.8); color: white; }
|
|
||||||
|
|
||||||
/* 정보 오버레이 */
|
:root { --anim-duration: 1.5s; --text-scale: 1.0; }
|
||||||
#info-overlay {
|
|
||||||
position: absolute; top: 30px; left: 30px; z-index: 15;
|
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; display: flex; flex-direction: column; font-family: 'Noto Sans KR', sans-serif; background: #000; }
|
||||||
text-shadow: 1px 1px 5px rgba(0,0,0,0.9);
|
|
||||||
pointer-events: none; opacity: 0; transition: opacity 1s;
|
#bg-layer {
|
||||||
max-width: 50%;
|
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
background: linear-gradient(to top, #09203f 0%, #537895 100%);
|
||||||
|
transition: background 1.5s ease; z-index: 0;
|
||||||
}
|
}
|
||||||
#info-date { font-size: 1.8em; font-weight: 700; color: #fff; margin-bottom: 5px; letter-spacing: -0.5px; }
|
|
||||||
#info-location { font-size: 1.1em; color: #eee; display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
|
||||||
#info-location i { color: #e44c65; font-size: 1.0em; }
|
|
||||||
|
|
||||||
/* 로그인 폼 */
|
#main-wrapper {
|
||||||
#login-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; z-index: 50; background: #000; }
|
position: relative; flex: 1; width: 100%;
|
||||||
#login-form { display: flex; flex-direction: column; gap: 15px; padding: 40px; background: #222; border-radius: 12px; width: 320px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; outline: none; }
|
z-index: 1; perspective: 1000px;
|
||||||
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; font-size: 1em; }
|
}
|
||||||
|
|
||||||
#photo-container { position: relative; width: 100%; height: 100%; z-index: 1; }
|
#card-stage {
|
||||||
.slide-image { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; opacity: 0; transition: opacity 1.5s ease-in-out; will-change: opacity; }
|
position: relative; width: 100%; height: 100%;
|
||||||
.slide-image.active { opacity: 1; }
|
display: flex; justify-content: center; align-items: center; padding-bottom: 0; /* 하단 여백 제거 */
|
||||||
|
}
|
||||||
|
|
||||||
#ad-container { position: absolute; bottom: 0; width: 100%; text-align: center; z-index: 10; padding-bottom: 20px; pointer-events: none; }
|
.polaroid-card {
|
||||||
#ad-container ins { pointer-events: auto; display: inline-block; }
|
position: absolute; background-color: #fff; color: #333;
|
||||||
|
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
|
||||||
|
width: 320px; height: 400px;
|
||||||
|
display: flex; flex-direction: column; border-radius: 4px;
|
||||||
|
transition: opacity var(--anim-duration) ease-in-out, transform var(--anim-duration) cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
opacity: 0; z-index: 1; transform: scale(1.05); will-change: opacity, transform; overflow: hidden;
|
||||||
|
}
|
||||||
|
.polaroid-card.active { opacity: 1; z-index: 10; transform: scale(1); }
|
||||||
|
|
||||||
#controls { position: absolute; bottom: 0; left: 0; width: 100%; padding: 20px 0 30px 0; background: linear-gradient(to top, rgba(0,0,0,0.9), transparent); z-index: 20; display: none; justify-content: center; align-items: center; gap: 15px; opacity: 0; transition: opacity 0.5s; }
|
.p-top {
|
||||||
body:hover #controls { opacity: 1; }
|
height: calc(30px * var(--text-scale)); flex-shrink: 0;
|
||||||
input[type=range] { -webkit-appearance: none; width: 300px; height: 6px; background: rgba(255,255,255,0.3); border-radius: 3px; outline: none; }
|
display: flex; justify-content: space-between; align-items: center; padding: 0 15px;
|
||||||
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #e44c65; cursor: pointer; }
|
font-size: calc(0.8em * var(--text-scale)); color: #777; border-bottom: 1px solid #eee;
|
||||||
|
overflow: hidden; white-space: nowrap; transition: height 0.3s ease, font-size 0.3s ease;
|
||||||
|
}
|
||||||
|
.p-top-left { display: flex; gap: 10px; }
|
||||||
|
|
||||||
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css');
|
.p-photo-area {
|
||||||
|
position: relative; flex: 1; background: #000; margin: 10px 10px 0 10px;
|
||||||
|
overflow: hidden; display: flex; justify-content: center; align-items: center;
|
||||||
|
}
|
||||||
|
.card-img, .card-vid { width: 100%; height: 100%; object-fit: contain; position: absolute; top:0; left:0; }
|
||||||
|
.card-vid { display: none; }
|
||||||
|
|
||||||
|
.p-bottom {
|
||||||
|
height: calc(70px * var(--text-scale)); flex-shrink: 0;
|
||||||
|
padding: 10px 20px; display: flex; flex-direction: column; justify-content: center;
|
||||||
|
font-family: 'Nanum Pen Script', cursive;
|
||||||
|
overflow: hidden; white-space: nowrap; transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
.p-date {
|
||||||
|
font-size: calc(1.8em * var(--text-scale)); font-weight: bold; line-height: 1.2;
|
||||||
|
margin-bottom: calc(15px * var(--text-scale)); transition: font-size 0.3s, margin-bottom 0.3s; min-height: 1.2em;
|
||||||
|
}
|
||||||
|
.p-location {
|
||||||
|
font-size: calc(1.3em * var(--text-scale)); color: #555; display: flex; align-items: center; gap: 5px;
|
||||||
|
transition: font-size 0.3s; min-height: 1.2em; line-height: 1.2;
|
||||||
|
}
|
||||||
|
.typing-cursor::after { content: '|'; display: inline-block; margin-left: 2px; color: #e44c65; animation: blink 0.8s step-start infinite; font-weight: normal; }
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
.p-keywords { font-size: calc(1.0em * var(--text-scale)); color: #e44c65; margin-left: 10px; transition: font-size 0.3s; }
|
||||||
|
|
||||||
|
#exif-full-overlay {
|
||||||
|
position: absolute; bottom: 90px; right: 20px; z-index: 25;
|
||||||
|
background: rgba(0, 0, 0, 0.75); padding: 15px; border-radius: 10px;
|
||||||
|
width: 280px; max-height: 60vh; overflow-y: auto;
|
||||||
|
font-size: 0.85em; color: #ccc; backdrop-filter: blur(5px);
|
||||||
|
display: none; text-align: right; box-shadow: 0 5px 20px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
#exif-full-overlay::-webkit-scrollbar { width: 6px; }
|
||||||
|
#exif-full-overlay::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
|
||||||
|
.exif-item { border-bottom: 1px dashed #444; padding: 4px 0; display: flex; justify-content: space-between; }
|
||||||
|
.exif-key { color: #888; font-weight: bold; margin-right: 10px; }
|
||||||
|
.exif-val { color: #fff; word-break: break-all; text-align: right; }
|
||||||
|
|
||||||
|
/* [수정] 컨트롤 바: 평소엔 숨김 */
|
||||||
|
#controls-bar {
|
||||||
|
position: absolute; bottom: 30px;
|
||||||
|
display: flex; align-items: center; gap: 15px;
|
||||||
|
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(5px);
|
||||||
|
padding: 10px 25px; border-radius: 50px;
|
||||||
|
z-index: 50;
|
||||||
|
/* 애니메이션 설정 */
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||||
|
pointer-events: none; /* 숨겨진 상태에선 클릭 방지 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [NEW] 활성화 클래스 */
|
||||||
|
#controls-bar.controls-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto; /* 보이게 되면 클릭 허용 */
|
||||||
|
}
|
||||||
|
|
||||||
|
#palette-panel { position: absolute; bottom: 100px; left: 50%; transform: translateX(-50%); background: rgba(255,255,255,0.9); padding: 10px; border-radius: 10px; display: none; grid-template-columns: repeat(6, 1fr); gap: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 30; }
|
||||||
|
.color-chip { width: 30px; height: 30px; border-radius: 50%; cursor: pointer; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.2); position: relative; }
|
||||||
|
.color-chip:hover { transform: scale(1.1); }
|
||||||
|
.color-chip.auto-chip { background: linear-gradient(135deg, red, orange, yellow, green, blue, indigo, violet); }
|
||||||
|
.color-chip.auto-chip::after { content: 'A'; position: absolute; top:50%; left:50%; transform:translate(-50%, -50%); color: white; font-weight: bold; font-family: sans-serif; text-shadow: 0 0 2px black; }
|
||||||
|
|
||||||
|
.ctrl-btn { background: none; border: none; color: white; font-size: 1.2em; cursor: pointer; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: 0.2s; }
|
||||||
|
.ctrl-btn:hover { background: rgba(255,255,255,0.2); color: #e44c65; }
|
||||||
|
.ctrl-btn.active { color: #e44c65; background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
.divider { width: 1px; height: 20px; background: #555; }
|
||||||
|
|
||||||
|
.slider-group { display: flex; align-items: center; gap: 8px; color: #ccc; font-size: 0.9em; }
|
||||||
|
.slider-label { min-width: 25px; text-align: right; }
|
||||||
|
input[type=range] { -webkit-appearance: none; width: 80px; height: 4px; background: rgba(255,255,255,0.3); border-radius: 2px; outline: none; }
|
||||||
|
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #e44c65; cursor: pointer; }
|
||||||
|
|
||||||
|
#ad-wrapper { flex: 0 0 100px; width: 100%; background: #111; display: flex; justify-content: center; align-items: center; z-index: 100; border-top: 1px solid #333; }
|
||||||
|
#login-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #111; z-index: 999; display: flex; justify-content: center; align-items: center; }
|
||||||
|
#login-form { display: flex; flex-direction: column; gap: 15px; padding: 40px; background: #222; border-radius: 12px; width: 320px; }
|
||||||
|
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; }
|
||||||
|
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; }
|
||||||
|
.exit-btn { position: absolute; top: 20px; right: 20px; z-index: 1000; color: rgba(255,255,255,0.7); text-decoration: none; background: rgba(0,0,0,0.5); padding: 8px 15px; border-radius: 30px; font-size: 0.9em; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="bg-layer"></div>
|
||||||
<a href="/" class="exit-btn">✕ Close</a>
|
<a href="/" class="exit-btn">✕ Close</a>
|
||||||
<div id="login-container">
|
|
||||||
|
<div id="main-wrapper">
|
||||||
|
<div id="card-stage">
|
||||||
|
<div id="polaroid-card-1" class="polaroid-card">
|
||||||
|
<div class="p-top">
|
||||||
|
<div class="p-top-left"><span class="p-camera"><i class="fas fa-camera"></i> -</span><span class="p-zoom"><i class="fas fa-search"></i> -</span></div>
|
||||||
|
<div class="p-top-right"><span class="p-coords" style="font-family: monospace;"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="p-photo-area">
|
||||||
|
<img class="card-img" src="">
|
||||||
|
<video class="card-vid" loop muted playsinline></video>
|
||||||
|
</div>
|
||||||
|
<div class="p-bottom">
|
||||||
|
<div class="p-date"></div>
|
||||||
|
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span><span class="p-tags p-keywords"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="polaroid-card-2" class="polaroid-card">
|
||||||
|
<div class="p-top">
|
||||||
|
<div class="p-top-left"><span class="p-camera"><i class="fas fa-camera"></i> -</span><span class="p-zoom"><i class="fas fa-search"></i> -</span></div>
|
||||||
|
<div class="p-top-right"><span class="p-coords" style="font-family: monospace;"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="p-photo-area">
|
||||||
|
<img class="card-img" src="">
|
||||||
|
<video class="card-vid" loop muted playsinline></video>
|
||||||
|
</div>
|
||||||
|
<div class="p-bottom">
|
||||||
|
<div class="p-date"></div>
|
||||||
|
<div class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span><span class="p-tags p-keywords"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="palette-panel"></div>
|
||||||
|
<div id="exif-full-overlay"><div style="color:#e44c65; font-weight:bold; margin-bottom:10px; border-bottom:1px solid #555; padding-bottom:5px;">EXIF INFO</div><div id="exif-list">Loading...</div></div>
|
||||||
|
|
||||||
|
<div id="controls-bar">
|
||||||
|
<button class="ctrl-btn" onclick="prevPhoto()" title="이전"><i class="fas fa-step-backward"></i></button>
|
||||||
|
<button class="ctrl-btn" onclick="togglePause()" id="btn-play" title="재생/정지"><i class="fas fa-pause"></i></button>
|
||||||
|
<button class="ctrl-btn" onclick="nextPhotoManual()" title="다음"><i class="fas fa-step-forward"></i></button>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="slider-group" title="사진 대기 시간">
|
||||||
|
<i class="fas fa-stopwatch"></i><input type="range" min="3" max="60" value="10" step="1" oninput="updateSpeed(this.value)"><span class="slider-label"><span id="speed-display">10</span>s</span>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="slider-group" title="전환 애니메이션 시간">
|
||||||
|
<i class="fas fa-magic"></i><input type="range" min="1" max="10" value="1.5" step="0.5" oninput="updateAnimSpeed(this.value)"><span class="slider-label"><span id="anim-display">1.5</span>s</span>
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="slider-group" title="텍스트 프레임 크기">
|
||||||
|
<i class="fas fa-text-height"></i><input type="range" min="0.6" max="1.4" value="1.0" step="0.1" oninput="updateTextScale(this.value)">
|
||||||
|
</div>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<button class="ctrl-btn" id="btn-info" onclick="toggleInfo()" title="상세 정보"><i class="fas fa-info-circle"></i></button>
|
||||||
|
<button class="ctrl-btn" onclick="togglePalette()" title="배경 변경"><i class="fas fa-palette"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ad-wrapper">
|
||||||
|
<ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-9504446465764716" data-ad-slot="YOUR_AD_SLOT_ID"></ins>
|
||||||
|
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="login-overlay">
|
||||||
<div id="login-form">
|
<div id="login-form">
|
||||||
<h2 style="color:#e44c65; text-align:center; margin:0 0 20px 0;">Synology Login</h2>
|
<h2 style="color:#e44c65; text-align:center; margin:0 0 20px 0;">Synology Login</h2>
|
||||||
<meta name="_csrf" th:content="${_csrf.token}"/>
|
<meta name="_csrf" th:content="${_csrf.token}"/>
|
||||||
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
|
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
|
||||||
<input type="text" id="nas-address" placeholder="NAS IP:Port (ex: 192.168.0.5:5000)">
|
<input type="text" id="nas-address" placeholder="NAS IP:Port">
|
||||||
<input type="text" id="username" placeholder="ID">
|
<input type="text" id="username" placeholder="ID">
|
||||||
<input type="password" id="password" placeholder="Password">
|
<input type="password" id="password" placeholder="Password">
|
||||||
<button onclick="loginAndStart()">Start Slideshow</button>
|
<button onclick="loginAndStart()">Start Slideshow</button>
|
||||||
@ -60,198 +215,450 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="photo-container"></div>
|
|
||||||
|
|
||||||
<div id="info-overlay">
|
|
||||||
<div id="info-date"></div>
|
|
||||||
<div id="info-location">
|
|
||||||
<i class="fas fa-map-marker-alt"></i>
|
|
||||||
<span id="info-loc-text"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="ad-container" style="display:none;">
|
|
||||||
<ins class="adsbygoogle" style="display:inline-block;width:728px;height:90px" data-ad-client="ca-pub-9504446465764716" data-ad-slot="YOUR_AD_SLOT_ID"></ins>
|
|
||||||
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="controls">
|
|
||||||
<span style="font-size: 0.9em; color: #ccc;">Speed: <span id="speed-display">5</span> sec</span>
|
|
||||||
<input type="range" id="speed-slider" min="3" max="30" value="5" step="1" oninput="updateSpeed(this.value)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let sid = '';
|
let sid = '', nasAddress = '';
|
||||||
let nasAddress = '';
|
const allPhotoItems = [];
|
||||||
const allPhotoIds = [];
|
const photoHistory = [];
|
||||||
|
let historyIndex = -1;
|
||||||
let slideshowTimeout;
|
let slideshowTimeout;
|
||||||
let currentSpeed = 5000;
|
let currentSpeed = 10000;
|
||||||
|
let isPaused = false;
|
||||||
|
let activeCardNum = 1;
|
||||||
|
const typingTimers = {
|
||||||
|
1: { date: null, addr: null },
|
||||||
|
2: { date: null, addr: null }
|
||||||
|
};
|
||||||
|
let isDynamicBg = true;
|
||||||
|
let showFullExif = false;
|
||||||
|
|
||||||
|
// [NEW] 컨트롤 바 숨김/표시 타이머 변수
|
||||||
|
let controlsHideTimer;
|
||||||
|
|
||||||
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
|
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
|
||||||
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
|
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
|
||||||
|
|
||||||
|
const bgOptions = [
|
||||||
|
"linear-gradient(to top, #09203f 0%, #537895 100%)",
|
||||||
|
"#222222", "#eeeeee", "linear-gradient(to bottom, #232526, #414345)",
|
||||||
|
"linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%)",
|
||||||
|
"linear-gradient(120deg, #f6d365 0%, #fda085 100%)",
|
||||||
|
"linear-gradient(to top, #30cfd0 0%, #330867 100%)",
|
||||||
|
"#556b2f", "#8b4513",
|
||||||
|
"url('https://www.transparenttextures.com/patterns/wood-pattern.png') #4a3b2a"
|
||||||
|
];
|
||||||
|
|
||||||
|
window.onload = () => {
|
||||||
|
initPalette();
|
||||||
|
initControlsAutoHider(); // [NEW] 자동 숨김 초기화
|
||||||
|
};
|
||||||
|
|
||||||
|
// [NEW] 컨트롤 바 자동 숨김 로직
|
||||||
|
function initControlsAutoHider() {
|
||||||
|
const controls = document.getElementById('controls-bar');
|
||||||
|
|
||||||
|
function showControls() {
|
||||||
|
controls.classList.add('controls-visible');
|
||||||
|
resetHideTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetHideTimer() {
|
||||||
|
clearTimeout(controlsHideTimer);
|
||||||
|
// 마우스가 컨트롤 바 위에 있거나 슬라이더 조작 중일 때는 숨기지 않음
|
||||||
|
if (controls.matches(':hover')) return;
|
||||||
|
|
||||||
|
controlsHideTimer = setTimeout(() => {
|
||||||
|
controls.classList.remove('controls-visible');
|
||||||
|
}, 3000); // 3초 뒤 숨김
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트 감지: 마우스 이동, 클릭, 터치
|
||||||
|
document.addEventListener('mousemove', showControls);
|
||||||
|
document.addEventListener('click', showControls);
|
||||||
|
document.addEventListener('touchstart', showControls);
|
||||||
|
|
||||||
|
// 컨트롤 바에서 마우스가 나갈 때 타이머 재시작
|
||||||
|
controls.addEventListener('mouseleave', resetHideTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPalette() {
|
||||||
|
const panel = document.getElementById('palette-panel');
|
||||||
|
const autoChip = document.createElement('div');
|
||||||
|
autoChip.className = 'color-chip auto-chip';
|
||||||
|
autoChip.onclick = () => {
|
||||||
|
isDynamicBg = true;
|
||||||
|
panel.style.display = 'none';
|
||||||
|
const currentImg = document.querySelector(`#polaroid-card-${activeCardNum} .card-img`);
|
||||||
|
if(currentImg && currentImg.src && currentImg.style.display !== 'none') updateBgFromImage(currentImg);
|
||||||
|
};
|
||||||
|
panel.appendChild(autoChip);
|
||||||
|
|
||||||
|
bgOptions.forEach(color => {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'color-chip';
|
||||||
|
chip.style.background = color;
|
||||||
|
chip.onclick = () => {
|
||||||
|
isDynamicBg = false;
|
||||||
|
document.getElementById('bg-layer').style.background = color;
|
||||||
|
panel.style.display = 'none';
|
||||||
|
};
|
||||||
|
panel.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePalette() {
|
||||||
|
const panel = document.getElementById('palette-panel');
|
||||||
|
panel.style.display = (panel.style.display === 'grid') ? 'none' : 'grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleInfo() {
|
||||||
|
showFullExif = !showFullExif;
|
||||||
|
const overlay = document.getElementById('exif-full-overlay');
|
||||||
|
const btn = document.getElementById('btn-info');
|
||||||
|
if(showFullExif) { overlay.style.display = 'block'; btn.classList.add('active'); }
|
||||||
|
else { overlay.style.display = 'none'; btn.classList.remove('active'); }
|
||||||
|
}
|
||||||
|
|
||||||
function showMessage(msg) { document.getElementById('message').innerText = msg; }
|
function showMessage(msg) { document.getElementById('message').innerText = msg; }
|
||||||
function updateSpeed(val) { currentSpeed = val * 1000; document.getElementById('speed-display').innerText = val; }
|
|
||||||
|
// [수정] 슬라이더 조작 시 타이머 리셋 (숨김 방지)
|
||||||
|
function updateSpeed(val) {
|
||||||
|
currentSpeed = val * 1000;
|
||||||
|
document.getElementById('speed-display').innerText = val;
|
||||||
|
clearTimeout(controlsHideTimer); // 조작 중엔 숨기지 않음
|
||||||
|
}
|
||||||
|
function updateAnimSpeed(val) {
|
||||||
|
document.documentElement.style.setProperty('--anim-duration', val + 's');
|
||||||
|
document.getElementById('anim-display').innerText = val;
|
||||||
|
clearTimeout(controlsHideTimer);
|
||||||
|
}
|
||||||
|
function updateTextScale(val) {
|
||||||
|
document.documentElement.style.setProperty('--text-scale', val);
|
||||||
|
clearTimeout(controlsHideTimer);
|
||||||
|
}
|
||||||
|
|
||||||
async function loginAndStart() {
|
async function loginAndStart() {
|
||||||
nasAddress = document.getElementById('nas-address').value.trim();
|
nasAddress = document.getElementById('nas-address').value.trim();
|
||||||
const username = document.getElementById('username').value.trim();
|
const username = document.getElementById('username').value.trim();
|
||||||
const password = document.getElementById('password').value;
|
const password = document.getElementById('password').value;
|
||||||
if (!nasAddress || !username || !password) { showMessage('모든 정보를 입력해주세요.'); return; }
|
if (!nasAddress || !username || !password) { showMessage('Input all fields'); return; }
|
||||||
showMessage('로그인 중...');
|
showMessage('Logging in...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/synology/login', {
|
const response = await fetch('/api/synology/login', { method: 'POST', headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, body: JSON.stringify({ nasAddress, username, password }) });
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
|
|
||||||
body: JSON.stringify({ nasAddress, username, password })
|
|
||||||
});
|
|
||||||
const loginData = await response.json();
|
const loginData = await response.json();
|
||||||
if (loginData.success && loginData.data.sid) {
|
if (loginData.success && loginData.data.sid) {
|
||||||
sid = loginData.data.sid;
|
sid = loginData.data.sid; showMessage('Fetching list...'); await fetchAllPhotos(0);
|
||||||
showMessage('목록 가져오는 중...');
|
} else { showMessage('Login Failed'); }
|
||||||
await fetchAllPhotos(0);
|
} catch (error) { console.error(error); showMessage('Network Error'); }
|
||||||
} else { showMessage('로그인 실패 (정보 확인 필요)'); }
|
|
||||||
} catch (error) { console.error(error); showMessage('서버 통신 오류'); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAllPhotos(offset) {
|
async function fetchAllPhotos(offset) {
|
||||||
const limit = 5000;
|
const limit = 5000;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/synology/list', {
|
const response = await fetch('/api/synology/list', { method: 'POST', headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, body: JSON.stringify({ nasAddress, sid, offset, limit }) });
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
|
|
||||||
body: JSON.stringify({ nasAddress, sid, offset, limit })
|
|
||||||
});
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
data.data.list.forEach(item => allPhotoIds.push(item.id));
|
data.data.list.forEach(item => {
|
||||||
|
if (item.type === 'folder') return;
|
||||||
|
allPhotoItems.push(item);
|
||||||
|
});
|
||||||
const total = data.data.total;
|
const total = data.data.total;
|
||||||
if (offset + limit < total) {
|
if (offset + limit < total) { showMessage(`${allPhotoItems.length}장 로딩 중...`); await fetchAllPhotos(offset + limit); }
|
||||||
showMessage(`${allPhotoIds.length} / ${total} 장 로딩...`);
|
else { document.getElementById('login-overlay').style.display = 'none'; loadNextPhoto(); }
|
||||||
await fetchAllPhotos(offset + limit);
|
}
|
||||||
} else {
|
} catch (error) { showMessage('Fetch Failed'); }
|
||||||
startSlideshow();
|
}
|
||||||
|
|
||||||
|
function togglePause() {
|
||||||
|
isPaused = !isPaused;
|
||||||
|
const btn = document.getElementById('btn-play');
|
||||||
|
if (isPaused) { clearTimeout(slideshowTimeout); btn.innerHTML = '<i class="fas fa-play"></i>'; }
|
||||||
|
else { loadNextPhoto(false); btn.innerHTML = '<i class="fas fa-pause"></i>'; }
|
||||||
|
}
|
||||||
|
function prevPhoto() { if (historyIndex > 0) { historyIndex--; isPaused = true; updatePlayButton(); displayPhoto(photoHistory[historyIndex]); } }
|
||||||
|
function nextPhotoManual() { isPaused = true; updatePlayButton(); loadNextPhoto(true); }
|
||||||
|
function updatePlayButton() { document.getElementById('btn-play').innerHTML = isPaused ? '<i class="fas fa-play"></i>' : '<i class="fas fa-pause"></i>'; }
|
||||||
|
|
||||||
|
function loadNextPhoto(forceNew = false) {
|
||||||
|
clearTimeout(slideshowTimeout);
|
||||||
|
if (allPhotoItems.length === 0) return;
|
||||||
|
let nextItem;
|
||||||
|
if (!forceNew && historyIndex < photoHistory.length - 1) { historyIndex++; nextItem = photoHistory[historyIndex]; }
|
||||||
|
else { nextItem = allPhotoItems[Math.floor(Math.random() * allPhotoItems.length)]; photoHistory.push(nextItem); historyIndex = photoHistory.length - 1; }
|
||||||
|
if (photoHistory.length > 100) { photoHistory.shift(); historyIndex--; }
|
||||||
|
displayPhoto(nextItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBgFromImage(img) {
|
||||||
|
if (!isDynamicBg) return;
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d');
|
||||||
|
const size = 100; canvas.width = size; canvas.height = size;
|
||||||
|
ctx.drawImage(img, 0, 0, size, size);
|
||||||
|
const patterns = [
|
||||||
|
{ css: 'to top', sx: 50, sy: 100, ex: 50, ey: 0 },
|
||||||
|
{ css: '45deg', sx: 0, sy: 100, ex: 100, ey: 0 },
|
||||||
|
{ css: 'to right', sx: 0, sy: 50, ex: 100, ey: 50 },
|
||||||
|
{ css: '135deg', sx: 0, sy: 0, ex: 100, ey: 100},
|
||||||
|
{ css: 'to bottom', sx: 50, sy: 0, ex: 50, ey: 100},
|
||||||
|
{ css: '225deg', sx: 100, sy: 0, ex: 0, ey: 100},
|
||||||
|
{ css: 'to left', sx: 100, sy: 50, ex: 0, ey: 50 },
|
||||||
|
{ css: '315deg', sx: 100, sy: 100, ex: 0, ey: 0 }
|
||||||
|
];
|
||||||
|
const pat = patterns[Math.floor(Math.random() * patterns.length)];
|
||||||
|
const colors = []; const steps = 5;
|
||||||
|
for(let i=0; i<steps; i++) {
|
||||||
|
const t = i / (steps - 1);
|
||||||
|
let x = Math.floor(pat.sx + (pat.ex - pat.sx) * t); let y = Math.floor(pat.sy + (pat.ey - pat.sy) * t);
|
||||||
|
x = Math.max(0, Math.min(size-1, x)); y = Math.max(0, Math.min(size-1, y));
|
||||||
|
const p = ctx.getImageData(x, y, 1, 1).data; colors.push(`rgba(${p[0]}, ${p[1]}, ${p[2]}, 1)`);
|
||||||
|
}
|
||||||
|
document.getElementById('bg-layer').style.background = `linear-gradient(${pat.css}, ${colors.join(', ')})`;
|
||||||
|
} catch(e) { console.warn("Bg Error", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoItem(item) {
|
||||||
|
if (item.type) return item.type === 'video' || item.type === 'live';
|
||||||
|
if (item.filename) {
|
||||||
|
const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.3gp', '.m2ts'];
|
||||||
|
const ext = item.filename.substring(item.filename.lastIndexOf('.')).toLowerCase();
|
||||||
|
return videoExts.includes(ext);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRawItem(item) {
|
||||||
|
if (!item.filename) return false;
|
||||||
|
const rawExts = ['.cr2', '.nef', '.arw', '.orf', '.rw2', '.dng', '.raf'];
|
||||||
|
const heicExts = ['.heic', '.heif'];
|
||||||
|
const ext = item.filename.substring(item.filename.lastIndexOf('.')).toLowerCase();
|
||||||
|
return rawExts.includes(ext) || heicExts.includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPhoto(item) {
|
||||||
|
let cacheKey = "";
|
||||||
|
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.cache_key) {
|
||||||
|
cacheKey = item.additional.thumbnail.cache_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseApiUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${item.id}&cacheKey=${cacheKey}`;
|
||||||
|
|
||||||
|
const isVideo = isVideoItem(item);
|
||||||
|
const isRaw = isRawItem(item);
|
||||||
|
|
||||||
|
const requestMode = isRaw ? '&mode=thumbnail' : '&mode=download';
|
||||||
|
const finalUrl = baseApiUrl + requestMode;
|
||||||
|
|
||||||
|
const nextCardNum = (activeCardNum === 1) ? 2 : 1;
|
||||||
|
const nextCard = document.getElementById(`polaroid-card-${nextCardNum}`);
|
||||||
|
const currentCard = document.getElementById(`polaroid-card-${activeCardNum}`);
|
||||||
|
const nextImgEl = nextCard.querySelector('.card-img');
|
||||||
|
const nextVidEl = nextCard.querySelector('.card-vid');
|
||||||
|
|
||||||
|
const tempImg = new Image();
|
||||||
|
tempImg.crossOrigin = "Anonymous";
|
||||||
|
tempImg.src = finalUrl;
|
||||||
|
|
||||||
|
tempImg.onload = function() {
|
||||||
|
const dims = calculateOptimalSize(tempImg.naturalWidth, tempImg.naturalHeight);
|
||||||
|
nextCard.style.width = dims.cardWidth + 'px';
|
||||||
|
nextCard.style.height = dims.cardHeight + 'px';
|
||||||
|
const randomAngle = (Math.random() * 10) - 5;
|
||||||
|
nextCard.style.transform = `rotate(${randomAngle}deg) scale(1.05)`;
|
||||||
|
|
||||||
|
resetCardInfo(nextCard, nextCardNum);
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
nextImgEl.style.display = 'none';
|
||||||
|
nextVidEl.style.display = 'block';
|
||||||
|
nextVidEl.src = finalUrl;
|
||||||
|
nextVidEl.load();
|
||||||
|
nextVidEl.play().catch(e => console.log("Auto-play blocked", e));
|
||||||
|
|
||||||
|
let dateText = (item.type === 'live') ? "Live Photo" : "Video";
|
||||||
|
if (item.takenTime) dateText = new Date(item.takenTime * 1000).toLocaleDateString();
|
||||||
|
typeText(nextCard.querySelector('.p-date'), dateText, nextCardNum, 'date', null);
|
||||||
|
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>${item.type === 'live' ? 'Live Photo' : 'Video File'}<br>${item.filename || ""}</div>`;
|
||||||
|
|
||||||
|
updateBgFromImage(tempImg);
|
||||||
|
} else {
|
||||||
|
nextVidEl.style.display = 'none';
|
||||||
|
nextVidEl.pause();
|
||||||
|
nextVidEl.removeAttribute('src');
|
||||||
|
nextImgEl.style.display = 'block';
|
||||||
|
nextImgEl.src = tempImg.src;
|
||||||
|
|
||||||
|
let nextFullExifHtml = "<div style='text-align:center'>No Data</div>";
|
||||||
|
EXIF.getData(tempImg, function() {
|
||||||
|
bindExifData(this, nextCard, nextCardNum);
|
||||||
|
nextFullExifHtml = generateFullExifHtml(this);
|
||||||
|
if (isRaw) {
|
||||||
|
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>RAW/HEIC Preview<br>${item.filename}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateBgFromImage(tempImg);
|
||||||
|
|
||||||
|
if (!isRaw) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.getElementById('exif-list').innerHTML = nextFullExifHtml;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) { showMessage('목록 불러오기 실패'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSlideshow() {
|
requestAnimationFrame(() => {
|
||||||
document.getElementById('login-container').style.display = 'none';
|
nextCard.classList.add('active');
|
||||||
document.getElementById('controls').style.display = 'flex';
|
currentCard.classList.remove('active');
|
||||||
document.getElementById('ad-container').style.display = 'block';
|
const prevVid = currentCard.querySelector('.card-vid');
|
||||||
document.getElementById('info-overlay').style.opacity = 1;
|
if(prevVid) { prevVid.pause(); prevVid.removeAttribute('src'); prevVid.load(); }
|
||||||
loadNextPhoto();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadNextPhoto() {
|
|
||||||
if (allPhotoIds.length === 0) return;
|
|
||||||
const photoId = allPhotoIds[Math.floor(Math.random() * allPhotoIds.length)];
|
|
||||||
const imgUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${photoId}`;
|
|
||||||
|
|
||||||
// [중요] 오버레이 초기화 (이전 정보 지우기)
|
|
||||||
updateOverlayText(null, null);
|
|
||||||
|
|
||||||
const newImg = document.createElement('img');
|
|
||||||
// crossOrigin 설정은 Proxy를 타기 때문에 필수는 아니지만 안전장치로 추가
|
|
||||||
newImg.crossOrigin = "Anonymous";
|
|
||||||
newImg.src = imgUrl;
|
|
||||||
newImg.className = 'slide-image';
|
|
||||||
|
|
||||||
newImg.onload = function() {
|
|
||||||
const container = document.getElementById('photo-container');
|
|
||||||
container.appendChild(newImg);
|
|
||||||
|
|
||||||
// [핵심] EXIF 라이브러리를 사용해 이미지에서 직접 정보 추출
|
|
||||||
EXIF.getData(newImg, function() {
|
|
||||||
// 1. 촬영 날짜 추출
|
|
||||||
const dateTaken = EXIF.getTag(this, "DateTimeOriginal");
|
|
||||||
let dateStr = null;
|
|
||||||
|
|
||||||
if (dateTaken) {
|
|
||||||
// EXIF 날짜 포맷은 "YYYY:MM:DD HH:MM:SS"
|
|
||||||
const parts = dateTaken.split(/[: ]/);
|
|
||||||
if(parts.length >= 3) {
|
|
||||||
// "2023. 12. 25." 형태로 변환
|
|
||||||
dateStr = `${parts[0]}. ${parts[1]}. ${parts[2]}.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. GPS 추출 및 주소 변환
|
|
||||||
const lat = EXIF.getTag(this, "GPSLatitude");
|
|
||||||
const lon = EXIF.getTag(this, "GPSLongitude");
|
|
||||||
const latRef = EXIF.getTag(this, "GPSLatitudeRef");
|
|
||||||
const lonRef = EXIF.getTag(this, "GPSLongitudeRef");
|
|
||||||
|
|
||||||
if (lat && lon) {
|
|
||||||
const decimalLat = convertDMSToDD(lat, latRef);
|
|
||||||
const decimalLon = convertDMSToDD(lon, lonRef);
|
|
||||||
|
|
||||||
updateOverlayText(dateStr, "위치 확인 중...");
|
|
||||||
|
|
||||||
// [수정] 외부 API 대신 내 백엔드 프록시로 요청
|
|
||||||
fetch(`/api/synology/geocode?lat=${decimalLat}&lon=${decimalLon}`, {
|
|
||||||
headers: { [csrfHeader]: csrfToken } // CSRF 토큰 추가 (필요시)
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
let addr = "";
|
|
||||||
if(data.address) {
|
|
||||||
const city = data.address.city || data.address.province || "";
|
|
||||||
const district = data.address.borough || data.address.district || data.address.suburb || "";
|
|
||||||
const country = data.address.country || "";
|
|
||||||
|
|
||||||
if (country !== "대한민국") addr += country + " ";
|
|
||||||
addr += `${city} ${district}`;
|
|
||||||
}
|
|
||||||
updateOverlayText(dateStr, addr.trim() || "Unknown Location");
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
updateOverlayText(dateStr, "위치 정보 없음");
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateOverlayText(dateStr, null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
requestAnimationFrame(() => { newImg.classList.add('active'); });
|
activeCardNum = nextCardNum;
|
||||||
|
|
||||||
const oldImages = container.querySelectorAll('img');
|
if (!isPaused) {
|
||||||
if (oldImages.length > 1) {
|
clearTimeout(slideshowTimeout);
|
||||||
setTimeout(() => {
|
slideshowTimeout = setTimeout(() => loadNextPhoto(false), currentSpeed);
|
||||||
for(let i = 0; i < oldImages.length - 1; i++) { container.removeChild(oldImages[i]); }
|
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
clearTimeout(slideshowTimeout);
|
|
||||||
slideshowTimeout = setTimeout(loadNextPhoto, currentSpeed);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
newImg.onerror = () => { setTimeout(loadNextPhoto, 1000); };
|
tempImg.onerror = () => {
|
||||||
|
console.error("로드 실패:", item);
|
||||||
|
if(!isPaused) setTimeout(() => loadNextPhoto(false), 1000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFullExifHtml(img) {
|
||||||
|
const allTags = EXIF.getAllTags(img);
|
||||||
|
let html = "";
|
||||||
|
for (let key in allTags) {
|
||||||
|
if (key === 'thumbnail' || key.startsWith('Thumbnail')) continue;
|
||||||
|
let val = allTags[key];
|
||||||
|
if (typeof val === 'object') {
|
||||||
|
if (val instanceof Number) val = val.valueOf(); else val = JSON.stringify(val);
|
||||||
|
}
|
||||||
|
if (String(val).length > 40) val = String(val).substring(0, 40) + "...";
|
||||||
|
html += `<div class="exif-item"><span class="exif-key">${key}</span><span class="exif-val">${val}</span></div>`;
|
||||||
|
}
|
||||||
|
return html || "<div style='text-align:center'>No EXIF Data</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOptimalSize(imgW, imgH) {
|
||||||
|
const maxW = window.innerWidth * 0.95;
|
||||||
|
const maxH = window.innerHeight * 0.82;
|
||||||
|
const scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--text-scale')) || 1.0;
|
||||||
|
const chromeH = (30 * scale) + (70 * scale) + 20;
|
||||||
|
const maxImgH = maxH - chromeH;
|
||||||
|
|
||||||
|
let scaleFactor = maxImgH / imgH;
|
||||||
|
let targetImgW = imgW * scaleFactor;
|
||||||
|
let targetImgH = maxImgH;
|
||||||
|
|
||||||
|
if (targetImgW > maxW) {
|
||||||
|
scaleFactor = maxW / targetImgW;
|
||||||
|
targetImgW = targetImgW * scaleFactor;
|
||||||
|
targetImgH = targetImgH * scaleFactor;
|
||||||
|
}
|
||||||
|
if (targetImgW < 320) targetImgW = 320;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardWidth: Math.round(targetImgW),
|
||||||
|
cardHeight: Math.round(targetImgH + chromeH)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCardInfo(card, cardNum) {
|
||||||
|
card.querySelector('.p-camera').innerHTML = '<i class="fas fa-camera"></i> -';
|
||||||
|
card.querySelector('.p-zoom').innerHTML = '<i class="fas fa-search"></i> -';
|
||||||
|
card.querySelector('.p-coords').innerText = '';
|
||||||
|
|
||||||
|
const dateEl = card.querySelector('.p-date');
|
||||||
|
dateEl.innerText = ''; dateEl.classList.remove('typing-cursor');
|
||||||
|
|
||||||
|
const addrEl = card.querySelector('.p-address');
|
||||||
|
addrEl.innerText = ''; addrEl.classList.remove('typing-cursor');
|
||||||
|
|
||||||
|
card.querySelector('.p-tags').innerText = '';
|
||||||
|
|
||||||
|
if(typingTimers[cardNum].date) clearTimeout(typingTimers[cardNum].date);
|
||||||
|
if(typingTimers[cardNum].addr) clearTimeout(typingTimers[cardNum].addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeText(element, text, cardNum, type, onComplete) {
|
||||||
|
element.innerText = "";
|
||||||
|
element.classList.add('typing-cursor');
|
||||||
|
let i = 0;
|
||||||
|
function typeFunc() {
|
||||||
|
if (i < text.length) {
|
||||||
|
element.innerText += text.charAt(i);
|
||||||
|
i++;
|
||||||
|
const speed = Math.floor(Math.random() * 30) + 20;
|
||||||
|
typingTimers[cardNum][type] = setTimeout(typeFunc, speed);
|
||||||
|
} else {
|
||||||
|
element.classList.remove('typing-cursor');
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typeFunc();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindExifData(img, card, cardNum) {
|
||||||
|
const make = EXIF.getTag(img, "Make"); const model = EXIF.getTag(img, "Model");
|
||||||
|
let camText = model ? model : (make ? make : "Unknown Camera");
|
||||||
|
if(camText.length > 20) camText = camText.substring(0, 18) + "..";
|
||||||
|
card.querySelector('.p-camera').innerHTML = `<i class="fas fa-camera"></i> ${camText}`;
|
||||||
|
|
||||||
|
const focal = EXIF.getTag(img, "FocalLength");
|
||||||
|
card.querySelector('.p-zoom').innerHTML = `<i class="fas fa-search"></i> ${focal ? Math.round(focal)+'mm' : "-"}`;
|
||||||
|
|
||||||
|
const dateTaken = EXIF.getTag(img, "DateTimeOriginal");
|
||||||
|
let dateStr = "";
|
||||||
|
if (dateTaken) {
|
||||||
|
const parts = dateTaken.split(/[: ]/);
|
||||||
|
if(parts.length >= 3) dateStr = `${parts[0]}. ${parts[1]}. ${parts[2]}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = EXIF.getTag(img, "GPSLatitude"); const lon = EXIF.getTag(img, "GPSLongitude");
|
||||||
|
const addrEl = card.querySelector('.p-address');
|
||||||
|
const dateEl = card.querySelector('.p-date');
|
||||||
|
|
||||||
|
let addrPromise = Promise.resolve("No GPS Info");
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
const latRef = EXIF.getTag(img, "GPSLatitudeRef"); const lonRef = EXIF.getTag(img, "GPSLongitudeRef");
|
||||||
|
const decLat = convertDMSToDD(lat, latRef); const decLon = convertDMSToDD(lon, lonRef);
|
||||||
|
card.querySelector('.p-coords').innerText = `${decLat.toFixed(4)}, ${decLon.toFixed(4)}`;
|
||||||
|
|
||||||
|
addrPromise = fetch(`/api/synology/geocode?lat=${decLat}&lon=${decLon}`, { headers: { [csrfHeader]: csrfToken } })
|
||||||
|
.then(res => res.ok ? res.json() : {})
|
||||||
|
.then(data => {
|
||||||
|
if(data.address) {
|
||||||
|
const city = data.address.city || data.address.province || "";
|
||||||
|
const dist = data.address.borough || data.address.district || "";
|
||||||
|
const country = data.address.country || "";
|
||||||
|
return (country !== "대한민국" ? country + " " : "") + `${city} ${dist}`;
|
||||||
|
}
|
||||||
|
return "Unknown Location";
|
||||||
|
})
|
||||||
|
.catch(() => "Location Error");
|
||||||
|
} else {
|
||||||
|
card.querySelector('.p-coords').innerText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
typeText(dateEl, dateStr, cardNum, 'date', () => {
|
||||||
|
addrPromise.then(addrText => {
|
||||||
|
typeText(addrEl, addrText, cardNum, 'addr', null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const desc = EXIF.getTag(img, "ImageDescription");
|
||||||
|
card.querySelector('.p-tags').innerText = (desc && desc.trim() !== "") ? `#${desc}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// [유틸] GPS 도분초(DMS) -> 십진수(DD) 변환 함수
|
|
||||||
function convertDMSToDD(coord, ref) {
|
function convertDMSToDD(coord, ref) {
|
||||||
let dd = coord[0] + (coord[1] / 60) + (coord[2] / 3600);
|
let dd = coord[0] + (coord[1] / 60) + (coord[2] / 3600);
|
||||||
if (ref === "S" || ref === "W") {
|
if (ref === "S" || ref === "W") dd = dd * -1;
|
||||||
dd = dd * -1;
|
|
||||||
}
|
|
||||||
return dd;
|
return dd;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOverlayText(date, location) {
|
|
||||||
const dateDiv = document.getElementById('info-date');
|
|
||||||
const locDiv = document.getElementById('info-location');
|
|
||||||
const locText = document.getElementById('info-loc-text');
|
|
||||||
|
|
||||||
if (date) { dateDiv.innerText = date; dateDiv.style.display = 'block'; }
|
|
||||||
else if (date === null) { dateDiv.style.display = 'none'; }
|
|
||||||
// date가 undefined가 아니라 null로 명시적으로 오면 끔, 기존 값 유지하고 싶으면 아예 호출 안함
|
|
||||||
|
|
||||||
if (location && location.trim() !== "") { locText.innerText = location; locDiv.style.display = 'flex'; }
|
|
||||||
else if (location === null) { locDiv.style.display = 'none'; }
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user