diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json
new file mode 100644
index 0000000..91d8560
--- /dev/null
+++ b/locales/server/ko-kr.json
@@ -0,0 +1,166 @@
+{
+ "common": {
+ "back": "← 돌아가기",
+ "backToList": "목록으로",
+ "save": "저장",
+ "cancel": "취소",
+ "ok": "확인",
+ "delete": "삭제",
+ "edit": "수정",
+ "close": "×",
+ "loading": "불러오는 중..."
+ },
+ "site": {
+ "indexTitle": "음악퀴즈 목록",
+ "heroTitle": "마인크래프트 음악퀴즈",
+ "heroSubtitle": "설치기에서 사용 가능한 음악퀴즈 목록입니다.",
+ "empty": "등록된 음악퀴즈가 없습니다.",
+ "fileLabel": "파일: {{file}}.json",
+ "mcVersion": "마인크래프트",
+ "platform": "플랫폼",
+ "modsFolder": "모드 폴더",
+ "resourcepack": "리소스팩",
+ "noneFallback": "없음"
+ },
+ "nav": {
+ "brand": "관리자 페이지",
+ "logout": "로그아웃"
+ },
+ "login": {
+ "title": "관리자 로그인",
+ "password": "비밀번호",
+ "submit": "로그인",
+ "wrongPassword": "비밀번호가 올바르지 않습니다."
+ },
+ "dashboard": {
+ "title": "음악퀴즈 목록",
+ "browserTitle": "관리자 대시보드",
+ "editList": "음악목록 수정",
+ "editDatapack": "데이터팩 수정",
+ "addPack": "음악퀴즈 추가",
+ "deletePack": "음악퀴즈 삭제",
+ "emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
+ "select": "선택",
+ "confirmDelete": "삭제 확인",
+ "mcShort": "MC"
+ },
+ "list": {
+ "browserTitle": "음악목록 수정",
+ "title": "음악목록 수정"
+ },
+ "listEditor": {
+ "browserTitle": "{{name}} — 음악/사진 목록",
+ "dirtyTooltip": "저장되지 않은 변경사항이 있습니다",
+ "tabMusic": "음악목록",
+ "tabImage": "사진목록",
+ "saveList": "목록 저장",
+ "clearList": "목록 초기화",
+ "playlistPlaceholder": "유튜브 플레이리스트 URL",
+ "fetchPlaylist": "플레이리스트 불러오기",
+ "imageFromMusic": "음악목록에서 가져오기",
+ "modalConfirmTitle": "확인",
+ "musicEditTitle": "음악 항목 수정",
+ "musicEditUrl": "유튜브 영상 주소",
+ "musicEditHint": "저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.",
+ "imageEditTitle": "사진 항목 수정",
+ "imageSegYt": "유튜브 주소",
+ "imageSegImg": "이미지 주소",
+ "imageEditUrl": "주소",
+ "titleFallback": "(제목 없음)",
+ "artistFallback": "(가수 미상)",
+ "rowEditTooltip": "더블클릭해서 수정",
+ "metaLoading": "메타데이터 가져오는 중…",
+ "metaFailedShort": "메타 조회 실패",
+ "metaFailedTitle": "메타데이터 조회 실패",
+ "metaFailedAsk": "{{message}}\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?",
+ "saving": "저장 중…",
+ "saved": "저장 완료",
+ "saveFailed": "저장 실패: {{message}}",
+ "fetchEnterUrl": "플레이리스트 주소를 입력해 주세요.",
+ "fetchTitle": "플레이리스트 불러오기",
+ "fetchConfirm": "현재 {{type}}목록 순서가 모두 사라집니다. 진행할까요?",
+ "fetchTypeMusic": "음악",
+ "fetchTypeImage": "사진",
+ "fetchLoading": "불러오는 중…",
+ "fetchedCount": "{{count}}개 항목을 불러왔습니다.",
+ "failed": "실패: {{message}}",
+ "clearTitle": "목록 초기화",
+ "clearConfirm": "\"{{type}}목록\"을 비웁니다. 진행할까요?",
+ "imageFromMusicEmpty": "음악목록이 비어 있어 가져올 수 없습니다.",
+ "imageFromMusicTitle": "사진목록 가져오기",
+ "imageFromMusicConfirm": "저장된 음악목록의 영상 {{count}}개를 그대로 사진목록으로 가져옵니다.\n현재 사진목록은 모두 사라집니다. 진행할까요?",
+ "leaveTitle": "저장되지 않은 변경사항",
+ "leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
+ },
+ "editor": {
+ "browserTitle": "{{name}} 편집",
+ "eyebrow": "PACK EDITOR",
+ "displayName": "음악퀴즈 이름",
+ "fileName": "JSON 파일 이름 (확장자 제외)",
+ "mcVersion": "마인크래프트 버전",
+ "platformType": "모드 플랫폼",
+ "platformDownloadUrl": "플랫폼 설치파일 URL",
+ "platformDownloadHint": "도메인 없이 입력하면 manifest.json 도메인의 /file/platforms/<파일명>으로 해석됩니다.",
+ "platformLoaderVersion": "Fabric Loader 버전",
+ "platformLoaderHint": "선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.",
+ "platformLoaderEmpty": "호환 로더 없음",
+ "platformLoaderPickMc": "마인크래프트 버전을 먼저 선택하세요",
+ "platformLoaderLoadFailed": "로더 목록 로드 실패: {{message}}",
+ "serverMinRam": "서버 최소 램 (MB)",
+ "serverMaxRam": "서버 최대 램 (MB)",
+ "clientMinRam": "클라이언트 최소 램 (MB)",
+ "clientRecommendedRam": "클라이언트 권장 램 (MB)",
+ "mapPath": "맵 파일 (.zip)",
+ "mapPathHint": "/file/maps/ 아래 zip 파일 이름.",
+ "serverPath": "서버 파일 (.zip)",
+ "serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
+ "modsFolder": "모드 폴더 이름",
+ "modsFolderHint": "/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
+ "resourcepackPath": "리소스팩 (.zip)",
+ "resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
+ "ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
+ "fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
+ },
+ "datapack": {
+ "browserTitle": "데이터팩 수정",
+ "title": "데이터팩 수정",
+ "pickPack": "음악퀴즈 선택",
+ "pickedNone": "선택된 음악퀴즈 없음",
+ "pickedLabel": "선택: {{name}}",
+ "totalCount": "총 {{count}}개의 음악을 찾았습니다.",
+ "export": "데이터팩 출력",
+ "copy": "복사",
+ "copied": "복사됨",
+ "exporting": "출력 중…",
+ "exported": "출력 완료",
+ "failed": "실패: {{message}}",
+ "modalPickTitle": "음악퀴즈 선택"
+ },
+ "errors": {
+ "packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
+ "packNotFoundJson": "음악퀴즈를 찾을 수 없습니다.",
+ "videoUrlRequired": "영상 주소를 입력해 주세요.",
+ "playlistUrlRequired": "플레이리스트 주소를 입력해 주세요.",
+ "metaNotFound": "메타데이터를 찾을 수 없습니다.",
+ "ramOrderInvalid": "clientMinRam은 clientRecommendedRam보다 클 수 없습니다.",
+ "unknown": "알 수 없는 오류",
+ "serverError": "서버 오류: {{message}}"
+ },
+ "youtube": {
+ "ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
+ "ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
+ "ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
+ "ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
+ "ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
+ "tooManyRedirects": "redirect 가 너무 많습니다."
+ },
+ "datapackOutput": {
+ "header": "# === musicquiz: {{name}} ===",
+ "summary": "# 총 {{musicCount}}곡 / 사진 {{imageCount}}장",
+ "initLine": "say [musicquiz] 데이터팩 초기화",
+ "placeholder": "# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.",
+ "trackLine": "# {{index}}. {{title}} - {{artist}} ({{duration}}s)",
+ "titleFallback": "(제목 없음)",
+ "artistFallback": "(가수 미상)"
+ }
+}
diff --git a/public/listEditor.js b/public/listEditor.js
index 4675b04..1f779ab 100644
--- a/public/listEditor.js
+++ b/public/listEditor.js
@@ -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 @@
'' + (idx + 1) + '' +
'
' +
'
' +
- '
' +
+ '
' +
escapeHtml(entry.title || '') +
'
' +
- '
' +
+ '
' +
escapeHtml(entry.artist || '') +
'
' +
'
' +
@@ -116,7 +132,7 @@
'
 + ')
' +
'
' +
'
' +
- '
' + (escapeHtml(cap.title) || '(제목 없음)') + '
' +
+ '
' + (escapeHtml(cap.title) || ('' + escapeHtml(tt('titleFallback')) + '')) + '
' +
'
' + escapeHtml(cap.sub) + '
' +
'
'
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) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그
diff --git a/src/server/app.ts b/src/server/app.ts
index 9389211..de1f0af 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -4,6 +4,7 @@ import path from 'node:path'
import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
import { loadEnv } from '../shared/env.js'
+import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js'
import { opRouter } from './routes/op.js'
@@ -23,6 +24,14 @@ app.set('trust proxy', 1)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
+// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
+// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
+app.use((_req, res, next) => {
+ res.locals.t = t
+ res.locals.localeDict = localeDict
+ next()
+})
+
app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
resave: false,
@@ -104,8 +113,8 @@ app.use('/', opRouter)
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err)
- const message = err instanceof Error ? err.message : '알 수 없는 오류'
- res.status(500).send(`서버 오류: ${message}`)
+ const message = err instanceof Error ? err.message : t('errors.unknown')
+ res.status(500).send(t('errors.serverError', { message }))
})
app.listen(PORT, HOST, () => {
diff --git a/src/server/i18n.ts b/src/server/i18n.ts
new file mode 100644
index 0000000..3f5c81b
--- /dev/null
+++ b/src/server/i18n.ts
@@ -0,0 +1,6 @@
+import { loadComponentI18n } from '../shared/i18n.js'
+
+// 서버 진입 시 한 번 로드. routes/views 어디서든 동일한 사전을 공유.
+const i18n = loadComponentI18n('server')
+export const t = i18n.t
+export const localeDict = i18n.dict
diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts
index 53f7a48..f0c2555 100644
--- a/src/server/routes/op.ts
+++ b/src/server/routes/op.ts
@@ -16,6 +16,7 @@ import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js'
import type { PackDefinition, PackList } from '../../shared/types.js'
+import { t } from '../i18n.js'
export const opRouter = Router()
@@ -46,7 +47,7 @@ opRouter.post('/op', async (req, res, next) => {
const accounts = await readAccounts()
const matched = accounts.find((entry) => entry.password === password)
if (!matched) {
- res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
+ res.status(401).render('op/login', { error: t('login.wrongPassword') })
return
}
req.session.userId = matched.id
@@ -106,7 +107,7 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
- res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
+ res.status(404).send(t('errors.packNotFound'))
return
}
const releases = await fetchReleaseVersions()
@@ -142,7 +143,7 @@ opRouter.get('/op/list/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
- res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
+ res.status(404).send(t('errors.packNotFound'))
return
}
const list = await loadPackList(packKey)
@@ -163,7 +164,7 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
- res.status(404).json({ ok: false, message: '음악퀴즈를 찾을 수 없습니다.' })
+ res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
return
}
const normalized = normalizePackList(req.body)
@@ -179,13 +180,13 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim()
if (!url) {
- res.status(400).json({ ok: false, message: '영상 주소를 입력해 주세요.' })
+ res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') })
return
}
try {
const entry = await fetchVideoMeta(url)
if (!entry) {
- res.status(404).json({ ok: false, message: '메타데이터를 찾을 수 없습니다.' })
+ res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
return
}
res.json({ ok: true, entry })
@@ -203,7 +204,7 @@ opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) =>
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim()
if (!url) {
- res.status(400).json({ ok: false, message: '플레이리스트 주소를 입력해 주세요.' })
+ res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') })
return
}
try {
@@ -238,19 +239,27 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
- res.status(404).type('text/plain').send('음악퀴즈를 찾을 수 없습니다.')
+ res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
return
}
const list = await loadPackList(packKey)
const lines: string[] = []
- lines.push(`# === musicquiz: ${definition.name} ===`)
- lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}장`)
- lines.push(`say [musicquiz] 데이터팩 초기화`)
- lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`)
+ lines.push(t('datapackOutput.header', { name: definition.name }))
+ lines.push(t('datapackOutput.summary', {
+ musicCount: list.music.length,
+ imageCount: list.images.length
+ }))
+ lines.push(t('datapackOutput.initLine'))
+ lines.push(t('datapackOutput.placeholder'))
list.music.forEach((entry, index) => {
- const title = entry.title || '(제목 없음)'
- const artist = entry.artist || '(가수 미상)'
- lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
+ const title = entry.title || t('datapackOutput.titleFallback')
+ const artist = entry.artist || t('datapackOutput.artistFallback')
+ lines.push(t('datapackOutput.trackLine', {
+ index: index + 1,
+ title,
+ artist,
+ duration: entry.durationSec
+ }))
})
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
} catch (error) {
@@ -287,7 +296,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const normalized = normalizePackDefinition(partial)
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
- res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
+ res.status(400).send(t('errors.ramOrderInvalid'))
return
}
const finalKey = await renamePack(packKey, requestedKey, normalized)
diff --git a/src/server/youtube.ts b/src/server/youtube.ts
index 2a253b2..cb74429 100644
--- a/src/server/youtube.ts
+++ b/src/server/youtube.ts
@@ -4,6 +4,7 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
+import { t } from './i18n.js'
export interface YtPlaylistEntry {
id: string
@@ -15,7 +16,7 @@ export interface YtPlaylistEntry {
export class YtDlpUnavailableError extends Error {
constructor(message?: string) {
- super(message || 'yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)')
+ super(message || t('youtube.ytdlpUnavailable'))
}
}
@@ -62,7 +63,7 @@ export async function ensureYtDlp(): Promise
{
// 검증
const okVersion = await probeVersion(target)
if (!okVersion) {
- throw new YtDlpUnavailableError('yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.')
+ throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
}
return target
} catch (err) {
@@ -71,7 +72,7 @@ export async function ensureYtDlp(): Promise {
throw err instanceof YtDlpUnavailableError
? err
: new YtDlpUnavailableError(
- 'yt-dlp 자동 설치에 실패했습니다: ' + (err instanceof Error ? err.message : String(err))
+ t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
)
} finally {
installPromise = null
@@ -112,7 +113,7 @@ function probeVersion(bin: string): Promise {
function downloadToFile(url: string, dest: string, redirects = 0): Promise {
return new Promise((resolve, reject) => {
if (redirects > 8) {
- reject(new Error('redirect 가 너무 많습니다.'))
+ reject(new Error(t('youtube.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http
@@ -161,7 +162,7 @@ export async function fetchVideoMeta(url: string): Promise reject(err))
child.on('close', (code) => {
if (code !== 0) {
- reject(new Error(`yt-dlp 영상 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
+ reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
@@ -208,7 +209,7 @@ export async function fetchPlaylistEntries(url: string): Promise reject(err))
child.on('close', (code) => {
if (code !== 0) {
- reject(new Error(`yt-dlp 플레이리스트 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
+ reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts
new file mode 100644
index 0000000..0d7d6d4
--- /dev/null
+++ b/src/shared/i18n.ts
@@ -0,0 +1,93 @@
+import fs from 'node:fs'
+import path from 'node:path'
+
+/**
+ * 단순 키-문자열 사전. 중첩 객체도 허용해서 그룹화 가능.
+ * { step1: { title: '1단계. 음악퀴즈 선택' } }
+ * t('step1.title') → '1단계. 음악퀴즈 선택'
+ */
+export type Locale = Record
+
+/**
+ * 자유 형식 ko-kr.json 을 로드하고 `t(key, params)` 헬퍼를 만들어 반환.
+ *
+ * 사용 패턴:
+ * const { t, dict } = createI18n(path.join(__dirname, 'locales', 'ko-kr.json'))
+ * t('step1.title')
+ * t('install.downloading', { idx: 3 }) // → '3번 노래 다운로드 중…'
+ *
+ * 키가 사전에 없으면 키 자체를 반환(개발 중 누락 빨리 찾도록).
+ * 사전이 비어 있어도 빌드는 깨지지 않고 키만 노출.
+ */
+export interface I18n {
+ /** 키로 문자열 lookup. 누락 시 키 그대로 반환. */
+ t(key: string, params?: Record): string
+ /** 렌더러로 전달하기 위한 원본 사전(JSON 그대로). */
+ dict: Locale
+}
+
+export function createI18n(filePath: string): I18n {
+ let dict: Locale = {}
+ try {
+ const raw = fs.readFileSync(filePath, 'utf-8')
+ dict = JSON.parse(raw) as Locale
+ } catch {
+ // 파일이 없거나 깨진 경우 빈 사전. t() 가 키 자체를 돌려주므로 UI 가 깨지진 않음.
+ dict = {}
+ }
+
+ function lookup(key: string): string | undefined {
+ const parts = key.split('.')
+ let cur: unknown = dict
+ for (const p of parts) {
+ if (cur && typeof cur === 'object' && p in (cur as Record)) {
+ cur = (cur as Record)[p]
+ } else {
+ return undefined
+ }
+ }
+ return typeof cur === 'string' ? cur : undefined
+ }
+
+ function interpolate(tpl: string, params?: Record): string {
+ if (!params) return tpl
+ return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name: string) => {
+ return name in params ? String(params[name]) : `{{${name}}}`
+ })
+ }
+
+ return {
+ t(key, params) {
+ const found = lookup(key)
+ return interpolate(found ?? key, params)
+ },
+ dict
+ }
+}
+
+/**
+ * 진입점에서 호출할 표준 로더. 컴포넌트 이름과 `__dirname`(컴파일 후) 만 주면
+ * `locales//ko-kr.json` 을 찾아 로드.
+ *
+ * 탐색 순서(처음 발견된 것만 사용):
+ * 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales//ko-kr.json`
+ * 2. `<프로젝트 루트>/locales//ko-kr.json`
+ */
+export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n {
+ // 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
+ const projectRoot = path.resolve(__dirname, '..', '..')
+
+ const candidates: string[] = []
+ const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
+ if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
+ candidates.push(path.join(resourcesPath, 'locales', component, 'ko-kr.json'))
+ }
+ candidates.push(path.join(projectRoot, 'locales', component, 'ko-kr.json'))
+
+ for (const p of candidates) {
+ if (fs.existsSync(p)) {
+ return createI18n(p)
+ }
+ }
+ return createI18n(candidates[candidates.length - 1] ?? '')
+}
diff --git a/views/index.ejs b/views/index.ejs
index 96a396c..fcf25ed 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -3,29 +3,29 @@
- 음악퀴즈 목록
+ <%= t('site.indexTitle') %>
- 마인크래프트 음악퀴즈
- 설치기에서 사용 가능한 음악퀴즈 목록입니다.
+ <%= t('site.heroTitle') %>
+ <%= t('site.heroSubtitle') %>