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') %>

<% if (packs.length === 0) { %> -

등록된 음악퀴즈가 없습니다.

+

<%= t('site.empty') %>

<% } %> <% packs.forEach(function (entry) { %>

<%= entry.name %>

-

파일: <%= entry.file %>.json

+

<%= t('site.fileLabel', { file: entry.file }) %>

<% if (entry.definition) { %>
    -
  • 마인크래프트 <%= entry.definition.mcVersion %>
  • -
  • 플랫폼 <%= entry.definition.platform.type %>
  • -
  • 모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %>
  • +
  • <%= t('site.mcVersion') %> <%= entry.definition.mcVersion %>
  • +
  • <%= t('site.platform') %> <%= entry.definition.platform.type %>
  • +
  • <%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %>
<% } %>
diff --git a/views/op/dashboard.ejs b/views/op/dashboard.ejs index dd618c5..65f577f 100644 --- a/views/op/dashboard.ejs +++ b/views/op/dashboard.ejs @@ -3,7 +3,7 @@ - 관리자 대시보드 + <%= t('dashboard.browserTitle') %> @@ -11,36 +11,36 @@
-

음악퀴즈 목록

+

<%= t('dashboard.title') %>

- 음악목록 수정 - 데이터팩 수정 + <%= t('dashboard.editList') %> + <%= t('dashboard.editDatapack') %>
- +
- +
<% if (items.length === 0) { %> -

등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.

+

<%= t('dashboard.emptyHint') %>

<% } %> <% items.forEach(function (item) { %>

<%= item.definition ? item.definition.name : item.key %>

<%= item.key %>.json

<% if (item.definition) { %>
    -
  • MC <%= item.definition.mcVersion %>
  • -
  • 플랫폼 <%= item.definition.platform.type %>
  • -
  • 모드 폴더 <%= item.definition.modsFolder || '없음' %>
  • +
  • <%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %>
  • +
  • <%= t('site.platform') %> <%= item.definition.platform.type %>
  • +
  • <%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %>
<% } %>
@@ -48,8 +48,8 @@ <% }) %>
diff --git a/views/op/datapack.ejs b/views/op/datapack.ejs index a883695..351fbc1 100644 --- a/views/op/datapack.ejs +++ b/views/op/datapack.ejs @@ -3,7 +3,7 @@ - 데이터팩 수정 + <%= t('datapack.browserTitle') %> @@ -12,21 +12,21 @@
- ← 돌아가기 -

데이터팩 수정

+ <%= t('common.back') %> +

<%= t('datapack.title') %>

- - 선택된 음악퀴즈 없음 + + <%= t('datapack.pickedNone') %>

@@ -36,8 +36,8 @@