Warn on unsaved navigation in list editor
Track a dirty flag set by every state mutation (inline edit, drag, delete, modal save, fetch playlist, clear, image-from-music, playlist URL input) and cleared by a successful save. Intercept back-link clicks with the existing ask() confirm modal. Use beforeunload for tab close / refresh. Also refactor ask() so cancel paths properly discard the pending callback instead of leaking handlers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,11 @@
|
|||||||
images: Array.isArray(INITIAL.images) ? INITIAL.images.slice() : []
|
images: Array.isArray(INITIAL.images) ? INITIAL.images.slice() : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 저장되지 않은 변경 추적
|
||||||
|
var dirty = false
|
||||||
|
function markDirty() { dirty = true }
|
||||||
|
function markClean() { dirty = false }
|
||||||
|
|
||||||
// ── 탭 ────────────────────────────────────────────
|
// ── 탭 ────────────────────────────────────────────
|
||||||
var tabBtns = document.querySelectorAll('.tabBtn')
|
var tabBtns = document.querySelectorAll('.tabBtn')
|
||||||
tabBtns.forEach(function (btn) {
|
tabBtns.forEach(function (btn) {
|
||||||
@@ -144,8 +149,11 @@
|
|||||||
var field = el.getAttribute('data-field')
|
var field = el.getAttribute('data-field')
|
||||||
var value = (el.textContent || '').replace(/\r?\n/g, ' ').trim()
|
var value = (el.textContent || '').replace(/\r?\n/g, ' ').trim()
|
||||||
if (!state.music[idx]) return
|
if (!state.music[idx]) return
|
||||||
|
var prev = field === 'title' ? state.music[idx].title : state.music[idx].artist
|
||||||
|
if (value === prev) return
|
||||||
if (field === 'title') state.music[idx].title = value
|
if (field === 'title') state.music[idx].title = value
|
||||||
else if (field === 'artist') state.music[idx].artist = value
|
else if (field === 'artist') state.music[idx].artist = value
|
||||||
|
markDirty()
|
||||||
})
|
})
|
||||||
el.addEventListener('keydown', function (e) {
|
el.addEventListener('keydown', function (e) {
|
||||||
if (el.getAttribute('contenteditable') !== 'true') return
|
if (el.getAttribute('contenteditable') !== 'true') return
|
||||||
@@ -227,8 +235,11 @@
|
|||||||
var arr = (type === 'music') ? state.music : state.images
|
var arr = (type === 'music') ? state.music : state.images
|
||||||
// 원래 인덱스: state 에서 동일 url 을 찾는 대신 data-index 가 렌더 시점의 위치이므로 사용.
|
// 원래 인덱스: state 에서 동일 url 을 찾는 대신 data-index 가 렌더 시점의 위치이므로 사용.
|
||||||
var srcIdx = Number(drag.srcEl.dataset.index)
|
var srcIdx = Number(drag.srcEl.dataset.index)
|
||||||
|
if (srcIdx !== newIdx) {
|
||||||
var moved = arr.splice(srcIdx, 1)[0]
|
var moved = arr.splice(srcIdx, 1)[0]
|
||||||
arr.splice(newIdx, 0, moved)
|
arr.splice(newIdx, 0, moved)
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
cleanupDrag()
|
cleanupDrag()
|
||||||
if (type === 'music') renderMusic(); else renderImage()
|
if (type === 'music') renderMusic(); else renderImage()
|
||||||
})
|
})
|
||||||
@@ -268,6 +279,7 @@
|
|||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
if (t.type === 'music') state.music.splice(t.index, 1)
|
if (t.type === 'music') state.music.splice(t.index, 1)
|
||||||
else state.images.splice(t.index, 1)
|
else state.images.splice(t.index, 1)
|
||||||
|
markDirty()
|
||||||
if (t.type === 'music') renderMusic(); else renderImage()
|
if (t.type === 'music') renderMusic(); else renderImage()
|
||||||
} else if (action === 'edit') {
|
} else if (action === 'edit') {
|
||||||
openEditModal(t.type, t.index)
|
openEditModal(t.type, t.index)
|
||||||
@@ -324,6 +336,7 @@
|
|||||||
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
|
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
|
||||||
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
|
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
|
||||||
state.music[editingIdx].url = url
|
state.music[editingIdx].url = url
|
||||||
|
markDirty()
|
||||||
closeAllModals()
|
closeAllModals()
|
||||||
renderMusic()
|
renderMusic()
|
||||||
})
|
})
|
||||||
@@ -337,6 +350,7 @@
|
|||||||
artist: meta.channel || prev.artist || '',
|
artist: meta.channel || prev.artist || '',
|
||||||
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
|
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
|
||||||
}
|
}
|
||||||
|
markDirty()
|
||||||
closeAllModals()
|
closeAllModals()
|
||||||
renderMusic()
|
renderMusic()
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
@@ -358,7 +372,10 @@
|
|||||||
document.getElementById('edit-image-save').addEventListener('click', function () {
|
document.getElementById('edit-image-save').addEventListener('click', function () {
|
||||||
var url = document.getElementById('edit-image-url').value.trim()
|
var url = document.getElementById('edit-image-url').value.trim()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
|
if (state.images[editingIdx].url !== url) {
|
||||||
state.images[editingIdx].url = url
|
state.images[editingIdx].url = url
|
||||||
|
markDirty()
|
||||||
|
}
|
||||||
closeAllModals()
|
closeAllModals()
|
||||||
renderImage()
|
renderImage()
|
||||||
})
|
})
|
||||||
@@ -374,6 +391,7 @@
|
|||||||
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
|
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
|
||||||
function () {
|
function () {
|
||||||
state.images = state.music.map(function (m) { return { url: m.url } })
|
state.images = state.music.map(function (m) { return { url: m.url } })
|
||||||
|
markDirty()
|
||||||
renderImage()
|
renderImage()
|
||||||
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
|
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
|
||||||
})
|
})
|
||||||
@@ -381,18 +399,26 @@
|
|||||||
|
|
||||||
// ── 액션 (save/clear/fetch) ───────────────────────
|
// ── 액션 (save/clear/fetch) ───────────────────────
|
||||||
var confirmModal = document.getElementById('confirmModal')
|
var confirmModal = document.getElementById('confirmModal')
|
||||||
|
var pendingOk = null
|
||||||
function ask(title, message, onOk) {
|
function ask(title, message, onOk) {
|
||||||
document.getElementById('confirm-title').textContent = title
|
document.getElementById('confirm-title').textContent = title
|
||||||
document.getElementById('confirm-message').textContent = message
|
document.getElementById('confirm-message').textContent = message
|
||||||
confirmModal.hidden = false
|
confirmModal.hidden = false
|
||||||
var ok = document.getElementById('confirm-ok')
|
pendingOk = onOk
|
||||||
var handler = function () {
|
}
|
||||||
ok.removeEventListener('click', handler)
|
document.getElementById('confirm-ok').addEventListener('click', function () {
|
||||||
confirmModal.hidden = true
|
confirmModal.hidden = true
|
||||||
onOk()
|
var fn = pendingOk
|
||||||
}
|
pendingOk = null
|
||||||
ok.addEventListener('click', handler)
|
if (fn) fn()
|
||||||
}
|
})
|
||||||
|
// 취소(×, 취소 버튼, 배경 클릭)로 닫히면 pending 콜백 폐기.
|
||||||
|
confirmModal.querySelectorAll('[data-modal-close]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { pendingOk = null })
|
||||||
|
})
|
||||||
|
confirmModal.addEventListener('click', function (e) {
|
||||||
|
if (e.target === confirmModal) pendingOk = null
|
||||||
|
})
|
||||||
|
|
||||||
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
@@ -402,6 +428,7 @@
|
|||||||
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
|
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
|
||||||
if (target === 'music') { state.music = []; renderMusic() }
|
if (target === 'music') { state.music = []; renderMusic() }
|
||||||
else { state.images = []; renderImage() }
|
else { state.images = []; renderImage() }
|
||||||
|
markDirty()
|
||||||
})
|
})
|
||||||
} else if (action === 'save') {
|
} else if (action === 'save') {
|
||||||
doSave(target)
|
doSave(target)
|
||||||
@@ -432,7 +459,7 @@
|
|||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||||
}).then(function (result) {
|
}).then(function (result) {
|
||||||
if (result.ok && result.body.ok) setStatus(statusId, '저장 완료')
|
if (result.ok && result.body.ok) { setStatus(statusId, '저장 완료'); markClean() }
|
||||||
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
|
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus(statusId, '저장 실패: ' + err.message, true)
|
setStatus(statusId, '저장 실패: ' + err.message, true)
|
||||||
@@ -469,6 +496,7 @@
|
|||||||
state.images = entries.map(function (e) { return { url: e.url } })
|
state.images = entries.map(function (e) { return { url: e.url } })
|
||||||
renderImage()
|
renderImage()
|
||||||
}
|
}
|
||||||
|
markDirty()
|
||||||
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
|
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus('status-' + target, '실패: ' + err.message, true)
|
setStatus('status-' + target, '실패: ' + err.message, true)
|
||||||
@@ -476,6 +504,38 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 플레이리스트 URL 입력 변경 추적
|
||||||
|
;['music-playlist-url', 'image-playlist-url'].forEach(function (id) {
|
||||||
|
var el = document.getElementById(id)
|
||||||
|
if (!el) return
|
||||||
|
var initialValue = el.value
|
||||||
|
el.addEventListener('input', function () {
|
||||||
|
if (el.value !== initialValue) markDirty()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 페이지 이탈 가드 ───────────────────────────────
|
||||||
|
// 1) 돌아가기 링크 : 커스텀 확인 팝업
|
||||||
|
document.querySelectorAll('a.ghostLink').forEach(function (a) {
|
||||||
|
a.addEventListener('click', function (e) {
|
||||||
|
if (!dirty) return
|
||||||
|
e.preventDefault()
|
||||||
|
var href = a.getAttribute('href')
|
||||||
|
ask('저장되지 않은 변경사항',
|
||||||
|
'저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?',
|
||||||
|
function () {
|
||||||
|
markClean()
|
||||||
|
window.location.href = href
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그
|
||||||
|
window.addEventListener('beforeunload', function (e) {
|
||||||
|
if (!dirty) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.returnValue = ''
|
||||||
|
})
|
||||||
|
|
||||||
// 초기 렌더
|
// 초기 렌더
|
||||||
renderMusic()
|
renderMusic()
|
||||||
renderImage()
|
renderImage()
|
||||||
|
|||||||
Reference in New Issue
Block a user