..
This commit is contained in:
parent
1fa47201a7
commit
526a7b598f
@ -1,6 +1,12 @@
|
|||||||
package kr.lunaticbum.back.lun.controllers
|
package kr.lunaticbum.back.lun.controllers
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kotlinx.coroutines.reactor.awaitSingle
|
import kotlinx.coroutines.reactor.awaitSingle
|
||||||
|
import kotlinx.coroutines.reactor.awaitSingleOrNull
|
||||||
|
import kr.lunaticbum.back.lun.model.PhotoMetadata
|
||||||
|
import kr.lunaticbum.back.lun.repository.PhotoMetadataRepository
|
||||||
|
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.core.io.buffer.DataBufferUtils
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
@ -9,9 +15,8 @@ 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.servlet.mvc.method.annotation.StreamingResponseBody
|
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|
||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
import java.nio.charset.StandardCharsets
|
import reactor.core.publisher.Mono
|
||||||
|
|
||||||
// DTO 정의
|
|
||||||
data class SynologyListRequest(
|
data class SynologyListRequest(
|
||||||
val nasAddress: String,
|
val nasAddress: String,
|
||||||
val sid: String,
|
val sid: String,
|
||||||
@ -19,13 +24,23 @@ data class SynologyListRequest(
|
|||||||
val limit: Int
|
val limit: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class MetadataRequest(
|
||||||
|
val id: String,
|
||||||
|
val memo: String?,
|
||||||
|
val tags: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/synology")
|
@RequestMapping("/api/synology")
|
||||||
class SynologyProxyController {
|
class SynologyProxyController(
|
||||||
|
private val photoMetadataRepository: PhotoMetadataRepository
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(SynologyProxyController::class.java)
|
||||||
|
private val mapper = ObjectMapper()
|
||||||
|
|
||||||
private val webClient = WebClient.builder()
|
private val webClient = WebClient.builder()
|
||||||
.codecs { configurer ->
|
.codecs { configurer ->
|
||||||
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024) // 16MB 버퍼
|
configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@ -33,6 +48,88 @@ class SynologyProxyController {
|
|||||||
return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress"
|
return if (nasAddress.startsWith("http")) nasAddress else "https://$nasAddress"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [핵심 로직] 라이브 포토의 짝꿍 비디오 ID 찾기 (Plan A -> Plan B)
|
||||||
|
private fun fetchLivePhotoUnitId(
|
||||||
|
baseUrl: String,
|
||||||
|
sid: String,
|
||||||
|
folderId: String,
|
||||||
|
uuid: String?,
|
||||||
|
originalFilename: String?
|
||||||
|
): String? {
|
||||||
|
try {
|
||||||
|
// ========================================================================
|
||||||
|
// PLAN A: MediaGroupUUID로 검색 (가장 정확함)
|
||||||
|
// ========================================================================
|
||||||
|
if (!uuid.isNullOrEmpty()) {
|
||||||
|
val searchUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||||
|
.path("/webapi/entry.cgi")
|
||||||
|
.queryParam("api", "SYNO.Foto.Browse.Item")
|
||||||
|
.queryParam("version", "1")
|
||||||
|
.queryParam("method", "list")
|
||||||
|
.queryParam("folder_id", folderId)
|
||||||
|
.queryParam("type", "video")
|
||||||
|
.queryParam("filter_media_group_uuid", uuid)
|
||||||
|
.queryParam("limit", 1)
|
||||||
|
.queryParam("_sid", sid)
|
||||||
|
.build().toUri()
|
||||||
|
|
||||||
|
val response = webClient.get().uri(searchUri).retrieve().bodyToMono(String::class.java).block()
|
||||||
|
logger.info(">>> [Smart Lookup] Plan A response: $response")
|
||||||
|
val list = mapper.readTree(response).path("data").path("list")
|
||||||
|
|
||||||
|
if (list.isArray && list.size() > 0) {
|
||||||
|
val foundId = list.get(0).path("id").asText()
|
||||||
|
logger.info(">>> [Smart Lookup] Plan A Success! Found ID via UUID: $foundId")
|
||||||
|
return foundId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// PLAN B: 파일명 매칭 (UUID 실패 시 시도)
|
||||||
|
// 예: IMG_1234.HEIC -> 같은 폴더의 IMG_1234.MOV 찾기
|
||||||
|
// ========================================================================
|
||||||
|
if (!originalFilename.isNullOrEmpty()) {
|
||||||
|
val baseName = originalFilename.substringBeforeLast(".")
|
||||||
|
// 해당 폴더의 비디오 목록 조회 (최대 1000개)
|
||||||
|
val listUri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||||
|
.path("/webapi/entry.cgi")
|
||||||
|
.queryParam("api", "SYNO.Foto.Browse.Item")
|
||||||
|
.queryParam("version", "1")
|
||||||
|
.queryParam("method", "list")
|
||||||
|
.queryParam("folder_id", folderId)
|
||||||
|
.queryParam("offset", 0)
|
||||||
|
.queryParam("type", "video")
|
||||||
|
.queryParam("limit", 1000)
|
||||||
|
.queryParam("_sid", sid)
|
||||||
|
.build().toUri()
|
||||||
|
|
||||||
|
val response = webClient.get().uri(listUri).retrieve().bodyToMono(String::class.java).block()
|
||||||
|
logger.info(">>> [Smart Lookup] Plan B response: $response")
|
||||||
|
val list = mapper.readTree(response).path("data").path("list")
|
||||||
|
|
||||||
|
if (list.isArray) {
|
||||||
|
for (node in list) {
|
||||||
|
val videoName = node.path("filename").asText("")
|
||||||
|
val videoBase = videoName.substringBeforeLast(".")
|
||||||
|
|
||||||
|
// 확장자 제외한 이름이 같으면 빙고!
|
||||||
|
if (videoBase.equals(baseName, ignoreCase = true)) {
|
||||||
|
val foundId = node.path("id").asText()
|
||||||
|
logger.info(">>> [Smart Lookup] Plan B Success! Found matching file: $videoName (ID: $foundId)")
|
||||||
|
return foundId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(">>> [Smart Lookup] Failed. Both Plan A and B failed for file: $originalFilename")
|
||||||
|
return null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error(">>> [Smart Lookup] Error", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
suspend fun login(@RequestBody request: Map<String, String>): ResponseEntity<Any> {
|
suspend fun login(@RequestBody request: Map<String, String>): ResponseEntity<Any> {
|
||||||
val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress")
|
val nasAddress = request["nasAddress"] ?: return ResponseEntity.badRequest().body("No nasAddress")
|
||||||
@ -43,11 +140,7 @@ class SynologyProxyController {
|
|||||||
val uri = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=$username&passwd=$password&session=Foto&format=cookie"
|
val uri = "$baseUrl/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=$username&passwd=$password&session=Foto&format=cookie"
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val response = webClient.get()
|
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
|
||||||
.uri(uri)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(String::class.java)
|
|
||||||
.awaitSingle()
|
|
||||||
ResponseEntity.ok(response)
|
ResponseEntity.ok(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||||
@ -55,11 +148,8 @@ class SynologyProxyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/list")
|
@PostMapping("/list")
|
||||||
suspend fun getList(
|
suspend fun getList(@RequestBody request: SynologyListRequest): ResponseEntity<Any> {
|
||||||
@RequestBody request: SynologyListRequest
|
|
||||||
): ResponseEntity<Any> {
|
|
||||||
val baseUrl = buildBaseUrl(request.nasAddress)
|
val baseUrl = buildBaseUrl(request.nasAddress)
|
||||||
|
|
||||||
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
val uri = org.springframework.web.util.UriComponentsBuilder.fromHttpUrl(baseUrl)
|
||||||
.path("/webapi/entry.cgi")
|
.path("/webapi/entry.cgi")
|
||||||
.queryParam("api", "SYNO.Foto.Browse.Item")
|
.queryParam("api", "SYNO.Foto.Browse.Item")
|
||||||
@ -67,18 +157,13 @@ class SynologyProxyController {
|
|||||||
.queryParam("method", "list")
|
.queryParam("method", "list")
|
||||||
.queryParam("offset", request.offset)
|
.queryParam("offset", request.offset)
|
||||||
.queryParam("limit", request.limit)
|
.queryParam("limit", request.limit)
|
||||||
// [중요] 썸네일 키(cache_key)를 받아오기 위해 필수
|
// [중요] 정보를 미리 확보 (thumbnail, exif)
|
||||||
.queryParam("additional", "[\"thumbnail\"]")
|
.queryParam("additional", "[\"thumbnail\",\"exif\"]")
|
||||||
.queryParam("_sid", request.sid)
|
.queryParam("_sid", request.sid)
|
||||||
.build()
|
.build().toUri()
|
||||||
.toUri()
|
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val response = webClient.get()
|
val response = webClient.get().uri(uri).retrieve().bodyToMono(String::class.java).awaitSingle()
|
||||||
.uri(uri)
|
|
||||||
.retrieve()
|
|
||||||
.bodyToMono(String::class.java)
|
|
||||||
.awaitSingle()
|
|
||||||
ResponseEntity.ok(response)
|
ResponseEntity.ok(response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
ResponseEntity.status(500).body(mapOf("success" to false, "message" to e.message))
|
||||||
@ -87,23 +172,40 @@ class SynologyProxyController {
|
|||||||
|
|
||||||
@GetMapping("/geocode")
|
@GetMapping("/geocode")
|
||||||
suspend fun reverseGeocode(
|
suspend fun reverseGeocode(
|
||||||
@RequestParam lat: Double,
|
@RequestParam id: String, @RequestParam lat: Double, @RequestParam lon: Double
|
||||||
@RequestParam lon: Double
|
|
||||||
): ResponseEntity<Any> {
|
): ResponseEntity<Any> {
|
||||||
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
val existingData = photoMetadataRepository.findById(id).awaitSingleOrNull()
|
||||||
|
if (existingData != null) {
|
||||||
return try {
|
return ResponseEntity.ok(mapOf(
|
||||||
val response = webClient.get()
|
"address" to (existingData.address ?: ""),
|
||||||
.uri(uri)
|
"memo" to (existingData.memo ?: ""),
|
||||||
.header("User-Agent", "SynoPhotoSlideshow/1.0")
|
"tags" to existingData.tags
|
||||||
.retrieve()
|
))
|
||||||
.bodyToMono(String::class.java)
|
|
||||||
.awaitSingle()
|
|
||||||
|
|
||||||
ResponseEntity.ok(response)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
ResponseEntity.ok("{}")
|
|
||||||
}
|
}
|
||||||
|
val uri = "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=10&accept-language=ko"
|
||||||
|
return try {
|
||||||
|
val responseString = webClient.get().uri(uri).header("User-Agent", "SynoPhotoSlideshow/1.0").retrieve().bodyToMono(String::class.java).awaitSingle()
|
||||||
|
val node = mapper.readTree(responseString)
|
||||||
|
val addressName = node.path("display_name").asText(null)
|
||||||
|
if (!addressName.isNullOrEmpty()) {
|
||||||
|
val metadata = PhotoMetadata(id = id, address = addressName, latitude = lat, longitude = lon)
|
||||||
|
photoMetadataRepository.save(metadata).awaitSingle()
|
||||||
|
return ResponseEntity.ok(mapOf("address" to addressName, "memo" to "", "tags" to emptyList<String>()))
|
||||||
|
}
|
||||||
|
ResponseEntity.ok(mapOf("address" to "Unknown"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ResponseEntity.ok(mapOf("address" to "Unknown"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/metadata")
|
||||||
|
suspend fun saveMetadata(@RequestBody req: MetadataRequest): ResponseEntity<Any> {
|
||||||
|
val existingMetadata = photoMetadataRepository.findById(req.id).awaitSingleOrNull()
|
||||||
|
val metadata = existingMetadata ?: PhotoMetadata(id = req.id)
|
||||||
|
metadata.memo = req.memo
|
||||||
|
metadata.tags = req.tags.toMutableList()
|
||||||
|
photoMetadataRepository.save(metadata).awaitSingle()
|
||||||
|
return ResponseEntity.ok(mapOf("success" to true))
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/image")
|
@GetMapping("/image")
|
||||||
@ -112,21 +214,37 @@ class SynologyProxyController {
|
|||||||
@RequestParam sid: String,
|
@RequestParam sid: String,
|
||||||
@RequestParam id: String,
|
@RequestParam id: String,
|
||||||
@RequestParam(defaultValue = "download") mode: String,
|
@RequestParam(defaultValue = "download") mode: String,
|
||||||
@RequestParam(required = false) cacheKey: String?
|
@RequestParam(required = false) cacheKey: String?,
|
||||||
|
@RequestParam(required = false) folderId: String?,
|
||||||
|
@RequestParam(required = false) uuid: String?,
|
||||||
|
@RequestParam(required = false) filename: String?
|
||||||
): ResponseEntity<StreamingResponseBody> {
|
): ResponseEntity<StreamingResponseBody> {
|
||||||
val baseUrl = buildBaseUrl(nasAddress)
|
val baseUrl = buildBaseUrl(nasAddress)
|
||||||
|
|
||||||
val rawUrl = if (mode == "thumbnail") {
|
var targetId = id
|
||||||
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"
|
// [Smart Lookup 발동 조건] video 모드 + 폴더ID 존재
|
||||||
} else {
|
if (mode == "video" && !folderId.isNullOrEmpty()) {
|
||||||
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&force_download=true&item_id=[$id]&_sid=$sid"
|
val unitId = fetchLivePhotoUnitId(baseUrl, sid, folderId, uuid, filename)
|
||||||
|
if (unitId != null) targetId = unitId
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawUrl = when (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"
|
||||||
|
}
|
||||||
|
"video" -> {
|
||||||
|
// 다운로드 API로 비디오 스트리밍 (MP4/MOV 원본)
|
||||||
|
"$baseUrl/webapi/entry.cgi?api=SYNO.Foto.Download&version=1&method=download&id=$targetId&cache_key=$cacheKey&_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)
|
val uri = java.net.URI.create(rawUrl)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// [해결] retrieve() + toEntityFlux() 사용
|
|
||||||
// exchangeToMono의 복잡한 스코프 문제를 피하고, 헤더와 바디 스트림을 안전하게 가져옵니다.
|
|
||||||
val responseEntity = webClient.get()
|
val responseEntity = webClient.get()
|
||||||
.uri(uri)
|
.uri(uri)
|
||||||
.accept(MediaType.ALL)
|
.accept(MediaType.ALL)
|
||||||
@ -137,30 +255,17 @@ class SynologyProxyController {
|
|||||||
val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM
|
val contentType = responseEntity.headers.contentType ?: MediaType.APPLICATION_OCTET_STREAM
|
||||||
val contentLength = responseEntity.headers.contentLength
|
val contentLength = responseEntity.headers.contentLength
|
||||||
|
|
||||||
// 1. 에러 체크 (JSON 응답이 오면 에러로 간주)
|
|
||||||
if (contentType.includes(MediaType.APPLICATION_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.notFound().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 정상 스트리밍 (DataBufferUtils.write 사용)
|
|
||||||
// Flux<DataBuffer>를 OutputStream에 바로 씁니다. (메모리 효율 최적)
|
|
||||||
val streamingBody = StreamingResponseBody { outputStream ->
|
val streamingBody = StreamingResponseBody { outputStream ->
|
||||||
val flux = responseEntity.body ?: Flux.empty()
|
val flux = responseEntity.body ?: Flux.empty()
|
||||||
DataBufferUtils.write(flux, outputStream)
|
DataBufferUtils.write(flux, outputStream).blockLast()
|
||||||
.blockLast() // 쓰기가 완료될 때까지 대기
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val builder = ResponseEntity.ok().contentType(contentType)
|
val builder = ResponseEntity.ok().contentType(contentType)
|
||||||
if (contentLength > 0) {
|
if (contentLength > 0) builder.contentLength(contentLength)
|
||||||
builder.contentLength(contentLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.body(streamingBody)
|
return builder.body(streamingBody)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
package kr.lunaticbum.back.lun.model
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document
|
||||||
|
|
||||||
|
@Document(collection = "photo_metadata")
|
||||||
|
data class PhotoMetadata(
|
||||||
|
@Id
|
||||||
|
val id: String,
|
||||||
|
|
||||||
|
var address: String? = null,
|
||||||
|
var latitude: Double? = null,
|
||||||
|
var longitude: Double? = null,
|
||||||
|
|
||||||
|
var memo: String? = null,
|
||||||
|
|
||||||
|
// [추가] 태그 리스트
|
||||||
|
var tags: MutableList<String> = mutableListOf()
|
||||||
|
)
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
package kr.lunaticbum.back.lun.repository
|
||||||
|
|
||||||
|
import kr.lunaticbum.back.lun.model.PhotoMetadata
|
||||||
|
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
|
||||||
|
|
||||||
|
interface PhotoMetadataRepository : ReactiveMongoRepository<PhotoMetadata,String> {
|
||||||
|
}
|
||||||
@ -2,7 +2,8 @@
|
|||||||
<html xmlns:th="http://www.thymeleaf.org">
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
<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>
|
||||||
@ -16,6 +17,9 @@
|
|||||||
|
|
||||||
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; }
|
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; }
|
||||||
|
|
||||||
|
:fullscreen { background: #000; }
|
||||||
|
::backdrop { background: #000; }
|
||||||
|
|
||||||
#bg-layer {
|
#bg-layer {
|
||||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
background: linear-gradient(to top, #09203f 0%, #537895 100%);
|
background: linear-gradient(to top, #09203f 0%, #537895 100%);
|
||||||
@ -30,16 +34,17 @@
|
|||||||
|
|
||||||
#card-stage {
|
#card-stage {
|
||||||
position: relative; width: 100%; height: 100%;
|
position: relative; width: 100%; height: 100%;
|
||||||
display: flex; justify-content: center; align-items: center; padding-bottom: 0; /* 하단 여백 제거 */
|
display: flex; justify-content: center; align-items: center; padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.polaroid-card {
|
.polaroid-card {
|
||||||
position: absolute; background-color: #fff; color: #333;
|
position: absolute; background-color: #fff; color: #333;
|
||||||
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
width: 320px; height: 400px;
|
width: 300px; height: 400px;
|
||||||
display: flex; flex-direction: column; border-radius: 4px;
|
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);
|
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;
|
opacity: 0; z-index: 1; transform: scale(1.05); will-change: opacity, transform; overflow: hidden;
|
||||||
|
max-width: 94vw; max-height: 90vh;
|
||||||
}
|
}
|
||||||
.polaroid-card.active { opacity: 1; z-index: 10; transform: scale(1); }
|
.polaroid-card.active { opacity: 1; z-index: 10; transform: scale(1); }
|
||||||
|
|
||||||
@ -66,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
.p-date {
|
.p-date {
|
||||||
font-size: calc(1.8em * var(--text-scale)); font-weight: bold; line-height: 1.2;
|
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;
|
margin-bottom: calc(5px * var(--text-scale)); transition: font-size 0.3s, margin-bottom 0.3s; min-height: 1.2em;
|
||||||
}
|
}
|
||||||
.p-location {
|
.p-location {
|
||||||
font-size: calc(1.3em * var(--text-scale)); color: #555; display: flex; align-items: center; gap: 5px;
|
font-size: calc(1.3em * var(--text-scale)); color: #555; display: flex; align-items: center; gap: 5px;
|
||||||
@ -74,40 +79,57 @@
|
|||||||
}
|
}
|
||||||
.typing-cursor::after { content: '|'; display: inline-block; margin-left: 2px; color: #e44c65; animation: blink 0.8s step-start infinite; font-weight: normal; }
|
.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; } }
|
@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; }
|
|
||||||
|
.p-meta-info {
|
||||||
|
font-size: 0.9em; color: #666; margin-top: 5px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
display: flex; gap: 5px; align-items: center; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.tag-badge {
|
||||||
|
background: #eee; color: #555; padding: 2px 6px; border-radius: 4px;
|
||||||
|
font-size: 0.8em; display: inline-block; margin-right: 2px;
|
||||||
|
}
|
||||||
|
.memo-text { color: #888; margin-left: 5px; font-size: 0.9em; }
|
||||||
|
|
||||||
#exif-full-overlay {
|
#exif-full-overlay {
|
||||||
position: absolute; bottom: 90px; right: 20px; z-index: 25;
|
position: absolute; bottom: 90px; right: 20px; z-index: 25;
|
||||||
background: rgba(0, 0, 0, 0.75); padding: 15px; border-radius: 10px;
|
background: rgba(0, 0, 0, 0.85); padding: 15px; border-radius: 10px;
|
||||||
width: 280px; max-height: 60vh; overflow-y: auto;
|
width: 280px; max-height: 60vh; overflow-y: auto;
|
||||||
font-size: 0.85em; color: #ccc; backdrop-filter: blur(5px);
|
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);
|
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; }
|
@media (max-width: 600px) {
|
||||||
#exif-full-overlay::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }
|
#exif-full-overlay { right: 10px; left: 10px; width: auto; bottom: 120px; }
|
||||||
.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 {
|
#controls-bar {
|
||||||
position: absolute; bottom: 30px;
|
position: absolute; bottom: 30px;
|
||||||
display: flex; align-items: center; gap: 15px;
|
display: flex; align-items: center; gap: 15px;
|
||||||
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(5px);
|
background: rgba(0, 0, 0, 0.6); backdrop-filter: blur(5px);
|
||||||
padding: 10px 25px; border-radius: 50px;
|
padding: 10px 25px; border-radius: 50px;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
/* 애니메이션 설정 */
|
opacity: 0; transform: translateY(20px);
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
transition: opacity 0.4s ease, transform 0.4s ease;
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||||
pointer-events: none; /* 숨겨진 상태에선 클릭 방지 */
|
pointer-events: none;
|
||||||
|
max-width: 95%; overflow-x: auto; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
#controls-bar.controls-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
|
||||||
|
#controls-bar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
/* [NEW] 활성화 클래스 */
|
.ctrl-filter-group {
|
||||||
#controls-bar.controls-visible {
|
display: flex; gap: 10px; align-items: center; background: rgba(255,255,255,0.1); padding: 5px 15px; border-radius: 20px; margin-right: 5px;
|
||||||
opacity: 1;
|
}
|
||||||
transform: translateY(0);
|
.ctrl-filter-group label {
|
||||||
pointer-events: auto; /* 보이게 되면 클릭 허용 */
|
color: #ccc; font-size: 0.8em; cursor: pointer; display: flex; align-items: center; gap: 4px; user-select: none;
|
||||||
|
}
|
||||||
|
.ctrl-filter-group input { accent-color: #e44c65; cursor: pointer; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#controls-bar { gap: 10px; padding: 10px 15px; bottom: 20px; }
|
||||||
|
.slider-group, .divider { display: none; }
|
||||||
|
.ctrl-filter-group { padding: 5px 10px; gap: 8px; }
|
||||||
|
.ctrl-filter-group label span { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
#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; }
|
#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; }
|
||||||
@ -116,7 +138,7 @@
|
|||||||
.color-chip.auto-chip { background: linear-gradient(135deg, red, orange, yellow, green, blue, indigo, violet); }
|
.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; }
|
.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 { 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; flex-shrink: 0; }
|
||||||
.ctrl-btn:hover { background: rgba(255,255,255,0.2); color: #e44c65; }
|
.ctrl-btn:hover { background: rgba(255,255,255,0.2); color: #e44c65; }
|
||||||
.ctrl-btn.active { color: #e44c65; background: rgba(255,255,255,0.1); }
|
.ctrl-btn.active { color: #e44c65; background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
@ -133,6 +155,31 @@
|
|||||||
#login-form input { padding: 12px; border: 1px solid #444; background: #333; color: #fff; border-radius: 6px; }
|
#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; }
|
#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; }
|
.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; }
|
||||||
|
|
||||||
|
/* 편집 모달 */
|
||||||
|
#edit-modal {
|
||||||
|
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
background: rgba(0,0,0,0.8); z-index: 2000;
|
||||||
|
display: none; justify-content: center; align-items: center;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background: #222; color: #fff; padding: 25px; border-radius: 15px;
|
||||||
|
width: 90%; max-width: 400px; display: flex; flex-direction: column; gap: 15px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5); border: 1px solid #444;
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 1.2em; font-weight: bold; color: #e44c65; }
|
||||||
|
.modal-input {
|
||||||
|
width: 100%; padding: 12px; border-radius: 5px; border: 1px solid #555;
|
||||||
|
background: #333; color: #fff; font-family: inherit; box-sizing: border-box; outline: none;
|
||||||
|
}
|
||||||
|
.modal-input:focus { border-color: #e44c65; }
|
||||||
|
.modal-btns { display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px; }
|
||||||
|
.m-btn { padding: 10px 20px; border-radius: 5px; border: none; cursor: pointer; font-weight: bold; transition: 0.2s; }
|
||||||
|
.btn-save { background: #e44c65; color: white; }
|
||||||
|
.btn-save:hover { background: #ff6b81; }
|
||||||
|
.btn-cancel { background: #555; color: white; }
|
||||||
|
.btn-cancel:hover { background: #666; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -152,7 +199,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-bottom">
|
<div class="p-bottom">
|
||||||
<div class="p-date"></div>
|
<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 class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span></div>
|
||||||
|
<div class="p-meta-info"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -167,7 +215,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-bottom">
|
<div class="p-bottom">
|
||||||
<div class="p-date"></div>
|
<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 class="p-location"><i class="fas fa-map-marker-alt" style="color:#e44c65; font-size:0.8em;"></i><span class="p-address">Loading...</span></div>
|
||||||
|
<div class="p-meta-info"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -175,11 +224,40 @@
|
|||||||
<div id="palette-panel"></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="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="edit-modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-title"><i class="fas fa-edit"></i> 정보 수정</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.9em; color:#aaa; display:block; margin-bottom:5px;">메모</label>
|
||||||
|
<input type="text" id="edit-memo" class="modal-input" placeholder="이 사진에 대한 메모...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style="font-size:0.9em; color:#aaa; display:block; margin-bottom:5px;">태그 (콤마로 구분)</label>
|
||||||
|
<input type="text" id="edit-tags" class="modal-input" placeholder="예: 여행, 가족, 맛집">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-btns">
|
||||||
|
<button class="m-btn btn-cancel" onclick="closeEditModal()">취소</button>
|
||||||
|
<button class="m-btn btn-save" onclick="saveMetadata()">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="controls-bar">
|
<div id="controls-bar">
|
||||||
|
<div class="ctrl-filter-group" title="필터">
|
||||||
|
<label><input type="checkbox" id="chk-photo" checked><i class="fas fa-image"></i><span>Img</span></label>
|
||||||
|
<label><input type="checkbox" id="chk-video" checked><i class="fas fa-video"></i><span>Vid</span></label>
|
||||||
|
<label><input type="checkbox" id="chk-live" checked><i class="fas fa-bolt"></i><span>Live</span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="ctrl-btn" onclick="prevPhoto()" title="이전"><i class="fas fa-step-backward"></i></button>
|
<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="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>
|
<button class="ctrl-btn" onclick="nextPhotoManual()" title="다음"><i class="fas fa-step-forward"></i></button>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<div class="slider-group" title="사진 대기 시간">
|
<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>
|
<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>
|
||||||
@ -191,9 +269,12 @@
|
|||||||
<div class="slider-group" title="텍스트 프레임 크기">
|
<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)">
|
<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>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
<button class="ctrl-btn" id="btn-edit" onclick="openEditModal()" title="정보 편집"><i class="fas fa-pen"></i></button>
|
||||||
<button class="ctrl-btn" id="btn-info" onclick="toggleInfo()" title="상세 정보"><i class="fas fa-info-circle"></i></button>
|
<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>
|
<button class="ctrl-btn" onclick="togglePalette()" title="배경 변경"><i class="fas fa-palette"></i></button>
|
||||||
|
<button class="ctrl-btn" onclick="toggleFullscreen()" id="btn-fullscreen" title="전체화면"><i class="fas fa-expand"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -230,10 +311,10 @@
|
|||||||
};
|
};
|
||||||
let isDynamicBg = true;
|
let isDynamicBg = true;
|
||||||
let showFullExif = false;
|
let showFullExif = false;
|
||||||
|
|
||||||
// [NEW] 컨트롤 바 숨김/표시 타이머 변수
|
|
||||||
let controlsHideTimer;
|
let controlsHideTimer;
|
||||||
|
|
||||||
|
let currentMetadata = { id: null, memo: "", tags: [] };
|
||||||
|
|
||||||
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');
|
||||||
|
|
||||||
@ -249,34 +330,106 @@
|
|||||||
|
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
initPalette();
|
initPalette();
|
||||||
initControlsAutoHider(); // [NEW] 자동 숨김 초기화
|
initControlsAutoHider();
|
||||||
|
initFullscreenListener();
|
||||||
};
|
};
|
||||||
|
|
||||||
// [NEW] 컨트롤 바 자동 숨김 로직
|
function toggleFullscreen() {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen().catch(err => console.error(err));
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) document.exitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFullscreenListener() {
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
const btn = document.getElementById('btn-fullscreen');
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
btn.innerHTML = '<i class="fas fa-compress"></i>';
|
||||||
|
btn.title = "전체화면 종료";
|
||||||
|
} else {
|
||||||
|
btn.innerHTML = '<i class="fas fa-expand"></i>';
|
||||||
|
btn.title = "전체화면";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
if (!currentMetadata.id) return;
|
||||||
|
if (!isPaused) togglePause();
|
||||||
|
document.getElementById('edit-memo').value = currentMetadata.memo || "";
|
||||||
|
document.getElementById('edit-tags').value = currentMetadata.tags ? currentMetadata.tags.join(", ") : "";
|
||||||
|
document.getElementById('edit-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('edit-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMetadata() {
|
||||||
|
const memoVal = document.getElementById('edit-memo').value.trim();
|
||||||
|
const tagsStr = document.getElementById('edit-tags').value.trim();
|
||||||
|
const tagsArr = tagsStr.split(',').map(t => t.trim()).filter(t => t !== "");
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: currentMetadata.id,
|
||||||
|
memo: memoVal,
|
||||||
|
tags: tagsArr
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/synology/metadata', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', [csrfHeader]: csrfToken },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
currentMetadata.memo = memoVal;
|
||||||
|
currentMetadata.tags = tagsArr;
|
||||||
|
updateCardMetaUI(activeCardNum);
|
||||||
|
closeEditModal();
|
||||||
|
} else {
|
||||||
|
alert("저장 실패");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert("네트워크 오류");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardMetaUI(cardNum) {
|
||||||
|
const card = document.getElementById(`polaroid-card-${cardNum}`);
|
||||||
|
const metaDiv = card.querySelector('.p-meta-info');
|
||||||
|
let html = "";
|
||||||
|
if (currentMetadata.tags && currentMetadata.tags.length > 0) {
|
||||||
|
currentMetadata.tags.forEach(tag => {
|
||||||
|
html += `<span class="tag-badge">#${tag}</span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (currentMetadata.memo) {
|
||||||
|
html += `<span class="memo-text">${currentMetadata.memo}</span>`;
|
||||||
|
}
|
||||||
|
metaDiv.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function initControlsAutoHider() {
|
function initControlsAutoHider() {
|
||||||
const controls = document.getElementById('controls-bar');
|
const controls = document.getElementById('controls-bar');
|
||||||
|
|
||||||
function showControls() {
|
function showControls() {
|
||||||
controls.classList.add('controls-visible');
|
controls.classList.add('controls-visible');
|
||||||
resetHideTimer();
|
resetHideTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetHideTimer() {
|
function resetHideTimer() {
|
||||||
clearTimeout(controlsHideTimer);
|
clearTimeout(controlsHideTimer);
|
||||||
// 마우스가 컨트롤 바 위에 있거나 슬라이더 조작 중일 때는 숨기지 않음
|
|
||||||
if (controls.matches(':hover')) return;
|
if (controls.matches(':hover')) return;
|
||||||
|
|
||||||
controlsHideTimer = setTimeout(() => {
|
controlsHideTimer = setTimeout(() => {
|
||||||
controls.classList.remove('controls-visible');
|
controls.classList.remove('controls-visible');
|
||||||
}, 3000); // 3초 뒤 숨김
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이벤트 감지: 마우스 이동, 클릭, 터치
|
|
||||||
document.addEventListener('mousemove', showControls);
|
document.addEventListener('mousemove', showControls);
|
||||||
document.addEventListener('click', showControls);
|
document.addEventListener('click', showControls);
|
||||||
document.addEventListener('touchstart', showControls);
|
document.addEventListener('touchstart', showControls);
|
||||||
|
|
||||||
// 컨트롤 바에서 마우스가 나갈 때 타이머 재시작
|
|
||||||
controls.addEventListener('mouseleave', resetHideTimer);
|
controls.addEventListener('mouseleave', resetHideTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,11 +473,10 @@
|
|||||||
|
|
||||||
function showMessage(msg) { document.getElementById('message').innerText = msg; }
|
function showMessage(msg) { document.getElementById('message').innerText = msg; }
|
||||||
|
|
||||||
// [수정] 슬라이더 조작 시 타이머 리셋 (숨김 방지)
|
|
||||||
function updateSpeed(val) {
|
function updateSpeed(val) {
|
||||||
currentSpeed = val * 1000;
|
currentSpeed = val * 1000;
|
||||||
document.getElementById('speed-display').innerText = val;
|
document.getElementById('speed-display').innerText = val;
|
||||||
clearTimeout(controlsHideTimer); // 조작 중엔 숨기지 않음
|
clearTimeout(controlsHideTimer);
|
||||||
}
|
}
|
||||||
function updateAnimSpeed(val) {
|
function updateAnimSpeed(val) {
|
||||||
document.documentElement.style.setProperty('--anim-duration', val + 's');
|
document.documentElement.style.setProperty('--anim-duration', val + 's');
|
||||||
@ -360,11 +512,26 @@
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
data.data.list.forEach(item => {
|
data.data.list.forEach(item => {
|
||||||
if (item.type === 'folder') return;
|
if (item.type === 'folder') return;
|
||||||
|
|
||||||
|
// [중요] 타입 판별 및 저장
|
||||||
|
let type = 'photo';
|
||||||
|
if (item.type === 'video') type = 'video';
|
||||||
|
else if (item.type === 'live' || (item.additional && item.additional.live_photo)) type = 'live';
|
||||||
|
|
||||||
|
item._customType = type; // 필터링용 태그
|
||||||
allPhotoItems.push(item);
|
allPhotoItems.push(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
const total = data.data.total;
|
const total = data.data.total;
|
||||||
if (offset + limit < total) { showMessage(`${allPhotoItems.length}장 로딩 중...`); await fetchAllPhotos(offset + limit); }
|
if (offset + limit < total) { showMessage(`${allPhotoItems.length}장 로딩 중...`); await fetchAllPhotos(offset + limit); }
|
||||||
else { document.getElementById('login-overlay').style.display = 'none'; loadNextPhoto(); }
|
else {
|
||||||
|
if (allPhotoItems.length === 0) {
|
||||||
|
showMessage("No items found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('login-overlay').style.display = 'none';
|
||||||
|
loadNextPhoto();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) { showMessage('Fetch Failed'); }
|
} catch (error) { showMessage('Fetch Failed'); }
|
||||||
}
|
}
|
||||||
@ -379,12 +546,54 @@
|
|||||||
function nextPhotoManual() { isPaused = true; updatePlayButton(); loadNextPhoto(true); }
|
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 updatePlayButton() { document.getElementById('btn-play').innerHTML = isPaused ? '<i class="fas fa-play"></i>' : '<i class="fas fa-pause"></i>'; }
|
||||||
|
|
||||||
|
// [핵심] 랜덤 뽑기 시 필터 체크
|
||||||
function loadNextPhoto(forceNew = false) {
|
function loadNextPhoto(forceNew = false) {
|
||||||
clearTimeout(slideshowTimeout);
|
clearTimeout(slideshowTimeout);
|
||||||
if (allPhotoItems.length === 0) return;
|
if (allPhotoItems.length === 0) return;
|
||||||
let nextItem;
|
|
||||||
if (!forceNew && historyIndex < photoHistory.length - 1) { historyIndex++; nextItem = photoHistory[historyIndex]; }
|
// 1. 현재 필터 상태 확인
|
||||||
else { nextItem = allPhotoItems[Math.floor(Math.random() * allPhotoItems.length)]; photoHistory.push(nextItem); historyIndex = photoHistory.length - 1; }
|
const showPhoto = document.getElementById('chk-photo').checked;
|
||||||
|
const showVideo = document.getElementById('chk-video').checked;
|
||||||
|
const showLive = document.getElementById('chk-live').checked;
|
||||||
|
|
||||||
|
if (!showPhoto && !showVideo && !showLive) {
|
||||||
|
if(!isPaused) togglePause();
|
||||||
|
alert("적어도 하나의 미디어 타입은 선택해야 합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextItem = null;
|
||||||
|
|
||||||
|
if (!forceNew && historyIndex < photoHistory.length - 1) {
|
||||||
|
historyIndex++;
|
||||||
|
nextItem = photoHistory[historyIndex];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 필터에 맞는 아이템 찾을 때까지 랜덤 시도
|
||||||
|
let maxAttempts = 2000;
|
||||||
|
while (maxAttempts > 0) {
|
||||||
|
const candidate = allPhotoItems[Math.floor(Math.random() * allPhotoItems.length)];
|
||||||
|
const type = candidate._customType;
|
||||||
|
|
||||||
|
if ((type === 'photo' && showPhoto) ||
|
||||||
|
(type === 'video' && showVideo) ||
|
||||||
|
(type === 'live' && showLive)) {
|
||||||
|
nextItem = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
maxAttempts--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nextItem) {
|
||||||
|
alert("조건에 맞는 사진이 없습니다.");
|
||||||
|
if(!isPaused) togglePause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
photoHistory.push(nextItem);
|
||||||
|
historyIndex = photoHistory.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (photoHistory.length > 100) { photoHistory.shift(); historyIndex--; }
|
if (photoHistory.length > 100) { photoHistory.shift(); historyIndex--; }
|
||||||
displayPhoto(nextItem);
|
displayPhoto(nextItem);
|
||||||
}
|
}
|
||||||
@ -435,19 +644,56 @@
|
|||||||
return rawExts.includes(ext) || heicExts.includes(ext);
|
return rawExts.includes(ext) || heicExts.includes(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayPhoto(item) {
|
async function displayPhoto(item) {
|
||||||
let cacheKey = "";
|
let cacheKey = "";
|
||||||
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.cache_key) {
|
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.cache_key) {
|
||||||
cacheKey = 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}`;
|
// [정보 추출: FolderID, UUID, Filename]
|
||||||
|
let folderId = item.folder_id;
|
||||||
|
let mediaUuid = null;
|
||||||
|
if (item.additional && item.additional.exif) {
|
||||||
|
if (item.additional.exif.MediaGroupUUID) {
|
||||||
|
mediaUuid = item.additional.exif.MediaGroupUUID;
|
||||||
|
} else if (item.additional.exif.ContentIdentifier) {
|
||||||
|
const cid = item.additional.exif.ContentIdentifier;
|
||||||
|
mediaUuid = cid.includes('#') ? cid.split('#')[1] : cid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let liveUnitId = null;
|
||||||
|
if (item.additional && item.additional.thumbnail && item.additional.thumbnail.live_photo_unit_id) {
|
||||||
|
liveUnitId = item.additional.thumbnail.live_photo_unit_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLive = (item.type === 'live' || !!liveUnitId || !!mediaUuid);
|
||||||
const isVideo = isVideoItem(item);
|
const isVideo = isVideoItem(item);
|
||||||
const isRaw = isRawItem(item);
|
const isRaw = isRawItem(item);
|
||||||
|
|
||||||
const requestMode = isRaw ? '&mode=thumbnail' : '&mode=download';
|
let requestMode = '&mode=download';
|
||||||
const finalUrl = baseApiUrl + requestMode;
|
let extraParams = '';
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
// if (liveUnitId) {
|
||||||
|
// requestMode = '&mode=video';
|
||||||
|
// } else if (folderId) {
|
||||||
|
// // [핵심] 백엔드의 Plan A(UUID) + Plan B(Filename)를 위해 정보 전달
|
||||||
|
// requestMode = '&mode=video';
|
||||||
|
// extraParams = `&folderId=${folderId}&filename=${encodeURIComponent(item.filename)}`;
|
||||||
|
// if (mediaUuid) extraParams += `&uuid=${encodeURIComponent(mediaUuid)}`;
|
||||||
|
// } else {
|
||||||
|
requestMode = '&mode=thumbnail';
|
||||||
|
// }
|
||||||
|
} else if (isVideo) {
|
||||||
|
requestMode = '&mode=video';
|
||||||
|
} else if (isRaw) {
|
||||||
|
requestMode = '&mode=thumbnail';
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetId = liveUnitId || item.id;
|
||||||
|
let baseApiUrl = `/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${targetId}&cacheKey=${cacheKey}`;
|
||||||
|
const finalUrl = baseApiUrl + requestMode + extraParams;
|
||||||
|
|
||||||
const nextCardNum = (activeCardNum === 1) ? 2 : 1;
|
const nextCardNum = (activeCardNum === 1) ? 2 : 1;
|
||||||
const nextCard = document.getElementById(`polaroid-card-${nextCardNum}`);
|
const nextCard = document.getElementById(`polaroid-card-${nextCardNum}`);
|
||||||
@ -455,9 +701,30 @@
|
|||||||
const nextImgEl = nextCard.querySelector('.card-img');
|
const nextImgEl = nextCard.querySelector('.card-img');
|
||||||
const nextVidEl = nextCard.querySelector('.card-vid');
|
const nextVidEl = nextCard.querySelector('.card-vid');
|
||||||
|
|
||||||
|
currentMetadata = { id: item.id, memo: "", tags: [] };
|
||||||
|
|
||||||
|
let imageSrc = finalUrl;
|
||||||
|
const isPlayingVideo = (requestMode === '&mode=video');
|
||||||
|
|
||||||
|
if (!isPlayingVideo) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(finalUrl);
|
||||||
|
if (!response.ok) throw new Error("Network response was not ok");
|
||||||
|
const blob = await response.blob();
|
||||||
|
imageSrc = URL.createObjectURL(blob);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Image Fetch Error:", err);
|
||||||
|
imageSrc = finalUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempImgUrl = isPlayingVideo ?
|
||||||
|
`/api/synology/image?nasAddress=${encodeURIComponent(nasAddress)}&sid=${sid}&id=${item.id}&cacheKey=${cacheKey}&mode=thumbnail` :
|
||||||
|
imageSrc;
|
||||||
|
|
||||||
const tempImg = new Image();
|
const tempImg = new Image();
|
||||||
tempImg.crossOrigin = "Anonymous";
|
tempImg.crossOrigin = "Anonymous";
|
||||||
tempImg.src = finalUrl;
|
tempImg.src = tempImgUrl;
|
||||||
|
|
||||||
tempImg.onload = function() {
|
tempImg.onload = function() {
|
||||||
const dims = calculateOptimalSize(tempImg.naturalWidth, tempImg.naturalHeight);
|
const dims = calculateOptimalSize(tempImg.naturalWidth, tempImg.naturalHeight);
|
||||||
@ -468,17 +735,18 @@
|
|||||||
|
|
||||||
resetCardInfo(nextCard, nextCardNum);
|
resetCardInfo(nextCard, nextCardNum);
|
||||||
|
|
||||||
if (isVideo) {
|
if (isPlayingVideo) {
|
||||||
nextImgEl.style.display = 'none';
|
nextImgEl.style.display = 'none';
|
||||||
nextVidEl.style.display = 'block';
|
nextVidEl.style.display = 'block';
|
||||||
|
nextVidEl.removeAttribute('src');
|
||||||
nextVidEl.src = finalUrl;
|
nextVidEl.src = finalUrl;
|
||||||
nextVidEl.load();
|
nextVidEl.load();
|
||||||
nextVidEl.play().catch(e => console.log("Auto-play blocked", e));
|
nextVidEl.play().catch(e => console.log("Auto-play blocked", e));
|
||||||
|
|
||||||
let dateText = (item.type === 'live') ? "Live Photo" : "Video";
|
let dateText = isLive ? "Live Photo" : "Video";
|
||||||
if (item.takenTime) dateText = new Date(item.takenTime * 1000).toLocaleDateString();
|
if (item.takenTime) dateText = new Date(item.takenTime * 1000).toLocaleDateString();
|
||||||
typeText(nextCard.querySelector('.p-date'), dateText, nextCardNum, 'date', null);
|
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>`;
|
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>${dateText}<br>${item.filename || ""}</div>`;
|
||||||
|
|
||||||
updateBgFromImage(tempImg);
|
updateBgFromImage(tempImg);
|
||||||
} else {
|
} else {
|
||||||
@ -486,11 +754,11 @@
|
|||||||
nextVidEl.pause();
|
nextVidEl.pause();
|
||||||
nextVidEl.removeAttribute('src');
|
nextVidEl.removeAttribute('src');
|
||||||
nextImgEl.style.display = 'block';
|
nextImgEl.style.display = 'block';
|
||||||
nextImgEl.src = tempImg.src;
|
nextImgEl.src = imageSrc;
|
||||||
|
|
||||||
let nextFullExifHtml = "<div style='text-align:center'>No Data</div>";
|
let nextFullExifHtml = "<div style='text-align:center'>No Data</div>";
|
||||||
EXIF.getData(tempImg, function() {
|
EXIF.getData(tempImg, function() {
|
||||||
bindExifData(this, nextCard, nextCardNum);
|
bindExifDataOnly(this, nextCard, nextCardNum);
|
||||||
nextFullExifHtml = generateFullExifHtml(this);
|
nextFullExifHtml = generateFullExifHtml(this);
|
||||||
if (isRaw) {
|
if (isRaw) {
|
||||||
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>RAW/HEIC Preview<br>${item.filename}</div>`;
|
document.getElementById('exif-list').innerHTML = `<div style='text-align:center'>RAW/HEIC Preview<br>${item.filename}</div>`;
|
||||||
@ -498,7 +766,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
updateBgFromImage(tempImg);
|
updateBgFromImage(tempImg);
|
||||||
|
|
||||||
if (!isRaw) {
|
if (!isRaw) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
document.getElementById('exif-list').innerHTML = nextFullExifHtml;
|
document.getElementById('exif-list').innerHTML = nextFullExifHtml;
|
||||||
@ -506,9 +773,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetchMetadataAndAddress(item, nextCard, nextCardNum);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
nextCard.classList.add('active');
|
nextCard.classList.add('active');
|
||||||
currentCard.classList.remove('active');
|
currentCard.classList.remove('active');
|
||||||
|
|
||||||
|
const prevImg = currentCard.querySelector('.card-img');
|
||||||
|
if (prevImg.src.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(prevImg.src);
|
||||||
|
prevImg.removeAttribute('src');
|
||||||
|
}
|
||||||
const prevVid = currentCard.querySelector('.card-vid');
|
const prevVid = currentCard.querySelector('.card-vid');
|
||||||
if(prevVid) { prevVid.pause(); prevVid.removeAttribute('src'); prevVid.load(); }
|
if(prevVid) { prevVid.pause(); prevVid.removeAttribute('src'); prevVid.load(); }
|
||||||
});
|
});
|
||||||
@ -527,24 +802,11 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function calculateOptimalSize(imgW, imgH) {
|
||||||
const maxW = window.innerWidth * 0.95;
|
const safeMargin = 20;
|
||||||
const maxH = window.innerHeight * 0.82;
|
const maxW = window.innerWidth - safeMargin;
|
||||||
|
const maxH = window.innerHeight * 0.85;
|
||||||
|
|
||||||
const scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--text-scale')) || 1.0;
|
const scale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--text-scale')) || 1.0;
|
||||||
const chromeH = (30 * scale) + (70 * scale) + 20;
|
const chromeH = (30 * scale) + (70 * scale) + 20;
|
||||||
const maxImgH = maxH - chromeH;
|
const maxImgH = maxH - chromeH;
|
||||||
@ -558,7 +820,6 @@
|
|||||||
targetImgW = targetImgW * scaleFactor;
|
targetImgW = targetImgW * scaleFactor;
|
||||||
targetImgH = targetImgH * scaleFactor;
|
targetImgH = targetImgH * scaleFactor;
|
||||||
}
|
}
|
||||||
if (targetImgW < 320) targetImgW = 320;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cardWidth: Math.round(targetImgW),
|
cardWidth: Math.round(targetImgW),
|
||||||
@ -570,14 +831,9 @@
|
|||||||
card.querySelector('.p-camera').innerHTML = '<i class="fas fa-camera"></i> -';
|
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-zoom').innerHTML = '<i class="fas fa-search"></i> -';
|
||||||
card.querySelector('.p-coords').innerText = '';
|
card.querySelector('.p-coords').innerText = '';
|
||||||
|
card.querySelector('.p-date').innerText = '';
|
||||||
const dateEl = card.querySelector('.p-date');
|
card.querySelector('.p-address').innerText = '';
|
||||||
dateEl.innerText = ''; dateEl.classList.remove('typing-cursor');
|
card.querySelector('.p-meta-info').innerHTML = '';
|
||||||
|
|
||||||
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].date) clearTimeout(typingTimers[cardNum].date);
|
||||||
if(typingTimers[cardNum].addr) clearTimeout(typingTimers[cardNum].addr);
|
if(typingTimers[cardNum].addr) clearTimeout(typingTimers[cardNum].addr);
|
||||||
@ -601,7 +857,7 @@
|
|||||||
typeFunc();
|
typeFunc();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindExifData(img, card, cardNum) {
|
function bindExifDataOnly(img, card, cardNum) {
|
||||||
const make = EXIF.getTag(img, "Make"); const model = EXIF.getTag(img, "Model");
|
const make = EXIF.getTag(img, "Make"); const model = EXIF.getTag(img, "Model");
|
||||||
let camText = model ? model : (make ? make : "Unknown Camera");
|
let camText = model ? model : (make ? make : "Unknown Camera");
|
||||||
if(camText.length > 20) camText = camText.substring(0, 18) + "..";
|
if(camText.length > 20) camText = camText.substring(0, 18) + "..";
|
||||||
@ -616,42 +872,55 @@
|
|||||||
const parts = dateTaken.split(/[: ]/);
|
const parts = dateTaken.split(/[: ]/);
|
||||||
if(parts.length >= 3) dateStr = `${parts[0]}. ${parts[1]}. ${parts[2]}.`;
|
if(parts.length >= 3) dateStr = `${parts[0]}. ${parts[1]}. ${parts[2]}.`;
|
||||||
}
|
}
|
||||||
|
typeText(card.querySelector('.p-date'), dateStr, cardNum, 'date');
|
||||||
|
|
||||||
const lat = EXIF.getTag(img, "GPSLatitude"); const lon = EXIF.getTag(img, "GPSLongitude");
|
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) {
|
if (lat && lon) {
|
||||||
const latRef = EXIF.getTag(img, "GPSLatitudeRef"); const lonRef = EXIF.getTag(img, "GPSLongitudeRef");
|
const latRef = EXIF.getTag(img, "GPSLatitudeRef"); const lonRef = EXIF.getTag(img, "GPSLongitudeRef");
|
||||||
const decLat = convertDMSToDD(lat, latRef); const decLon = convertDMSToDD(lon, lonRef);
|
const decLat = convertDMSToDD(lat, latRef); const decLon = convertDMSToDD(lon, lonRef);
|
||||||
card.querySelector('.p-coords').innerText = `${decLat.toFixed(4)}, ${decLon.toFixed(4)}`;
|
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', () => {
|
function fetchMetadataAndAddress(item, card, cardNum) {
|
||||||
addrPromise.then(addrText => {
|
fetch(`/api/synology/geocode?id=${item.id}&lat=0&lon=0`, {
|
||||||
typeText(addrEl, addrText, cardNum, 'addr', null);
|
headers: { [csrfHeader]: csrfToken }
|
||||||
});
|
})
|
||||||
});
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if(item.id == currentMetadata.id) {
|
||||||
|
currentMetadata.memo = data.memo;
|
||||||
|
currentMetadata.tags = data.tags;
|
||||||
|
|
||||||
const desc = EXIF.getTag(img, "ImageDescription");
|
let addrText = "";
|
||||||
card.querySelector('.p-tags').innerText = (desc && desc.trim() !== "") ? `#${desc}` : "";
|
if (data.address && data.address !== 'Unknown') {
|
||||||
|
if (typeof data.address === 'object') {
|
||||||
|
addrText = data.address.display_name || "Location Found";
|
||||||
|
} else {
|
||||||
|
addrText = data.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(addrText) {
|
||||||
|
typeText(card.querySelector('.p-address'), addrText, cardNum, 'addr');
|
||||||
|
}
|
||||||
|
updateCardMetaUI(cardNum);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 convertDMSToDD(coord, ref) {
|
function convertDMSToDD(coord, ref) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user