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:
2026-05-13 03:43:04 +09:00
parent 401d72622e
commit c2fcc2fbbf
15 changed files with 490 additions and 172 deletions

View File

@@ -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) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그