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

166
locales/server/ko-kr.json Normal file
View File

@@ -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 도메인의 <code>/file/platforms/&lt;파일명&gt;</code>으로 해석됩니다.",
"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/&lt;폴더이름&gt;/ 안의 모든 .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": "(가수 미상)"
}
}

View File

@@ -1,6 +1,22 @@
(function () { (function () {
'use strict' '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 = { var state = {
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '', musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '', imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
@@ -87,10 +103,10 @@
'<span class="rowNum">' + (idx + 1) + '</span>' + '<span class="rowNum">' + (idx + 1) + '</span>' +
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' + '<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
'<div class="rowMeta">' + '<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 || '') + escapeHtml(entry.title || '') +
'</div>' + '</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 || '') + escapeHtml(entry.artist || '') +
'</div>' + '</div>' +
'</div>' + '</div>' +
@@ -116,7 +132,7 @@
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' + '<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
'</div>' + '</div>' +
'<div class="cardCaption">' + '<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 class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
'</div>' '</div>'
attachDraggable(card, 'image', idx) attachDraggable(card, 'image', idx)
@@ -330,7 +346,7 @@
if (!url) return if (!url) return
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 } var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
if (url === prev.url) { closeAllModals(); return } if (url === prev.url) { closeAllModals(); return }
setStatus('edit-music-status', '메타데이터 가져오는 중…') setStatus('edit-music-status', tt('metaLoading'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', { fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -339,8 +355,8 @@
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } }) return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
}).then(function (result) { }).then(function (result) {
if (!result.ok || !result.body || !result.body.ok) { if (!result.ok || !result.body || !result.body.ok) {
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패' var msg = (result.body && result.body.message) ? result.body.message : tt('metaFailedShort')
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () { ask(tt('metaFailedTitle'), tt('metaFailedAsk', { message: msg }), function () {
state.music[editingIdx].url = url state.music[editingIdx].url = url
markDirty() markDirty()
closeAllModals() closeAllModals()
@@ -360,7 +376,7 @@
closeAllModals() closeAllModals()
renderMusic() renderMusic()
}).catch(function (err) { }).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 () { document.getElementById('image-from-music').addEventListener('click', function () {
if (state.music.length === 0) { if (state.music.length === 0) {
setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true) setStatus('status-image', tt('imageFromMusicEmpty'), true)
return return
} }
ask('사진목록 가져오기', ask(tt('imageFromMusicTitle'),
'저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n' tt('imageFromMusicConfirm', { count: state.music.length }),
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
function () { function () {
state.images = state.music.map(function (m) { return { url: m.url } }) state.images = state.music.map(function (m) { return { url: m.url } })
markDirty() markDirty()
renderImage() 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 action = btn.getAttribute('data-action')
var target = btn.getAttribute('data-target') var target = btn.getAttribute('data-target')
if (action === 'clear') { 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() } if (target === 'music') { state.music = []; renderMusic() }
else { state.images = []; renderImage() } else { state.images = []; renderImage() }
markDirty() markDirty()
@@ -457,7 +473,7 @@
} }
}) })
var statusId = 'status-' + target var statusId = 'status-' + target
setStatus(statusId, '저장 중…') setStatus(statusId, tt('saving'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY), { fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -465,10 +481,10 @@
}).then(function (r) { }).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, body: body } }) return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) { }).then(function (result) {
if (result.ok && result.body.ok) { setStatus(statusId, '저장 완료'); markClean() } if (result.ok && result.body.ok) { setStatus(statusId, tt('saved')); markClean() }
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true) else setStatus(statusId, tt('saveFailed', { message: result.body.message || '' }), true)
}).catch(function (err) { }).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 input = document.getElementById(target + '-playlist-url')
var url = input.value.trim() var url = input.value.trim()
if (!url) { if (!url) {
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true) setStatus('status-' + target, tt('fetchEnterUrl'), true)
return return
} }
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () { var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
setStatus('status-' + target, '불러오는 중…') ask(tt('fetchTitle'), tt('fetchConfirm', { type: typeLabel }), function () {
setStatus('status-' + target, tt('fetchLoading'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', { fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -489,7 +506,7 @@
return r.json().then(function (body) { return { ok: r.ok, body: body } }) return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) { }).then(function (result) {
if (!result.ok || !result.body.ok) { if (!result.ok || !result.body.ok) {
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true) setStatus('status-' + target, tt('failed', { message: result.body.message || '' }), true)
return return
} }
var entries = result.body.entries || [] var entries = result.body.entries || []
@@ -503,9 +520,9 @@
renderImage() renderImage()
} }
markDirty() markDirty()
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.') setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
}).catch(function (err) { }).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 if (!dirty) return
e.preventDefault() e.preventDefault()
var href = a.getAttribute('href') var href = a.getAttribute('href')
ask('저장되지 않은 변경사항', ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
'저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?', markClean()
function () { window.location.href = href
markClean() })
window.location.href = href
})
}) })
}) })
// 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그 // 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js' import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
import { loadEnv } from '../shared/env.js' import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js' import { indexRouter } from './routes/index.js'
import { opRouter } from './routes/op.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.urlencoded({ extended: true }))
app.use(express.json()) 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({ app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret', secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
resave: false, resave: false,
@@ -104,8 +113,8 @@ app.use('/', opRouter)
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err) console.error(err)
const message = err instanceof Error ? err.message : '알 수 없는 오류' const message = err instanceof Error ? err.message : t('errors.unknown')
res.status(500).send(`서버 오류: ${message}`) res.status(500).send(t('errors.serverError', { message }))
}) })
app.listen(PORT, HOST, () => { app.listen(PORT, HOST, () => {

6
src/server/i18n.ts Normal file
View File

@@ -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

View File

@@ -16,6 +16,7 @@ import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js' import { requireAuth } from '../middleware/auth.js'
import type { PackDefinition, PackList } from '../../shared/types.js' import type { PackDefinition, PackList } from '../../shared/types.js'
import { t } from '../i18n.js'
export const opRouter = Router() export const opRouter = Router()
@@ -46,7 +47,7 @@ opRouter.post('/op', async (req, res, next) => {
const accounts = await readAccounts() const accounts = await readAccounts()
const matched = accounts.find((entry) => entry.password === password) const matched = accounts.find((entry) => entry.password === password)
if (!matched) { if (!matched) {
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' }) res.status(401).render('op/login', { error: t('login.wrongPassword') })
return return
} }
req.session.userId = matched.id 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 packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey) const definition = await loadPackDefinition(packKey)
if (!definition) { if (!definition) {
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.') res.status(404).send(t('errors.packNotFound'))
return return
} }
const releases = await fetchReleaseVersions() 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 packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey) const definition = await loadPackDefinition(packKey)
if (!definition) { if (!definition) {
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.') res.status(404).send(t('errors.packNotFound'))
return return
} }
const list = await loadPackList(packKey) 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 packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey) const definition = await loadPackDefinition(packKey)
if (!definition) { if (!definition) {
res.status(404).json({ ok: false, message: '음악퀴즈를 찾을 수 없습니다.' }) res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
return return
} }
const normalized = normalizePackList(req.body) 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) => { opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim() const url = pickFirstValue(req.body?.url).trim()
if (!url) { if (!url) {
res.status(400).json({ ok: false, message: '영상 주소를 입력해 주세요.' }) res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') })
return return
} }
try { try {
const entry = await fetchVideoMeta(url) const entry = await fetchVideoMeta(url)
if (!entry) { if (!entry) {
res.status(404).json({ ok: false, message: '메타데이터를 찾을 수 없습니다.' }) res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
return return
} }
res.json({ ok: true, entry }) 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) => { opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim() const url = pickFirstValue(req.body?.url).trim()
if (!url) { if (!url) {
res.status(400).json({ ok: false, message: '플레이리스트 주소를 입력해 주세요.' }) res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') })
return return
} }
try { try {
@@ -238,19 +239,27 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
const packKey = sanitizePackKey(pickFirstValue(req.params.packName)) const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey) const definition = await loadPackDefinition(packKey)
if (!definition) { if (!definition) {
res.status(404).type('text/plain').send('음악퀴즈를 찾을 수 없습니다.') res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
return return
} }
const list = await loadPackList(packKey) const list = await loadPackList(packKey)
const lines: string[] = [] const lines: string[] = []
lines.push(`# === musicquiz: ${definition.name} ===`) lines.push(t('datapackOutput.header', { name: definition.name }))
lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}`) lines.push(t('datapackOutput.summary', {
lines.push(`say [musicquiz] 데이터팩 초기화`) musicCount: list.music.length,
lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`) imageCount: list.images.length
}))
lines.push(t('datapackOutput.initLine'))
lines.push(t('datapackOutput.placeholder'))
list.music.forEach((entry, index) => { list.music.forEach((entry, index) => {
const title = entry.title || '(제목 없음)' const title = entry.title || t('datapackOutput.titleFallback')
const artist = entry.artist || '(가수 미상)' const artist = entry.artist || t('datapackOutput.artistFallback')
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`) 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') res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
} catch (error) { } catch (error) {
@@ -287,7 +296,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const normalized = normalizePackDefinition(partial) const normalized = normalizePackDefinition(partial)
if (normalized.clientMinRam > normalized.clientRecommendedRam) { if (normalized.clientMinRam > normalized.clientRecommendedRam) {
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.') res.status(400).send(t('errors.ramOrderInvalid'))
return return
} }
const finalKey = await renamePack(packKey, requestedKey, normalized) const finalKey = await renamePack(packKey, requestedKey, normalized)

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import https from 'node:https' import https from 'node:https'
import http from 'node:http' import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js' import { getMcCustomDir } from '../shared/paths.js'
import { t } from './i18n.js'
export interface YtPlaylistEntry { export interface YtPlaylistEntry {
id: string id: string
@@ -15,7 +16,7 @@ export interface YtPlaylistEntry {
export class YtDlpUnavailableError extends Error { export class YtDlpUnavailableError extends Error {
constructor(message?: string) { constructor(message?: string) {
super(message || 'yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)') super(message || t('youtube.ytdlpUnavailable'))
} }
} }
@@ -62,7 +63,7 @@ export async function ensureYtDlp(): Promise<string> {
// 검증 // 검증
const okVersion = await probeVersion(target) const okVersion = await probeVersion(target)
if (!okVersion) { if (!okVersion) {
throw new YtDlpUnavailableError('yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.') throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
} }
return target return target
} catch (err) { } catch (err) {
@@ -71,7 +72,7 @@ export async function ensureYtDlp(): Promise<string> {
throw err instanceof YtDlpUnavailableError throw err instanceof YtDlpUnavailableError
? err ? err
: new YtDlpUnavailableError( : new YtDlpUnavailableError(
'yt-dlp 자동 설치에 실패했습니다: ' + (err instanceof Error ? err.message : String(err)) t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
) )
} finally { } finally {
installPromise = null installPromise = null
@@ -112,7 +113,7 @@ function probeVersion(bin: string): Promise<boolean> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> { function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (redirects > 8) { if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.')) reject(new Error(t('youtube.tooManyRedirects')))
return return
} }
const lib = url.startsWith('https://') ? https : http const lib = url.startsWith('https://') ? https : http
@@ -161,7 +162,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
child.on('error', (err) => reject(err)) child.on('error', (err) => reject(err))
child.on('close', (code) => { child.on('close', (code) => {
if (code !== 0) { 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 return
} }
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0) const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
@@ -208,7 +209,7 @@ export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry
child.on('error', (err) => reject(err)) child.on('error', (err) => reject(err))
child.on('close', (code) => { child.on('close', (code) => {
if (code !== 0) { 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 return
} }
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0) const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)

93
src/shared/i18n.ts Normal file
View File

@@ -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<string, unknown>
/**
* 자유 형식 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, string | number>): 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<string, unknown>)) {
cur = (cur as Record<string, unknown>)[p]
} else {
return undefined
}
}
return typeof cur === 'string' ? cur : undefined
}
function interpolate(tpl: string, params?: Record<string, string | number>): 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/<component>/ko-kr.json` 을 찾아 로드.
*
* 탐색 순서(처음 발견된 것만 사용):
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
* 2. `<프로젝트 루트>/locales/<component>/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] ?? '')
}

View File

@@ -3,29 +3,29 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악퀴즈 목록</title> <title><%= t('site.indexTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="siteBody"> <body class="siteBody">
<main class="pageWrap"> <main class="pageWrap">
<section class="hero"> <section class="hero">
<h1>마인크래프트 음악퀴즈</h1> <h1><%= t('site.heroTitle') %></h1>
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p> <p><%= t('site.heroSubtitle') %></p>
</section> </section>
<section class="cardRow horizontalScroll"> <section class="cardRow horizontalScroll">
<% if (packs.length === 0) { %> <% if (packs.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다.</p> <p class="muted"><%= t('site.empty') %></p>
<% } %> <% } %>
<% packs.forEach(function (entry) { %> <% packs.forEach(function (entry) { %>
<article class="packCard"> <article class="packCard">
<h2><%= entry.name %></h2> <h2><%= entry.name %></h2>
<p class="muted">파일: <%= entry.file %>.json</p> <p class="muted"><%= t('site.fileLabel', { file: entry.file }) %></p>
<% if (entry.definition) { %> <% if (entry.definition) { %>
<ul class="metaList"> <ul class="metaList">
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li> <li><%= t('site.mcVersion') %> <strong><%= entry.definition.mcVersion %></strong></li>
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li> <li><%= t('site.platform') %> <strong><%= entry.definition.platform.type %></strong></li>
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li> <li><%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %></li>
</ul> </ul>
<% } %> <% } %>
</article> </article>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>관리자 대시보드</title> <title><%= t('dashboard.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="siteBody"> <body class="siteBody">
@@ -11,36 +11,36 @@
<main class="pageWrap"> <main class="pageWrap">
<section class="dashboardHeader"> <section class="dashboardHeader">
<h1>음악퀴즈 목록</h1> <h1><%= t('dashboard.title') %></h1>
<div class="dashboardActions"> <div class="dashboardActions">
<a class="secondaryButton" href="/op/list">음악목록 수정</a> <a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
<a class="secondaryButton" href="/op/datapack">데이터팩 수정</a> <a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
<form method="post" action="/op/dashboard/create" class="inlineForm"> <form method="post" action="/op/dashboard/create" class="inlineForm">
<button type="submit" class="primaryButton">음악퀴즈 추가</button> <button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button>
</form> </form>
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button> <button type="button" class="secondaryButton" id="deleteToggle"><%= t('dashboard.deletePack') %></button>
</div> </div>
</section> </section>
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm"> <form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
<section class="cardRow horizontalScroll"> <section class="cardRow horizontalScroll">
<% if (items.length === 0) { %> <% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p> <p class="muted"><%= t('dashboard.emptyHint') %></p>
<% } %> <% } %>
<% items.forEach(function (item) { %> <% items.forEach(function (item) { %>
<article class="packCard editableCard" data-key="<%= item.key %>"> <article class="packCard editableCard" data-key="<%= item.key %>">
<label class="cardCheckbox" hidden> <label class="cardCheckbox" hidden>
<input type="checkbox" name="targetKey" value="<%= item.key %>" /> <input type="checkbox" name="targetKey" value="<%= item.key %>" />
<span>선택</span> <span><%= t('dashboard.select') %></span>
</label> </label>
<a class="cardLink" href="/op/dashboard/<%= item.key %>"> <a class="cardLink" href="/op/dashboard/<%= item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2> <h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p> <p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %> <% if (item.definition) { %>
<ul class="metaList"> <ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li> <li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li> <li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li> <li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul> </ul>
<% } %> <% } %>
</a> </a>
@@ -48,8 +48,8 @@
<% }) %> <% }) %>
</section> </section>
<div class="deleteConfirmRow" id="deleteConfirm" hidden> <div class="deleteConfirmRow" id="deleteConfirm" hidden>
<button type="button" class="secondaryButton" id="deleteCancel">취소</button> <button type="button" class="secondaryButton" id="deleteCancel"><%= t('common.cancel') %></button>
<button type="submit" class="dangerButton">삭제 확인</button> <button type="submit" class="dangerButton"><%= t('dashboard.confirmDelete') %></button>
</div> </div>
</form> </form>
</main> </main>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>데이터팩 수정</title> <title><%= t('datapack.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="siteBody"> <body class="siteBody">
@@ -12,21 +12,21 @@
<main class="pageWrap"> <main class="pageWrap">
<section class="dashboardHeader"> <section class="dashboardHeader">
<div> <div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a> <a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;">데이터팩 수정</h1> <h1 style="margin-top:20px;"><%= t('datapack.title') %></h1>
</div> </div>
</section> </section>
<section class="dpControls"> <section class="dpControls">
<button type="button" class="primaryButton" id="pickPackBtn">음악퀴즈 선택</button> <button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
<span class="muted" id="pickedLabel">선택된 음악퀴즈 없음</span> <span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
</section> </section>
<p class="muted" id="countLabel"></p> <p class="muted" id="countLabel"></p>
<section class="dpActions" hidden id="dpActions"> <section class="dpActions" hidden id="dpActions">
<button type="button" class="secondaryButton" id="exportBtn">데이터팩 출력</button> <button type="button" class="secondaryButton" id="exportBtn"><%= t('datapack.export') %></button>
<button type="button" class="secondaryButton" id="copyBtn">복사</button> <button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
<span class="statusText" id="dp-status"></span> <span class="statusText" id="dp-status"></span>
</section> </section>
@@ -36,8 +36,8 @@
<!-- 음악퀴즈 선택 팝업 --> <!-- 음악퀴즈 선택 팝업 -->
<div class="modalOverlay" id="pickModal" hidden> <div class="modalOverlay" id="pickModal" hidden>
<div class="modalCard"> <div class="modalCard">
<header><h3>음악퀴즈 선택</h3> <header><h3><%= t('datapack.modalPickTitle') %></h3>
<button class="modalClose" type="button" data-modal-close>×</button> <button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header> </header>
<div class="modalBody"> <div class="modalBody">
<div class="cardRow horizontalScroll" id="pickList"> <div class="cardRow horizontalScroll" id="pickList">
@@ -47,8 +47,8 @@
<p class="muted"><%= item.key %>.json</p> <p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %> <% if (item.definition) { %>
<ul class="metaList"> <ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li> <li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li> <li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
</ul> </ul>
<% } %> <% } %>
</article> </article>
@@ -58,6 +58,11 @@
</div> </div>
</div> </div>
<script>
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
// 데이터팩 출력 본문의 "총 N곡" 패턴은 datapackOutput.summary 와 동일.
var SUMMARY_PATTERN = <%- JSON.stringify(localeDict.datapackOutput.summary) %>;
</script>
<script> <script>
(function () { (function () {
var pickModal = document.getElementById('pickModal') var pickModal = document.getElementById('pickModal')
@@ -75,12 +80,10 @@
card.addEventListener('click', function () { card.addEventListener('click', function () {
pickedKey = card.getAttribute('data-key') pickedKey = card.getAttribute('data-key')
var name = card.getAttribute('data-name') var name = card.getAttribute('data-name')
document.getElementById('pickedLabel').textContent = '선택: ' + name document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
pickModal.hidden = true pickModal.hidden = true
document.getElementById('dpActions').hidden = false document.getElementById('dpActions').hidden = false
// 곡 수 미리 가져오기
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {}) fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
document.getElementById('countLabel').textContent = '' document.getElementById('countLabel').textContent = ''
document.getElementById('codeOut').hidden = true document.getElementById('codeOut').hidden = true
}) })
@@ -88,30 +91,30 @@
document.getElementById('exportBtn').addEventListener('click', function () { document.getElementById('exportBtn').addEventListener('click', function () {
if (!pickedKey) return if (!pickedKey) return
var s = document.getElementById('dp-status') var s = document.getElementById('dp-status')
s.textContent = '출력 중…'; s.classList.remove('error') s.textContent = I18N.exporting; s.classList.remove('error')
fetch('/op/datapack/' + encodeURIComponent(pickedKey) + '/generate') fetch('/op/datapack/' + encodeURIComponent(pickedKey) + '/generate')
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) }) .then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
s.textContent = '실패: ' + res.text; s.classList.add('error') s.textContent = I18N.failed.replace('{{message}}', res.text); s.classList.add('error')
return return
} }
var out = document.getElementById('codeOut') var out = document.getElementById('codeOut')
out.textContent = res.text out.textContent = res.text
out.hidden = false out.hidden = false
// 첫줄/둘째줄에서 카운트 가져와 표기 // 첫줄/둘째줄에서 곡 개수를 추출해 카운트 라벨에 표시.
var m = res.text.match(/총\s+(\d+)곡/) var m = res.text.match(/총\s+(\d+)곡/)
if (m) document.getElementById('countLabel').textContent = '총 ' + m[1] + '개의 음악을 찾았습니다.' if (m) document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', m[1])
s.textContent = '출력 완료' s.textContent = I18N.exported
}) })
.catch(function (err) { s.textContent = '실패: ' + err.message; s.classList.add('error') }) .catch(function (err) { s.textContent = I18N.failed.replace('{{message}}', err.message); s.classList.add('error') })
}) })
document.getElementById('copyBtn').addEventListener('click', function () { document.getElementById('copyBtn').addEventListener('click', function () {
var out = document.getElementById('codeOut') var out = document.getElementById('codeOut')
if (out.hidden) return if (out.hidden) return
navigator.clipboard.writeText(out.textContent).then(function () { navigator.clipboard.writeText(out.textContent).then(function () {
var s = document.getElementById('dp-status') var s = document.getElementById('dp-status')
s.textContent = '복사됨' s.textContent = I18N.copied
s.classList.remove('error') s.classList.remove('error')
}) })
}) })

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> 편집</title> <title><%= t('editor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="siteBody"> <body class="siteBody">
@@ -12,27 +12,27 @@
<main class="pageWrap"> <main class="pageWrap">
<section class="editorHeader"> <section class="editorHeader">
<div> <div>
<p class="eyebrow">PACK EDITOR</p> <p class="eyebrow"><%= t('editor.eyebrow') %></p>
<h1><%= pack.name %></h1> <h1><%= pack.name %></h1>
</div> </div>
<a class="ghostLink" href="/op/dashboard">목록으로</a> <a class="ghostLink" href="/op/dashboard"><%= t('common.backToList') %></a>
</section> </section>
<form method="post" class="editorForm" id="editorForm"> <form method="post" class="editorForm" id="editorForm">
<div class="gridTwo"> <div class="gridTwo">
<label> <label>
<span>음악퀴즈 이름</span> <span><%= t('editor.displayName') %></span>
<input name="displayName" value="<%= pack.name %>" required /> <input name="displayName" value="<%= pack.name %>" required />
</label> </label>
<label> <label>
<span>JSON 파일 이름 (확장자 제외)</span> <span><%= t('editor.fileName') %></span>
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" /> <input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
</label> </label>
</div> </div>
<div class="gridTwo"> <div class="gridTwo">
<label> <label>
<span>마인크래프트 버전</span> <span><%= t('editor.mcVersion') %></span>
<select name="mcVersion" required> <select name="mcVersion" required>
<% releases.forEach(function (release) { %> <% releases.forEach(function (release) { %>
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option> <option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
@@ -40,7 +40,7 @@
</select> </select>
</label> </label>
<label> <label>
<span>모드 플랫폼</span> <span><%= t('editor.platformType') %></span>
<select name="platformType" id="platformType"> <select name="platformType" id="platformType">
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %> <% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option> <option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
@@ -48,62 +48,75 @@
</select> </select>
</label> </label>
<label class="fullSpan" id="platformDownloadField"> <label class="fullSpan" id="platformDownloadField">
<span>플랫폼 설치파일 URL</span> <span><%= t('editor.platformDownloadUrl') %></span>
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" /> <input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/&lt;파일명&gt;</code>으로 해석됩니다.</small> <small class="muted"><%- t('editor.platformDownloadHint') %></small>
</label> </label>
<label class="fullSpan" id="platformLoaderField" hidden> <label class="fullSpan" id="platformLoaderField" hidden>
<span>Fabric Loader 버전</span> <span><%= t('editor.platformLoaderVersion') %></span>
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>"> <select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
<option value="">불러오는 중...</option> <option value=""><%= t('common.loading') %></option>
</select> </select>
<small class="muted">선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.</small> <small class="muted"><%= t('editor.platformLoaderHint') %></small>
</label> </label>
<label> <label>
<span>서버 최소 램 (MB)</span> <span><%= t('editor.serverMinRam') %></span>
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required /> <input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
</label> </label>
<label> <label>
<span>서버 최대 램 (MB)</span> <span><%= t('editor.serverMaxRam') %></span>
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required /> <input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
</label> </label>
<label> <label>
<span>클라이언트 최소 램 (MB)</span> <span><%= t('editor.clientMinRam') %></span>
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required /> <input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
</label> </label>
<label> <label>
<span>클라이언트 권장 램 (MB)</span> <span><%= t('editor.clientRecommendedRam') %></span>
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required /> <input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
</label> </label>
<label> <label>
<span>맵 파일 (.zip)</span> <span><%= t('editor.mapPath') %></span>
<input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" /> <input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" />
<small class="muted">/file/maps/ 아래 zip 파일 이름.</small> <small class="muted"><%= t('editor.mapPathHint') %></small>
</label> </label>
<label> <label>
<span>서버 파일 (.zip)</span> <span><%= t('editor.serverPath') %></span>
<input name="serverPath" value="<%= pack.serverPath %>" placeholder="my-server.zip" pattern=".+\.zip" /> <input name="serverPath" value="<%= pack.serverPath %>" placeholder="my-server.zip" pattern=".+\.zip" />
<small class="muted">/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.</small> <small class="muted"><%= t('editor.serverPathHint') %></small>
</label> </label>
</div> </div>
<div class="gridTwo"> <div class="gridTwo">
<label> <label>
<span>모드 폴더 이름</span> <span><%= t('editor.modsFolder') %></span>
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" /> <input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
<small class="muted">/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small> <small class="muted"><%- t('editor.modsFolderHint') %></small>
</label> </label>
<label> <label>
<span>리소스팩 (.zip)</span> <span><%= t('editor.resourcepackPath') %></span>
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" /> <input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small> <small class="muted"><%= t('editor.resourcepackHint') %></small>
</label> </label>
</div> </div>
<button class="primaryButton" type="submit">저장</button> <button class="primaryButton" type="submit"><%= t('common.save') %></button>
</form> </form>
</main> </main>
<script>
var I18N = {
ramOrderInvalid: <%- JSON.stringify(t('editor.ramOrderInvalid')) %>,
fabricLoaderRequired: <%- JSON.stringify(t('editor.fabricLoaderRequired')) %>,
loaderEmpty: <%- JSON.stringify(t('editor.platformLoaderEmpty')) %>,
loaderPickMc: <%- JSON.stringify(t('editor.platformLoaderPickMc')) %>,
loaderLoadFailedPrefix: <%- JSON.stringify(t('editor.platformLoaderLoadFailed', { message: '__M__' })) %>,
loading: <%- JSON.stringify(t('common.loading')) %>
}
function formatLoaderLoadFailed(message) {
return I18N.loaderLoadFailedPrefix.replace('__M__', message)
}
</script>
<script> <script>
(function () { (function () {
var platformSelect = document.getElementById('platformType') var platformSelect = document.getElementById('platformType')
@@ -136,7 +149,7 @@
function populateLoaderOptions(versions, preselect) { function populateLoaderOptions(versions, preselect) {
if (!versions || versions.length === 0) { if (!versions || versions.length === 0) {
loaderSelect.innerHTML = '<option value="">호환 로더 없음</option>' loaderSelect.innerHTML = '<option value="">' + I18N.loaderEmpty + '</option>'
return return
} }
var html = '' var html = ''
@@ -156,7 +169,7 @@
function loadFabricLoaders() { function loadFabricLoaders() {
var mc = (mcVersionSelect && mcVersionSelect.value) || '' var mc = (mcVersionSelect && mcVersionSelect.value) || ''
if (!mc) { if (!mc) {
loaderSelect.innerHTML = '<option value="">마인크래프트 버전을 먼저 선택하세요</option>' loaderSelect.innerHTML = '<option value="">' + I18N.loaderPickMc + '</option>'
return return
} }
if (loaderCache[mc]) { if (loaderCache[mc]) {
@@ -164,7 +177,7 @@
return return
} }
var seq = ++loaderFetchSeq var seq = ++loaderFetchSeq
loaderSelect.innerHTML = '<option value="">불러오는 중...</option>' loaderSelect.innerHTML = '<option value="">' + I18N.loading + '</option>'
fetch('https://meta.fabricmc.net/v2/versions/loader/' + encodeURIComponent(mc)) fetch('https://meta.fabricmc.net/v2/versions/loader/' + encodeURIComponent(mc))
.then(function (res) { .then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status) if (!res.ok) throw new Error('HTTP ' + res.status)
@@ -181,7 +194,8 @@
}) })
.catch(function (err) { .catch(function (err) {
if (seq !== loaderFetchSeq) return if (seq !== loaderFetchSeq) return
loaderSelect.innerHTML = '<option value="">로더 목록 로드 실패: ' + (err && err.message ? err.message : err) + '</option>' var msg = (err && err.message) ? err.message : String(err)
loaderSelect.innerHTML = '<option value="">' + formatLoaderLoadFailed(msg) + '</option>'
}) })
} }
@@ -197,12 +211,12 @@
var clientReco = Number(form.clientRecommendedRam.value) var clientReco = Number(form.clientRecommendedRam.value)
if (clientMin > clientReco) { if (clientMin > clientReco) {
event.preventDefault() event.preventDefault()
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.') alert(I18N.ramOrderInvalid)
return return
} }
if (platformSelect.value === 'fabric' && !loaderSelect.value) { if (platformSelect.value === 'fabric' && !loaderSelect.value) {
event.preventDefault() event.preventDefault()
alert('Fabric 로더 버전을 선택해 주세요.') alert(I18N.fabricLoaderRequired)
} }
}) })
})() })()

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악목록 수정</title> <title><%= t('list.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="siteBody"> <body class="siteBody">
@@ -12,14 +12,14 @@
<main class="pageWrap"> <main class="pageWrap">
<section class="dashboardHeader"> <section class="dashboardHeader">
<div> <div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a> <a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;">음악목록 수정</h1> <h1 style="margin-top:20px;"><%= t('list.title') %></h1>
</div> </div>
</section> </section>
<section class="cardRow horizontalScroll"> <section class="cardRow horizontalScroll">
<% if (items.length === 0) { %> <% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다.</p> <p class="muted"><%= t('site.empty') %></p>
<% } %> <% } %>
<% items.forEach(function (item) { %> <% items.forEach(function (item) { %>
<article class="packCard"> <article class="packCard">
@@ -28,9 +28,9 @@
<p class="muted"><%= item.key %>.json</p> <p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %> <% if (item.definition) { %>
<ul class="metaList"> <ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li> <li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li> <li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li> <li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul> </ul>
<% } %> <% } %>
</a> </a>

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> — 음악/사진 목록</title> <title><%= t('listEditor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="siteBody"> <body class="siteBody">
@@ -12,31 +12,31 @@
<main class="pageWrap"> <main class="pageWrap">
<section class="dashboardHeader"> <section class="dashboardHeader">
<div> <div>
<a class="ghostLink" href="/op/list">← 돌아가기</a> <a class="ghostLink" href="/op/list"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= pack.name %></h1> <h1 style="margin-top:20px;"><%= pack.name %></h1>
<p class="muted"><%= packKey %>.json</p> <p class="muted"><%= packKey %>.json</p>
</div> </div>
<div class="dirtyMark" id="dirty-mark" hidden title="저장되지 않은 변경사항이 있습니다">*</div> <div class="dirtyMark" id="dirty-mark" hidden title="<%= t('listEditor.dirtyTooltip') %>">*</div>
</section> </section>
<div class="tabBar"> <div class="tabBar">
<button type="button" class="tabBtn active" data-tab="music">음악목록</button> <button type="button" class="tabBtn active" data-tab="music"><%= t('listEditor.tabMusic') %></button>
<button type="button" class="tabBtn" data-tab="image">사진목록</button> <button type="button" class="tabBtn" data-tab="image"><%= t('listEditor.tabImage') %></button>
</div> </div>
<!-- 음악 탭 --> <!-- 음악 탭 -->
<section class="tabPanel" id="tab-music"> <section class="tabPanel" id="tab-music">
<div class="listActionsRow"> <div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="music">목록 저장</button> <button type="button" class="primaryButton" data-action="save" data-target="music"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="music">목록 초기화</button> <button type="button" class="dangerButton" data-action="clear" data-target="music"><%= t('listEditor.clearList') %></button>
<span class="statusText" id="status-music"></span> <span class="statusText" id="status-music"></span>
</div> </div>
<div class="playlistRow"> <div class="playlistRow">
<input type="url" class="textInput" id="music-playlist-url" <input type="url" class="textInput" id="music-playlist-url"
placeholder="유튜브 플레이리스트 URL" placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.musicPlaylistUrl %>" /> value="<%= list.musicPlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="music">플레이리스트 불러오기</button> <button type="button" class="secondaryButton" data-action="fetch" data-target="music"><%= t('listEditor.fetchPlaylist') %></button>
</div> </div>
<ol class="trackList" id="music-list"></ol> <ol class="trackList" id="music-list"></ol>
@@ -45,17 +45,17 @@
<!-- 사진 탭 --> <!-- 사진 탭 -->
<section class="tabPanel" id="tab-image" hidden> <section class="tabPanel" id="tab-image" hidden>
<div class="listActionsRow"> <div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button> <button type="button" class="primaryButton" data-action="save" data-target="image"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button> <button type="button" class="dangerButton" data-action="clear" data-target="image"><%= t('listEditor.clearList') %></button>
<button type="button" class="secondaryButton" id="image-from-music">음악목록에서 가져오기</button> <button type="button" class="secondaryButton" id="image-from-music"><%= t('listEditor.imageFromMusic') %></button>
<span class="statusText" id="status-image"></span> <span class="statusText" id="status-image"></span>
</div> </div>
<div class="playlistRow"> <div class="playlistRow">
<input type="url" class="textInput" id="image-playlist-url" <input type="url" class="textInput" id="image-playlist-url"
placeholder="유튜브 플레이리스트 URL" placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.imagePlaylistUrl %>" /> value="<%= list.imagePlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="image">플레이리스트 불러오기</button> <button type="button" class="secondaryButton" data-action="fetch" data-target="image"><%= t('listEditor.fetchPlaylist') %></button>
</div> </div>
<div class="imageGrid" id="image-list"></div> <div class="imageGrid" id="image-list"></div>
@@ -64,22 +64,22 @@
<!-- Context menu --> <!-- Context menu -->
<div class="ctxMenu" id="ctxMenu" hidden> <div class="ctxMenu" id="ctxMenu" hidden>
<button type="button" data-ctx="edit">수정</button> <button type="button" data-ctx="edit"><%= t('common.edit') %></button>
<button type="button" data-ctx="delete">삭제</button> <button type="button" data-ctx="delete"><%= t('common.delete') %></button>
</div> </div>
<!-- Confirm modal --> <!-- Confirm modal -->
<div class="modalOverlay" id="confirmModal" hidden> <div class="modalOverlay" id="confirmModal" hidden>
<div class="modalCard"> <div class="modalCard">
<header><h3 id="confirm-title">확인</h3> <header><h3 id="confirm-title"><%= t('listEditor.modalConfirmTitle') %></h3>
<button class="modalClose" type="button" data-modal-close>×</button> <button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header> </header>
<div class="modalBody"> <div class="modalBody">
<p id="confirm-message"></p> <p id="confirm-message"></p>
</div> </div>
<footer style="display:flex;gap:8px;justify-content:flex-end;"> <footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button> <button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="confirm-ok">확인</button> <button type="button" class="primaryButton" id="confirm-ok"><%= t('common.ok') %></button>
</footer> </footer>
</div> </div>
</div> </div>
@@ -87,21 +87,21 @@
<!-- Edit modal (music) --> <!-- Edit modal (music) -->
<div class="modalOverlay" id="editMusicModal" hidden> <div class="modalOverlay" id="editMusicModal" hidden>
<div class="modalCard"> <div class="modalCard">
<header><h3>음악 항목 수정</h3> <header><h3><%= t('listEditor.musicEditTitle') %></h3>
<button class="modalClose" type="button" data-modal-close>×</button> <button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header> </header>
<div class="modalBody"> <div class="modalBody">
<label>유튜브 영상 주소 <label><%= t('listEditor.musicEditUrl') %>
<input type="url" id="edit-music-url" class="textInput" /> <input type="url" id="edit-music-url" class="textInput" />
</label> </label>
<p class="muted" style="margin-top:6px;font-size:12px;"> <p class="muted" style="margin-top:6px;font-size:12px;">
저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다. <%= t('listEditor.musicEditHint') %>
</p> </p>
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p> <p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
</div> </div>
<footer style="display:flex;gap:8px;justify-content:flex-end;"> <footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button> <button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="edit-music-save">저장</button> <button type="button" class="primaryButton" id="edit-music-save"><%= t('common.save') %></button>
</footer> </footer>
</div> </div>
</div> </div>
@@ -109,21 +109,21 @@
<!-- Edit modal (image) --> <!-- Edit modal (image) -->
<div class="modalOverlay" id="editImageModal" hidden> <div class="modalOverlay" id="editImageModal" hidden>
<div class="modalCard"> <div class="modalCard">
<header><h3>사진 항목 수정</h3> <header><h3><%= t('listEditor.imageEditTitle') %></h3>
<button class="modalClose" type="button" data-modal-close>×</button> <button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header> </header>
<div class="modalBody"> <div class="modalBody">
<div class="segmentedRow"> <div class="segmentedRow">
<button type="button" class="segBtn active" data-seg="yt">유튜브 주소</button> <button type="button" class="segBtn active" data-seg="yt"><%= t('listEditor.imageSegYt') %></button>
<button type="button" class="segBtn" data-seg="img">이미지 주소</button> <button type="button" class="segBtn" data-seg="img"><%= t('listEditor.imageSegImg') %></button>
</div> </div>
<label>주소 <label><%= t('listEditor.imageEditUrl') %>
<input type="url" id="edit-image-url" class="textInput" /> <input type="url" id="edit-image-url" class="textInput" />
</label> </label>
</div> </div>
<footer style="display:flex;gap:8px;justify-content:flex-end;"> <footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button> <button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="edit-image-save">저장</button> <button type="button" class="primaryButton" id="edit-image-save"><%= t('common.save') %></button>
</footer> </footer>
</div> </div>
</div> </div>
@@ -131,6 +131,8 @@
<script> <script>
var PACK_KEY = <%- JSON.stringify(packKey) %>; var PACK_KEY = <%- JSON.stringify(packKey) %>;
var INITIAL = <%- JSON.stringify(list) %>; var INITIAL = <%- JSON.stringify(list) %>;
var I18N = <%- JSON.stringify(localeDict.listEditor) %>;
I18N.common = <%- JSON.stringify(localeDict.common) %>;
</script> </script>
<script src="/static/listEditor.js"></script> <script src="/static/listEditor.js"></script>
</body> </body>

View File

@@ -3,21 +3,21 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>관리자 로그인</title> <title><%= t('login.title') %></title>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body class="siteBody centerLayout"> <body class="siteBody centerLayout">
<main class="loginCard"> <main class="loginCard">
<h1>관리자 로그인</h1> <h1><%= t('login.title') %></h1>
<% if (error) { %> <% if (error) { %>
<p class="errorBanner"><%= error %></p> <p class="errorBanner"><%= error %></p>
<% } %> <% } %>
<form method="post" action="/op" class="loginForm"> <form method="post" action="/op" class="loginForm">
<label> <label>
<span>비밀번호</span> <span><%= t('login.password') %></span>
<input name="password" type="password" autocomplete="current-password" required autofocus /> <input name="password" type="password" autocomplete="current-password" required autofocus />
</label> </label>
<button class="primaryButton" type="submit">로그인</button> <button class="primaryButton" type="submit"><%= t('login.submit') %></button>
</form> </form>
</main> </main>
</body> </body>

View File

@@ -1,13 +1,13 @@
<header class="topNav"> <header class="topNav">
<a class="navBrand" href="/op/dashboard"> <a class="navBrand" href="/op/dashboard">
<span class="navLogo">🎵</span> <span class="navLogo">🎵</span>
<span class="navTitle">관리자 페이지</span> <span class="navTitle"><%= t('nav.brand') %></span>
</a> </a>
<div class="navUser"> <div class="navUser">
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button> <button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
<div class="navUserMenu" id="userMenu" hidden> <div class="navUserMenu" id="userMenu" hidden>
<form method="post" action="/op/logout"> <form method="post" action="/op/logout">
<button type="submit" class="dangerLink">로그아웃</button> <button type="submit" class="dangerLink"><%= t('nav.logout') %></button>
</form> </form>
</div> </div>
</div> </div>