i18n: 서버 측 모든 UI 문구를 locales/server/ko-kr.json 으로 분리
- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
This commit is contained in:
@@ -1,6 +1,22 @@
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
// listEditor.ejs 에서 주입되는 사전 (locales/server/ko-kr.json 의 listEditor + common 섹션).
|
||||
// 키가 비어 있어도 lookup 함수가 키를 그대로 반환해 UI 가 깨지지는 않는다.
|
||||
function tt(key, params) {
|
||||
var parts = key.split('.')
|
||||
var cur = (typeof I18N !== 'undefined') ? I18N : {}
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
|
||||
else { cur = null; break }
|
||||
}
|
||||
var tpl = (typeof cur === 'string') ? cur : key
|
||||
if (!params) return tpl
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
||||
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
|
||||
})
|
||||
}
|
||||
|
||||
var state = {
|
||||
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
|
||||
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
|
||||
@@ -87,10 +103,10 @@
|
||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||
'<div class="rowMeta">' +
|
||||
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="(제목 없음)" title="더블클릭해서 수정">' +
|
||||
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="' + escapeHtml(tt('titleFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
|
||||
escapeHtml(entry.title || '') +
|
||||
'</div>' +
|
||||
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="(가수 미상)" title="더블클릭해서 수정">' +
|
||||
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="' + escapeHtml(tt('artistFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
|
||||
escapeHtml(entry.artist || '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
@@ -116,7 +132,7 @@
|
||||
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
||||
'</div>' +
|
||||
'<div class="cardCaption">' +
|
||||
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || '<span class="muted">(제목 없음)</span>') + '</div>' +
|
||||
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || ('<span class="muted">' + escapeHtml(tt('titleFallback')) + '</span>')) + '</div>' +
|
||||
'<div class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
|
||||
'</div>'
|
||||
attachDraggable(card, 'image', idx)
|
||||
@@ -330,7 +346,7 @@
|
||||
if (!url) return
|
||||
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
|
||||
if (url === prev.url) { closeAllModals(); return }
|
||||
setStatus('edit-music-status', '메타데이터 가져오는 중…')
|
||||
setStatus('edit-music-status', tt('metaLoading'))
|
||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -339,8 +355,8 @@
|
||||
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
|
||||
}).then(function (result) {
|
||||
if (!result.ok || !result.body || !result.body.ok) {
|
||||
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
|
||||
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
|
||||
var msg = (result.body && result.body.message) ? result.body.message : tt('metaFailedShort')
|
||||
ask(tt('metaFailedTitle'), tt('metaFailedAsk', { message: msg }), function () {
|
||||
state.music[editingIdx].url = url
|
||||
markDirty()
|
||||
closeAllModals()
|
||||
@@ -360,7 +376,7 @@
|
||||
closeAllModals()
|
||||
renderMusic()
|
||||
}).catch(function (err) {
|
||||
setStatus('edit-music-status', '실패: ' + err.message, true)
|
||||
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -389,17 +405,16 @@
|
||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||
if (state.music.length === 0) {
|
||||
setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true)
|
||||
setStatus('status-image', tt('imageFromMusicEmpty'), true)
|
||||
return
|
||||
}
|
||||
ask('사진목록 가져오기',
|
||||
'저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n'
|
||||
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
|
||||
ask(tt('imageFromMusicTitle'),
|
||||
tt('imageFromMusicConfirm', { count: state.music.length }),
|
||||
function () {
|
||||
state.images = state.music.map(function (m) { return { url: m.url } })
|
||||
markDirty()
|
||||
renderImage()
|
||||
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
|
||||
setStatus('status-image', tt('fetchedCount', { count: state.images.length }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -431,7 +446,8 @@
|
||||
var action = btn.getAttribute('data-action')
|
||||
var target = btn.getAttribute('data-target')
|
||||
if (action === 'clear') {
|
||||
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
|
||||
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
|
||||
ask(tt('clearTitle'), tt('clearConfirm', { type: typeLabel }), function () {
|
||||
if (target === 'music') { state.music = []; renderMusic() }
|
||||
else { state.images = []; renderImage() }
|
||||
markDirty()
|
||||
@@ -457,7 +473,7 @@
|
||||
}
|
||||
})
|
||||
var statusId = 'status-' + target
|
||||
setStatus(statusId, '저장 중…')
|
||||
setStatus(statusId, tt('saving'))
|
||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -465,10 +481,10 @@
|
||||
}).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, '저장 완료'); markClean() }
|
||||
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
|
||||
if (result.ok && result.body.ok) { setStatus(statusId, tt('saved')); markClean() }
|
||||
else setStatus(statusId, tt('saveFailed', { message: result.body.message || '' }), true)
|
||||
}).catch(function (err) {
|
||||
setStatus(statusId, '저장 실패: ' + err.message, true)
|
||||
setStatus(statusId, tt('saveFailed', { message: err.message }), true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -476,11 +492,12 @@
|
||||
var input = document.getElementById(target + '-playlist-url')
|
||||
var url = input.value.trim()
|
||||
if (!url) {
|
||||
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true)
|
||||
setStatus('status-' + target, tt('fetchEnterUrl'), true)
|
||||
return
|
||||
}
|
||||
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () {
|
||||
setStatus('status-' + target, '불러오는 중…')
|
||||
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
|
||||
ask(tt('fetchTitle'), tt('fetchConfirm', { type: typeLabel }), function () {
|
||||
setStatus('status-' + target, tt('fetchLoading'))
|
||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -489,7 +506,7 @@
|
||||
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||
}).then(function (result) {
|
||||
if (!result.ok || !result.body.ok) {
|
||||
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true)
|
||||
setStatus('status-' + target, tt('failed', { message: result.body.message || '' }), true)
|
||||
return
|
||||
}
|
||||
var entries = result.body.entries || []
|
||||
@@ -503,9 +520,9 @@
|
||||
renderImage()
|
||||
}
|
||||
markDirty()
|
||||
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
|
||||
setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
|
||||
}).catch(function (err) {
|
||||
setStatus('status-' + target, '실패: ' + err.message, true)
|
||||
setStatus('status-' + target, tt('failed', { message: err.message }), true)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -527,12 +544,10 @@
|
||||
if (!dirty) return
|
||||
e.preventDefault()
|
||||
var href = a.getAttribute('href')
|
||||
ask('저장되지 않은 변경사항',
|
||||
'저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?',
|
||||
function () {
|
||||
markClean()
|
||||
window.location.href = href
|
||||
})
|
||||
ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
|
||||
markClean()
|
||||
window.location.href = href
|
||||
})
|
||||
})
|
||||
})
|
||||
// 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그
|
||||
|
||||
Reference in New Issue
Block a user