...
This commit is contained in:
parent
b9fe935e98
commit
84ba6a02aa
@ -126,8 +126,29 @@ class SecurityConfig(
|
||||
// handling.authenticationEntryPoint(jwtAuthenticationEntryPoint())
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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 org.slf4j.LoggerFactory
|
||||
import org.springframework.core.io.buffer.DataBuffer
|
||||
import org.springframework.core.io.buffer.DataBufferUtils
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector
|
||||
import org.springframework.web.bind.annotation.*
|
||||
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.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
|
||||
@RequestMapping("/api/synology")
|
||||
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()
|
||||
.clientConnector(ReactorClientHttpConnector(httpClient))
|
||||
.codecs { configurer ->
|
||||
configurer.defaultCodecs().maxInMemorySize(20 * 1024 * 1024) // 20MB로 증설
|
||||
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB 버퍼
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun buildBaseUrl(nasAddress: String): String {
|
||||
if (nasAddress.startsWith("http://") || nasAddress.startsWith("https://")) {
|
||||
return nasAddress
|
||||
}
|
||||
return if (nasAddress.contains(":5000")) "http://$nasAddress" else "https://$nasAddress"
|
||||
return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress"
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
suspend fun login(@RequestBody payload: Map<String, Any>): String {
|
||||
try {
|
||||
val nasAddress = payload["nasAddress"].toString()
|
||||
val username = payload["username"].toString()
|
||||
val password = payload["password"].toString()
|
||||
suspend fun login(@RequestBody request: Map<String, String>): ResponseEntity<Any> {
|
||||
val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress")
|
||||
val username = request["username"] ?: return ResponseEntity.badRequest().body("No username")
|
||||
val password = request["password"] ?: return ResponseEntity.badRequest().body("No password")
|
||||
|
||||
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 baseUrl = buildBaseUrl(nasAddress)
|
||||
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 webClient.get().uri(url).retrieve().awaitBody()
|
||||
return try {
|
||||
val response = webClient.get()
|
||||
.uri(uri)
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.awaitSingle()
|
||||
ResponseEntity.ok(response)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Login Error: ", e)
|
||||
throw e
|
||||
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||
}
|
||||
}
|
||||
|
||||
// 1. [수정] list 메서드: additional 제거 (에러 해결)
|
||||
@PostMapping("/list")
|
||||
suspend fun list(@RequestBody payload: Map<String, Any>): String {
|
||||
try {
|
||||
val nasAddress = payload["nasAddress"].toString()
|
||||
val sid = payload["sid"].toString()
|
||||
val offset = payload["offset"].toString()
|
||||
val limit = payload["limit"].toString()
|
||||
suspend fun getList(
|
||||
@RequestBody request: SynologyListRequest
|
||||
): ResponseEntity<Any> {
|
||||
val baseUrl = buildBaseUrl(request.nasAddress)
|
||||
|
||||
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()
|
||||
|
||||
// 순수하게 목록만 요청
|
||||
val url = "$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Browse.Item&version=1&method=list&limit=$limit&offset=$offset&_sid=$sid"
|
||||
|
||||
logger.info("List Request URL: $url")
|
||||
return webClient.get().uri(url).retrieve().awaitBody()
|
||||
return try {
|
||||
val response = webClient.get()
|
||||
.uri(uri)
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.awaitSingle()
|
||||
ResponseEntity.ok(response)
|
||||
} catch (e: Exception) {
|
||||
logger.error("List Error: ", e)
|
||||
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
|
||||
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/geocode")
|
||||
suspend fun geocode(
|
||||
@RequestParam lat: String,
|
||||
@RequestParam lon: String
|
||||
): String {
|
||||
// OpenStreetMap API URL
|
||||
val url = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
||||
suspend fun reverseGeocode(
|
||||
@RequestParam lat: Double,
|
||||
@RequestParam lon: Double
|
||||
): ResponseEntity<Any> {
|
||||
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
||||
|
||||
// Nominatim은 User-Agent 헤더가 필수입니다.
|
||||
return webClient.get()
|
||||
.uri(url)
|
||||
.header("User-Agent", "MyNasSlideshow/1.0 (contact@example.com)") // 임의의 식별 문자열
|
||||
.retrieve()
|
||||
.awaitBody()
|
||||
return try {
|
||||
val response = webClient.get()
|
||||
.uri(uri)
|
||||
.header("User-Agent", "SynoPhotoSlideshow/1.0")
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.awaitSingle()
|
||||
|
||||
ResponseEntity.ok(response)
|
||||
} catch (e: Exception) {
|
||||
ResponseEntity.ok("{}")
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/image")
|
||||
suspend fun getImage(
|
||||
fun getImage(
|
||||
@RequestParam nasAddress: String,
|
||||
@RequestParam sid: String,
|
||||
@RequestParam id: String
|
||||
): ResponseEntity<ByteArray> {
|
||||
@RequestParam id: String,
|
||||
@RequestParam(defaultValue = "download") mode: String,
|
||||
@RequestParam(required = false) cacheKey: String?
|
||||
): ResponseEntity<StreamingResponseBody> {
|
||||
val baseUrl = buildBaseUrl(nasAddress)
|
||||
|
||||
// [핵심] 문자열 더하기 대신 UriComponentsBuilder 사용
|
||||
// Spring이 '[' 문자를 자동으로 알맞게(%5B) 인코딩해줍니다. (이중 인코딩 방지)
|
||||
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||
.path("/webapi/entry.cgi")
|
||||
.queryParam("api", "SYNO.Foto.Download")
|
||||
.queryParam("version", "1")
|
||||
.queryParam("method", "download")
|
||||
.queryParam("force_download", "true")
|
||||
.queryParam("item_id", "[$id]") // 여기에 대괄호 []를 직접 넣습니다.
|
||||
.queryParam("_sid", sid)
|
||||
.build()
|
||||
.toUri()
|
||||
val rawUrl = if (mode == "thumbnail") {
|
||||
val cacheKeyParam = if (!cacheKey.isNullOrEmpty()) "&cache_key=$cacheKey" else ""
|
||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Thumbnail&version=1&method=get&type=unit&size=xl&id=$id$cacheKeyParam&_sid=$sid"
|
||||
} else {
|
||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
|
||||
}
|
||||
val uri = java.net.URI.create(rawUrl)
|
||||
|
||||
try {
|
||||
val response = webClient.get()
|
||||
.uri(uri) // String url 대신 URI 객체를 전달
|
||||
.accept(MediaType.IMAGE_JPEG, MediaType.IMAGE_PNG, MediaType.ALL)
|
||||
// [해결] retrieve() + toEntityFlux() 사용
|
||||
// exchangeToMono의 복잡한 스코프 문제를 피하고, 헤더와 바디 스트림을 안전하게 가져옵니다.
|
||||
val responseEntity = webClient.get()
|
||||
.uri(uri)
|
||||
.accept(MediaType.ALL)
|
||||
.retrieve()
|
||||
.toEntity(ByteArray::class.java)
|
||||
.awaitSingle()
|
||||
.toEntityFlux(DataBuffer::class.java)
|
||||
.block() ?: return ResponseEntity.notFound().build()
|
||||
|
||||
val contentType = response.headers.contentType
|
||||
val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM
|
||||
val contentLength = responseEntity.headers.contentLength
|
||||
|
||||
// 에러 체크
|
||||
if (contentType != null && !contentType.toString().startsWith("image")) {
|
||||
val errorBody = String(response.body ?: ByteArray(0))
|
||||
logger.error("NAS returned non-image content ($contentType): $errorBody")
|
||||
logger.error("Requested URI: $uri") // 디버깅을 위해 요청한 URI도 출력
|
||||
// 1. 에러 체크 (JSON 응답이 오면 에러로 간주)
|
||||
if (contentType.includes(MediaType.APPLICATION_JSON)) {
|
||||
// 스트림을 문자열로 읽어서 로그 출력
|
||||
val errorBody = responseEntity.body?.collectList()?.block()?.joinToString("") {
|
||||
it.toString(StandardCharsets.UTF_8)
|
||||
} ?: "Unknown Error"
|
||||
|
||||
println(">>> NAS Error (ID: $id): $errorBody")
|
||||
return ResponseEntity.notFound().build()
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(contentType ?: MediaType.IMAGE_JPEG)
|
||||
.body(response.body)
|
||||
// 2. 정상 스트리밍 (DataBufferUtils.write 사용)
|
||||
// Flux<DataBuffer>를 OutputStream에 바로 씁니다. (메모리 효율 최적)
|
||||
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) {
|
||||
logger.error("Image Fetch Error: ", e)
|
||||
e.printStackTrace()
|
||||
return ResponseEntity.notFound().build()
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,53 +6,208 @@
|
||||
<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 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>
|
||||
body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color: #000; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; }
|
||||
.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; }
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nanum+Pen+Script&family=Noto+Sans+KR:wght@300;400;700&display=swap');
|
||||
|
||||
/* 정보 오버레이 */
|
||||
#info-overlay {
|
||||
position: absolute; top: 30px; left: 30px; z-index: 15;
|
||||
text-shadow: 1px 1px 5px rgba(0,0,0,0.9);
|
||||
pointer-events: none; opacity: 0; transition: opacity 1s;
|
||||
max-width: 50%;
|
||||
:root { --anim-duration: 1.5s; --text-scale: 1.0; }
|
||||
|
||||
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; }
|
||||
|
||||
#bg-layer {
|
||||
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; }
|
||||
|
||||
/* 로그인 폼 */
|
||||
#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; }
|
||||
#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); }
|
||||
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; outline: none; }
|
||||
#login-form button { padding: 12px; background: #e44c65; border: none; color: white; cursor: pointer; border-radius: 6px; font-weight: bold; font-size: 1em; }
|
||||
#main-wrapper {
|
||||
position: relative; flex: 1; width: 100%;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
z-index: 1; perspective: 1000px;
|
||||
}
|
||||
|
||||
#photo-container { position: relative; width: 100%; height: 100%; z-index: 1; }
|
||||
.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; }
|
||||
.slide-image.active { opacity: 1; }
|
||||
#card-stage {
|
||||
position: relative; width: 100%; height: 100%;
|
||||
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; }
|
||||
#ad-container ins { pointer-events: auto; display: inline-block; }
|
||||
.polaroid-card {
|
||||
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; }
|
||||
body:hover #controls { opacity: 1; }
|
||||
input[type=range] { -webkit-appearance: none; width: 300px; height: 6px; background: rgba(255,255,255,0.3); border-radius: 3px; outline: none; }
|
||||
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #e44c65; cursor: pointer; }
|
||||
.p-top {
|
||||
height: calc(30px * var(--text-scale)); flex-shrink: 0;
|
||||
display: flex; justify-content: space-between; align-items: center; padding: 0 15px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="bg-layer"></div>
|
||||
<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">
|
||||
<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_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="password" id="password" placeholder="Password">
|
||||
<button onclick="loginAndStart()">Start Slideshow</button>
|
||||
@ -60,198 +215,450 @@
|
||||
</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>
|
||||
let sid = '';
|
||||
let nasAddress = '';
|
||||
const allPhotoIds = [];
|
||||
let sid = '', nasAddress = '';
|
||||
const allPhotoItems = [];
|
||||
const photoHistory = [];
|
||||
let historyIndex = -1;
|
||||
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 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 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() {
|
||||
nasAddress = document.getElementById('nas-address').value.trim();
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
if (!nasAddress || !username || !password) { showMessage('모든 정보를 입력해주세요.'); return; }
|
||||
showMessage('로그인 중...');
|
||||
if (!nasAddress || !username || !password) { showMessage('Input all fields'); return; }
|
||||
showMessage('Logging in...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/synology/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
|
||||
body: JSON.stringify({ nasAddress, username, password })
|
||||
});
|
||||
const response = await fetch('/api/synology/login', { method: 'POST', headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, body: JSON.stringify({ nasAddress, username, password }) });
|
||||
const loginData = await response.json();
|
||||
if (loginData.success && loginData.data.sid) {
|
||||
sid = loginData.data.sid;
|
||||
showMessage('목록 가져오는 중...');
|
||||
await fetchAllPhotos(0);
|
||||
} else { showMessage('로그인 실패 (정보 확인 필요)'); }
|
||||
} catch (error) { console.error(error); showMessage('서버 통신 오류'); }
|
||||
sid = loginData.data.sid; showMessage('Fetching list...'); await fetchAllPhotos(0);
|
||||
} else { showMessage('Login Failed'); }
|
||||
} catch (error) { console.error(error); showMessage('Network Error'); }
|
||||
}
|
||||
|
||||
async function fetchAllPhotos(offset) {
|
||||
const limit = 5000;
|
||||
try {
|
||||
const response = await fetch('/api/synology/list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
|
||||
body: JSON.stringify({ nasAddress, sid, offset, limit })
|
||||
});
|
||||
const response = await fetch('/api/synology/list', { method: 'POST', headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken }, body: JSON.stringify({ nasAddress, sid, offset, limit }) });
|
||||
const data = await response.json();
|
||||
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;
|
||||
if (offset + limit < total) {
|
||||
showMessage(`${allPhotoIds.length} / ${total} 장 로딩...`);
|
||||
await fetchAllPhotos(offset + limit);
|
||||
} else {
|
||||
startSlideshow();
|
||||
if (offset + limit < total) { showMessage(`${allPhotoItems.length}장 로딩 중...`); await fetchAllPhotos(offset + limit); }
|
||||
else { document.getElementById('login-overlay').style.display = 'none'; loadNextPhoto(); }
|
||||
}
|
||||
} catch (error) { showMessage('Fetch Failed'); }
|
||||
}
|
||||
|
||||
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() {
|
||||
document.getElementById('login-container').style.display = 'none';
|
||||
document.getElementById('controls').style.display = 'flex';
|
||||
document.getElementById('ad-container').style.display = 'block';
|
||||
document.getElementById('info-overlay').style.opacity = 1;
|
||||
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(() => {
|
||||
nextCard.classList.add('active');
|
||||
currentCard.classList.remove('active');
|
||||
const prevVid = currentCard.querySelector('.card-vid');
|
||||
if(prevVid) { prevVid.pause(); prevVid.removeAttribute('src'); prevVid.load(); }
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => { newImg.classList.add('active'); });
|
||||
activeCardNum = nextCardNum;
|
||||
|
||||
const oldImages = container.querySelectorAll('img');
|
||||
if (oldImages.length > 1) {
|
||||
setTimeout(() => {
|
||||
for(let i = 0; i < oldImages.length - 1; i++) { container.removeChild(oldImages[i]); }
|
||||
}, 1500);
|
||||
if (!isPaused) {
|
||||
clearTimeout(slideshowTimeout);
|
||||
slideshowTimeout = setTimeout(() => loadNextPhoto(false), currentSpeed);
|
||||
}
|
||||
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) {
|
||||
let dd = coord[0] + (coord[1] / 60) + (coord[2] / 3600);
|
||||
if (ref === "S" || ref === "W") {
|
||||
dd = dd * -1;
|
||||
}
|
||||
if (ref === "S" || ref === "W") dd = dd * -1;
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user