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:
2026-05-12 14:55:47 +09:00
parent 633a895617
commit 4d18c93369

View File

@@ -8,6 +8,11 @@
images: Array.isArray(INITIAL.images) ? INITIAL.images.slice() : []
}
// 저장되지 않은 변경 추적
var dirty = false
function markDirty() { dirty = true }
function markClean() { dirty = false }
// ── 탭 ────────────────────────────────────────────
var tabBtns = document.querySelectorAll('.tabBtn')
tabBtns.forEach(function (btn) {
@@ -144,8 +149,11 @@
var field = el.getAttribute('data-field')
var value = (el.textContent || '').replace(/\r?\n/g, ' ').trim()
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
else if (field === 'artist') state.music[idx].artist = value
markDirty()
})
el.addEventListener('keydown', function (e) {
if (el.getAttribute('contenteditable') !== 'true') return
@@ -227,8 +235,11 @@
var arr = (type === 'music') ? state.music : state.images
// 원래 인덱스: state 에서 동일 url 을 찾는 대신 data-index 가 렌더 시점의 위치이므로 사용.
var srcIdx = Number(drag.srcEl.dataset.index)
var moved = arr.splice(srcIdx, 1)[0]
arr.splice(newIdx, 0, moved)
if (srcIdx !== newIdx) {
var moved = arr.splice(srcIdx, 1)[0]
arr.splice(newIdx, 0, moved)
markDirty()
}
cleanupDrag()
if (type === 'music') renderMusic(); else renderImage()
})
@@ -268,6 +279,7 @@
if (action === 'delete') {
if (t.type === 'music') state.music.splice(t.index, 1)
else state.images.splice(t.index, 1)
markDirty()
if (t.type === 'music') renderMusic(); else renderImage()
} else if (action === 'edit') {
openEditModal(t.type, t.index)
@@ -324,6 +336,7 @@
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
state.music[editingIdx].url = url
markDirty()
closeAllModals()
renderMusic()
})
@@ -337,6 +350,7 @@
artist: meta.channel || prev.artist || '',
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
}
markDirty()
closeAllModals()
renderMusic()
}).catch(function (err) {
@@ -358,7 +372,10 @@
document.getElementById('edit-image-save').addEventListener('click', function () {
var url = document.getElementById('edit-image-url').value.trim()
if (!url) return
state.images[editingIdx].url = url
if (state.images[editingIdx].url !== url) {
state.images[editingIdx].url = url
markDirty()
}
closeAllModals()
renderImage()
})
@@ -374,6 +391,7 @@
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
function () {
state.images = state.music.map(function (m) { return { url: m.url } })
markDirty()
renderImage()
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
})
@@ -381,18 +399,26 @@
// ── 액션 (save/clear/fetch) ───────────────────────
var confirmModal = document.getElementById('confirmModal')
var pendingOk = null
function ask(title, message, onOk) {
document.getElementById('confirm-title').textContent = title
document.getElementById('confirm-message').textContent = message
confirmModal.hidden = false
var ok = document.getElementById('confirm-ok')
var handler = function () {
ok.removeEventListener('click', handler)
confirmModal.hidden = true
onOk()
}
ok.addEventListener('click', handler)
pendingOk = onOk
}
document.getElementById('confirm-ok').addEventListener('click', function () {
confirmModal.hidden = true
var fn = pendingOk
pendingOk = null
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) {
btn.addEventListener('click', function () {
@@ -402,6 +428,7 @@
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
if (target === 'music') { state.music = []; renderMusic() }
else { state.images = []; renderImage() }
markDirty()
})
} else if (action === 'save') {
doSave(target)
@@ -432,7 +459,7 @@
}).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).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)
}).catch(function (err) {
setStatus(statusId, '저장 실패: ' + err.message, true)
@@ -469,6 +496,7 @@
state.images = entries.map(function (e) { return { url: e.url } })
renderImage()
}
markDirty()
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
}).catch(function (err) {
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()
renderImage()