This commit is contained in:
lunaticbum 2025-12-30 17:35:29 +09:00
parent b9fe935e98
commit 84ba6a02aa
3 changed files with 727 additions and 303 deletions

View File

@ -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()
} }

View File

@ -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()
} }
} }

View File

@ -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>