Compare commits
3 Commits
401d72622e
...
6cd402121b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cd402121b | |||
| 135bc98840 | |||
| c2fcc2fbbf |
@@ -9,11 +9,17 @@ files:
|
||||
- package.json
|
||||
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
|
||||
# locales/ 폴더는 i18n.ts 가 process.resourcesPath/locales/<component>/ko-kr.json
|
||||
# 을 찾아 로드하므로, 빌드된 .exe 에서도 한국어 사전이 적용되도록 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-Setup.${ext}
|
||||
|
||||
@@ -10,6 +10,25 @@ const state = {
|
||||
resourcepackPath: ''
|
||||
}
|
||||
|
||||
let I18N = {}
|
||||
|
||||
function tt(key, params) {
|
||||
var parts = String(key).split('.')
|
||||
var cur = I18N
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur && typeof cur === 'object' && parts[i] in cur) {
|
||||
cur = cur[parts[i]]
|
||||
} else {
|
||||
return key
|
||||
}
|
||||
}
|
||||
if (typeof cur !== 'string') return key
|
||||
if (!params) return cur
|
||||
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
||||
return name in params ? String(params[name]) : '{{' + name + '}}'
|
||||
})
|
||||
}
|
||||
|
||||
const pageHost = document.getElementById('pageHost')
|
||||
const stepIndicator = document.getElementById('stepIndicator')
|
||||
const logViewer = document.getElementById('logViewer')
|
||||
@@ -20,10 +39,10 @@ logToggle.addEventListener('click', function () {
|
||||
logViewer.classList.toggle('collapsed')
|
||||
if (logViewer.classList.contains('collapsed')) {
|
||||
logViewer.style.height = '36px'
|
||||
logToggle.textContent = '펼치기'
|
||||
logToggle.textContent = tt('logViewer.expand')
|
||||
} else {
|
||||
logViewer.style.height = ''
|
||||
logToggle.textContent = '접기'
|
||||
logToggle.textContent = tt('logViewer.collapse')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -33,6 +52,22 @@ api.onLog(function (line) {
|
||||
logBody.scrollTop = logBody.scrollHeight
|
||||
})
|
||||
|
||||
function applyStaticI18n() {
|
||||
document.title = tt('app.title')
|
||||
var h1 = document.querySelector('.appHeader h1')
|
||||
if (h1) h1.textContent = tt('app.title')
|
||||
var stepLis = stepIndicator.querySelectorAll('li')
|
||||
stepLis.forEach(function (item) {
|
||||
var idx = item.getAttribute('data-step')
|
||||
if (idx === '1') item.textContent = tt('stepIndicator.step1')
|
||||
else if (idx === '2') item.textContent = tt('stepIndicator.step2')
|
||||
else if (idx === '3') item.textContent = tt('stepIndicator.step3')
|
||||
})
|
||||
var logH2 = logViewer.querySelector('header h2')
|
||||
if (logH2) logH2.textContent = tt('logViewer.heading')
|
||||
logToggle.textContent = tt('logViewer.collapse')
|
||||
}
|
||||
|
||||
function setActiveStep(step) {
|
||||
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||
var index = Number(item.getAttribute('data-step'))
|
||||
@@ -51,9 +86,9 @@ function renderStep1() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>1단계. 음악퀴즈 선택</h2>' +
|
||||
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<h2>' + escapeHtml(tt('step1.heading')) + '</h2>' +
|
||||
'<div id="packList" class="cardChoice"><p class="formMessage">' + escapeHtml(tt('common.loading')) + '</p></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var listEl = section.querySelector('#packList')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
@@ -61,7 +96,7 @@ function renderStep1() {
|
||||
function renderList() {
|
||||
listEl.innerHTML = ''
|
||||
if (state.packs.length === 0) {
|
||||
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
|
||||
listEl.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('common.noPacks')) + '</p>'
|
||||
return
|
||||
}
|
||||
state.packs.forEach(function (pack) {
|
||||
@@ -69,11 +104,14 @@ function renderStep1() {
|
||||
card.type = 'button'
|
||||
card.className = 'choiceCard'
|
||||
if (state.selectedKey === pack.key) card.classList.add('selected')
|
||||
var verLabel = pack.mcVersion ? '마인크래프트 ' + escapeHtml(pack.mcVersion) + ' · ' : ''
|
||||
var verLabel = pack.mcVersion
|
||||
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
|
||||
: ''
|
||||
card.innerHTML =
|
||||
'<strong>' + escapeHtml(pack.name) + '</strong>' +
|
||||
'<small>' + verLabel +
|
||||
'음악 ' + pack.list.music.length + '곡 · 사진 ' + pack.list.images.length + '장</small>'
|
||||
escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) +
|
||||
'</small>'
|
||||
card.addEventListener('click', function () {
|
||||
state.selectedKey = pack.key
|
||||
nextBtn.disabled = false
|
||||
@@ -88,7 +126,7 @@ function renderStep1() {
|
||||
api.selectPack(state.selectedKey).then(function () {
|
||||
renderStep2()
|
||||
}).catch(function (err) {
|
||||
alert(err.message || '선택 실패')
|
||||
alert(err.message || tt('common.selectFailed'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -96,7 +134,9 @@ function renderStep1() {
|
||||
state.packs = packs || []
|
||||
renderList()
|
||||
}).catch(function (err) {
|
||||
listEl.innerHTML = '<p class="formMessage error">목록 로드 실패: ' + escapeHtml(err.message || '') + '</p>'
|
||||
listEl.innerHTML = '<p class="formMessage error">' +
|
||||
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
|
||||
'</p>'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,30 +155,29 @@ function renderStep2() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>2단계. 리소스팩 설치</h2>' +
|
||||
'<p class="formMessage">음악·사진을 받아 리소스팩을 만들고 ' +
|
||||
'<code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.</p>' +
|
||||
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + tt('step2.description') + '</p>' +
|
||||
'<div class="prepRow">' +
|
||||
' <span class="prepChip" id="chip-ytdlp">yt-dlp 준비</span>' +
|
||||
' <span class="prepChip" id="chip-ffmpeg">ffmpeg 준비</span>' +
|
||||
' <span class="prepChip" id="chip-ytdlp">' + escapeHtml(tt('step2.chipYtdlp')) + '</span>' +
|
||||
' <span class="prepChip" id="chip-ffmpeg">' + escapeHtml(tt('step2.chipFfmpeg')) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>음악 다운로드</h3>' +
|
||||
' <div class="sectionSub" id="music-sub">' + musicTotal + '곡</div>' +
|
||||
' <h3>' + escapeHtml(tt('step2.musicHeading')) + '</h3>' +
|
||||
' <div class="sectionSub" id="music-sub">' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '</div>' +
|
||||
' <div class="progressGrid" id="musicGrid"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>사진 다운로드</h3>' +
|
||||
' <div class="sectionSub" id="image-sub">' + imageTotal + '장</div>' +
|
||||
' <h3>' + escapeHtml(tt('step2.imageHeading')) + '</h3>' +
|
||||
' <div class="sectionSub" id="image-sub">' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '</div>' +
|
||||
' <div class="progressGrid" id="imageGrid"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>리소스팩 빌드</h3>' +
|
||||
' <div class="sectionSub" id="pkg-sub">대기 중…</div>' +
|
||||
' <h3>' + escapeHtml(tt('step2.packageHeading')) + '</h3>' +
|
||||
' <div class="sectionSub" id="pkg-sub">' + escapeHtml(tt('step2.packageWaiting')) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow">' +
|
||||
' <span></span>' +
|
||||
' <button class="dangerBtn" id="cancel">취소</button>' +
|
||||
' <button class="dangerBtn" id="cancel">' + escapeHtml(tt('common.cancel')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
|
||||
@@ -156,7 +195,7 @@ function renderStep2() {
|
||||
card.innerHTML =
|
||||
'<div class="cardTop"><span class="label">' + idx + '</span><span class="icon">○</span></div>' +
|
||||
'<div class="bar"><span></span></div>' +
|
||||
'<div class="pct">대기</div>'
|
||||
'<div class="pct">' + escapeHtml(tt('step2.cardWaiting')) + '</div>'
|
||||
return card
|
||||
}
|
||||
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
|
||||
@@ -172,17 +211,17 @@ function renderStep2() {
|
||||
var pct = card.querySelector('.pct')
|
||||
var icon = card.querySelector('.icon')
|
||||
if (status === 'done') {
|
||||
if (pct) pct.textContent = '완료'
|
||||
if (pct) pct.textContent = tt('step2.cardDone')
|
||||
if (icon) icon.textContent = '✓'
|
||||
if (bar) bar.style.width = '100%'
|
||||
} else if (status === 'error') {
|
||||
if (pct) pct.textContent = '실패'
|
||||
if (pct) pct.textContent = tt('step2.cardError')
|
||||
if (icon) icon.textContent = '✕'
|
||||
} else if (status === 'running') {
|
||||
if (pct) pct.textContent = Math.round(percent) + '%'
|
||||
if (icon) icon.textContent = '⏳'
|
||||
} else {
|
||||
if (pct) pct.textContent = '대기'
|
||||
if (pct) pct.textContent = tt('step2.cardWaiting')
|
||||
if (icon) icon.textContent = '○'
|
||||
}
|
||||
}
|
||||
@@ -209,7 +248,9 @@ function renderStep2() {
|
||||
return
|
||||
}
|
||||
if (payload.phase === 'package') {
|
||||
pkgSub.textContent = payload.done ? '설치 완료' : (payload.message || '빌드 중…')
|
||||
pkgSub.textContent = payload.done
|
||||
? tt('step2.packageDone')
|
||||
: (payload.message || tt('step2.packageBuilding'))
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -232,7 +273,7 @@ function renderStep2() {
|
||||
}).catch(function (err) {
|
||||
state.installing = false
|
||||
if (stopProgress) stopProgress()
|
||||
alert('설치 실패: ' + ((err && err.message) || err))
|
||||
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
||||
renderStep1()
|
||||
})
|
||||
}
|
||||
@@ -244,14 +285,14 @@ function renderStep3() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>3단계. 완료</h2>' +
|
||||
'<p class="formMessage">리소스팩 설치를 완료했습니다.</p>' +
|
||||
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
|
||||
(state.resourcepackPath
|
||||
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
|
||||
: '') +
|
||||
'<div class="actionRow">' +
|
||||
' <button class="secondaryBtn" id="openFolder">리소스팩 폴더 열기</button>' +
|
||||
' <button class="primaryBtn" id="finish">확인</button>' +
|
||||
' <button class="secondaryBtn" id="openFolder">' + escapeHtml(tt('common.openFolder')) + '</button>' +
|
||||
' <button class="primaryBtn" id="finish">' + escapeHtml(tt('common.confirm')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||
@@ -268,4 +309,8 @@ function escapeHtml(s) {
|
||||
})
|
||||
}
|
||||
|
||||
;(async function () {
|
||||
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
|
||||
applyStaticI18n()
|
||||
renderStep1()
|
||||
})()
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
const installerApi = window.installer
|
||||
|
||||
// I18N 사전: locales/installer/ko-kr.json. 처음 한 번 메인 프로세스에서 받아오고
|
||||
// 그 뒤로는 동기적으로 lookup. tt() 가 호출될 때 사전이 비어 있어도 키를 그대로 반환해
|
||||
// 화면이 깨지지는 않는다.
|
||||
var I18N = {}
|
||||
function tt(key, params) {
|
||||
var parts = key.split('.')
|
||||
var cur = 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 + '}}')
|
||||
})
|
||||
}
|
||||
|
||||
const state = {
|
||||
packs: [],
|
||||
selectedPackKey: null,
|
||||
@@ -30,14 +48,27 @@ const logViewer = document.getElementById('logViewer')
|
||||
const logBody = document.getElementById('logBody')
|
||||
const logToggle = document.getElementById('logToggle')
|
||||
|
||||
function applyStaticI18n() {
|
||||
document.title = tt('app.browserTitle')
|
||||
var headerH1 = document.querySelector('.appHeader h1')
|
||||
if (headerH1) headerH1.textContent = tt('app.headerTitle')
|
||||
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||
var step = Number(item.getAttribute('data-step'))
|
||||
item.textContent = tt('stepIndicator.step' + step)
|
||||
})
|
||||
var logHeader = logViewer.querySelector('h2')
|
||||
if (logHeader) logHeader.textContent = tt('logViewer.title')
|
||||
logToggle.textContent = tt('common.collapse')
|
||||
}
|
||||
|
||||
logToggle.addEventListener('click', function () {
|
||||
logViewer.classList.toggle('collapsed')
|
||||
if (logViewer.classList.contains('collapsed')) {
|
||||
logViewer.style.height = '36px'
|
||||
logToggle.textContent = '펼치기'
|
||||
logToggle.textContent = tt('common.expand')
|
||||
} else {
|
||||
logViewer.style.height = ''
|
||||
logToggle.textContent = '접기'
|
||||
logToggle.textContent = tt('common.collapse')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -66,9 +97,9 @@ function renderStep1() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>1단계. 설치할 음악퀴즈 선택</h2>' +
|
||||
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<h2>' + tt('step1.heading') + '</h2>' +
|
||||
'<div id="packList" class="cardChoice"><p class="formMessage">' + tt('step1.loading') + '</p></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var listEl = section.querySelector('#packList')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
@@ -76,13 +107,14 @@ function renderStep1() {
|
||||
function renderList() {
|
||||
listEl.innerHTML = ''
|
||||
if (state.packs.length === 0) {
|
||||
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
|
||||
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.empty') + '</p>'
|
||||
return
|
||||
}
|
||||
state.packs.forEach(function (pack) {
|
||||
var btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '</small>'
|
||||
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>' +
|
||||
tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + '</small>'
|
||||
if (state.selectedPackKey === pack.key) btn.classList.add('selected')
|
||||
btn.addEventListener('click', function () {
|
||||
state.selectedPackKey = pack.key
|
||||
@@ -106,7 +138,7 @@ function renderStep1() {
|
||||
state.packs = packs
|
||||
renderList()
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<p class="formMessage error">목록을 가져오지 못했습니다: ' + err.message + '</p>'
|
||||
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.fetchFailed', { message: err.message }) + '</p>'
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -117,12 +149,12 @@ function renderStep2() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>2단계. 싱글 / 멀티 선택</h2>' +
|
||||
'<h2>' + tt('step2.heading') + '</h2>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button id="single" type="button" data-mode="single"><strong>싱글</strong><br><small>혼자 즐기는 모드. 4단계만 진행합니다.</small></button>' +
|
||||
'<button id="multi" type="button" data-mode="multi"><strong>멀티</strong><br><small>친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다.</small></button>' +
|
||||
'<button id="single" type="button" data-mode="single"><strong>' + tt('step2.singleTitle') + '</strong><br><small>' + tt('step2.singleHint') + '</small></button>' +
|
||||
'<button id="multi" type="button" data-mode="multi"><strong>' + tt('step2.multiTitle') + '</strong><br><small>' + tt('step2.multiHint') + '</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var modeButtons = section.querySelectorAll('[data-mode]')
|
||||
@@ -159,7 +191,7 @@ function renderStep3() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>3단계. 서버 관련 설정</h2>' +
|
||||
'<h2>' + tt('step3.heading') + '</h2>' +
|
||||
'<div class="subStep" id="subHost"></div>'
|
||||
pageHost.appendChild(section)
|
||||
var subHost = section.querySelector('#subHost')
|
||||
@@ -180,12 +212,12 @@ function renderStep3() {
|
||||
|
||||
function renderSubStep31(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-1. 서버 설치 경로</h3>' +
|
||||
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
|
||||
'<h3>' + tt('step3.sub31.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub31.description') + '</p>' +
|
||||
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
|
||||
'<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' +
|
||||
'<button class="secondaryBtn" id="pickFolder">' + tt('step3.sub31.pickFolder') + '</button></div>' +
|
||||
'<div class="formMessage" id="msg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
var input = host.querySelector('#installPath')
|
||||
var msg = host.querySelector('#msg')
|
||||
host.querySelector('#pickFolder').addEventListener('click', async function () {
|
||||
@@ -196,11 +228,11 @@ function renderSubStep31(host, back, done) {
|
||||
host.querySelector('#next').addEventListener('click', async function () {
|
||||
var result = await installerApi.validateInstallPath(input.value.trim())
|
||||
if (!result.ok) {
|
||||
msg.textContent = result.message || '경로가 유효하지 않습니다.'
|
||||
msg.textContent = result.message || tt('step3.sub31.invalidPath')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
msg.textContent = '경로 확정: ' + result.message
|
||||
msg.textContent = tt('step3.sub31.confirmed', { message: result.message })
|
||||
msg.classList.remove('error')
|
||||
msg.classList.add('success')
|
||||
state.serverInstall.path = input.value.trim()
|
||||
@@ -210,14 +242,14 @@ function renderSubStep31(host, back, done) {
|
||||
|
||||
function renderSubStep32(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-2. JDK 확인</h3>' +
|
||||
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 "자동 설치" 로 Temurin 21 을 받아 설치할 수 있습니다.</p>' +
|
||||
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
|
||||
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
|
||||
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
|
||||
'<button class="secondaryBtn" id="auto">자동 탐색</button>' +
|
||||
'<button class="secondaryBtn" id="install">자동 설치</button></div>' +
|
||||
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
|
||||
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
|
||||
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
|
||||
'<div class="formMessage" id="msg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
var input = host.querySelector('#jdkPath')
|
||||
var msg = host.querySelector('#msg')
|
||||
var installBtn = host.querySelector('#install')
|
||||
@@ -229,7 +261,7 @@ function renderSubStep32(host, back, done) {
|
||||
function setInstallingUi(on) {
|
||||
installing = on
|
||||
if (on) {
|
||||
installBtn.textContent = '설치 취소'
|
||||
installBtn.textContent = tt('step3.sub32.installCancel')
|
||||
installBtn.classList.remove('secondaryBtn')
|
||||
installBtn.classList.add('dangerBtn')
|
||||
autoBtn.disabled = true
|
||||
@@ -237,7 +269,7 @@ function renderSubStep32(host, back, done) {
|
||||
nextBtn.disabled = true
|
||||
input.disabled = true
|
||||
} else {
|
||||
installBtn.textContent = '자동 설치'
|
||||
installBtn.textContent = tt('step3.sub32.install')
|
||||
installBtn.classList.remove('dangerBtn')
|
||||
installBtn.classList.add('secondaryBtn')
|
||||
autoBtn.disabled = false
|
||||
@@ -252,11 +284,11 @@ function renderSubStep32(host, back, done) {
|
||||
var detect = await installerApi.detectJdk()
|
||||
if (detect.found) {
|
||||
input.value = detect.path
|
||||
msg.textContent = 'JDK 발견: ' + detect.path
|
||||
msg.textContent = tt('step3.sub32.found', { path: detect.path })
|
||||
msg.classList.remove('error')
|
||||
msg.classList.add('success')
|
||||
} else {
|
||||
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.'
|
||||
msg.textContent = tt('step3.sub32.notFound')
|
||||
msg.classList.remove('success')
|
||||
msg.classList.add('error')
|
||||
}
|
||||
@@ -269,27 +301,30 @@ function renderSubStep32(host, back, done) {
|
||||
installBtn.addEventListener('click', async function () {
|
||||
if (installing) {
|
||||
// 진행 중이면 취소.
|
||||
msg.textContent = 'JDK 설치 취소 요청 중...'
|
||||
msg.textContent = tt('step3.sub32.cancelRequested')
|
||||
msg.classList.remove('success', 'error')
|
||||
await installerApi.cancelJdkInstall()
|
||||
return
|
||||
}
|
||||
setInstallingUi(true)
|
||||
msg.classList.remove('success', 'error')
|
||||
msg.textContent = 'Temurin 21 다운로드 중... (네트워크 상태에 따라 1~5분)'
|
||||
msg.textContent = tt('step3.sub32.downloading')
|
||||
try {
|
||||
var result = await installerApi.installJdk()
|
||||
if (result.ok && result.path) {
|
||||
input.value = result.path
|
||||
state.serverInstall.jdk = result.path
|
||||
msg.textContent = 'JDK 자동 설치 완료: ' + result.path
|
||||
msg.textContent = tt('step3.sub32.installComplete', { path: result.path })
|
||||
msg.classList.add('success')
|
||||
} else {
|
||||
msg.textContent = 'JDK 설치 ' + (result.message === '취소됨' ? '취소됨' : '실패: ' + (result.message || '알 수 없는 오류'))
|
||||
var raw = result.message || tt('common.unknownError')
|
||||
msg.textContent = raw === '취소됨'
|
||||
? tt('step3.sub32.installCanceled')
|
||||
: tt('step3.sub32.installFailed', { message: raw })
|
||||
msg.classList.add('error')
|
||||
}
|
||||
} catch (err) {
|
||||
msg.textContent = 'JDK 설치 오류: ' + (err && err.message ? err.message : err)
|
||||
msg.textContent = tt('step3.sub32.installError', { message: (err && err.message) ? err.message : String(err) })
|
||||
msg.classList.add('error')
|
||||
} finally {
|
||||
setInstallingUi(false)
|
||||
@@ -302,7 +337,7 @@ function renderSubStep32(host, back, done) {
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (installing) return
|
||||
if (!input.value.trim()) {
|
||||
msg.textContent = 'JDK 경로를 입력해 주세요.'
|
||||
msg.textContent = tt('step3.sub32.pathRequired')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
@@ -313,24 +348,24 @@ function renderSubStep32(host, back, done) {
|
||||
var detect = await installerApi.detectJdk()
|
||||
if (detect.found && !input.value) {
|
||||
input.value = detect.path
|
||||
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
|
||||
msg.textContent = tt('step3.sub32.autoDetected', { path: detect.path })
|
||||
msg.classList.add('success')
|
||||
} else if (!detect.found) {
|
||||
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.'
|
||||
msg.textContent = tt('step3.sub32.notFoundHint')
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
function renderSubStep33(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
|
||||
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
|
||||
'<h3>' + tt('step3.sub33.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub33.description') + '</p>' +
|
||||
'<div class="formMessage" id="downloadStatus">' + tt('step3.sub33.waiting') + '</div>' +
|
||||
'<div id="ramSection" hidden style="margin-top:14px;">' +
|
||||
'<h4>램 검사</h4>' +
|
||||
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
|
||||
'<h4>' + tt('step3.sub33.ramHeading') + '</h4>' +
|
||||
'<div class="formMessage" id="ramMsg">' + tt('step3.sub33.ramChecking') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
|
||||
var statusEl = host.querySelector('#downloadStatus')
|
||||
var ramSection = host.querySelector('#ramSection')
|
||||
@@ -345,7 +380,7 @@ function renderSubStep33(host, back, done) {
|
||||
|
||||
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
|
||||
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
|
||||
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
|
||||
statusEl.textContent = tt('step3.sub33.doneSummary')
|
||||
statusEl.classList.add('success')
|
||||
showRamResult(state.serverInstall.ram)
|
||||
nextBtn.disabled = false
|
||||
@@ -357,29 +392,29 @@ function renderSubStep33(host, back, done) {
|
||||
state.serverInstall.eulaAccepted = false
|
||||
nextBtn.disabled = true
|
||||
statusEl.classList.remove('success', 'error')
|
||||
statusEl.textContent = '다운로드 중...'
|
||||
statusEl.textContent = tt('step3.sub33.downloading')
|
||||
try {
|
||||
await installerApi.startServerInstall({
|
||||
packKey: state.selectedPackKey,
|
||||
installPath: state.serverInstall.path,
|
||||
jdkPath: state.serverInstall.jdk
|
||||
})
|
||||
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
|
||||
statusEl.textContent = tt('step3.sub33.eulaPrompt')
|
||||
var accepted = await openEulaPopup(state.serverInstall.path)
|
||||
if (!accepted) {
|
||||
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소했습니다. 이전→다음으로 다시 시도하세요.'
|
||||
statusEl.textContent = tt('step3.sub33.eulaRejected')
|
||||
statusEl.classList.add('error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await installerApi.acceptEula(state.serverInstall.path)
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'EULA 저장 실패: ' + err.message
|
||||
statusEl.textContent = tt('step3.sub33.eulaSaveFailed', { message: err.message })
|
||||
statusEl.classList.add('error')
|
||||
return
|
||||
}
|
||||
state.serverInstall.eulaAccepted = true
|
||||
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
|
||||
statusEl.textContent = tt('step3.sub33.doneSummary')
|
||||
statusEl.classList.add('success')
|
||||
var ram = await installerApi.checkRam(state.selectedPackKey)
|
||||
state.serverInstall.ram = ram
|
||||
@@ -387,7 +422,7 @@ function renderSubStep33(host, back, done) {
|
||||
if (ram.decision === 'tooLow') return
|
||||
nextBtn.disabled = false
|
||||
} catch (err) {
|
||||
statusEl.textContent = '다운로드 실패: ' + (err && err.message ? err.message : err)
|
||||
statusEl.textContent = tt('step3.sub33.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
|
||||
statusEl.classList.add('error')
|
||||
}
|
||||
})()
|
||||
@@ -398,13 +433,13 @@ function renderSubStep33(host, back, done) {
|
||||
if (result.decision === 'tooLow') {
|
||||
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
|
||||
var minRam = pack ? pack.pack.serverMinRam : 0
|
||||
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + minRam + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
|
||||
ramMsg.innerHTML = tt('step3.sub33.ramTooLow', { system: result.systemRamMb, min: minRam })
|
||||
ramMsg.classList.add('error')
|
||||
} else if (result.decision === 'minOk') {
|
||||
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
|
||||
ramMsg.innerHTML = tt('step3.sub33.ramMinOk', { system: result.systemRamMb, applied: result.appliedRamMb })
|
||||
ramMsg.classList.add('warn')
|
||||
} else {
|
||||
ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
|
||||
ramMsg.textContent = tt('step3.sub33.ramMaxOk', { system: result.systemRamMb, applied: result.appliedRamMb })
|
||||
ramMsg.classList.add('success')
|
||||
}
|
||||
}
|
||||
@@ -415,15 +450,15 @@ async function openEulaPopup(installPath) {
|
||||
var read = await installerApi.readEula(installPath)
|
||||
var bodyHtml = ''
|
||||
if (read.exists) {
|
||||
bodyHtml = '<p class="formMessage">서버 파일에 포함된 eula.txt 내용입니다.</p>' +
|
||||
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromFile') + '</p>' +
|
||||
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
|
||||
} else {
|
||||
var fetched = await installerApi.fetchMinecraftEula()
|
||||
if (fetched.html) {
|
||||
bodyHtml = '<p class="formMessage">서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href="' + fetched.url + '" target="_blank">' + fetched.url + '</a>).</p>' +
|
||||
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
|
||||
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
|
||||
} else {
|
||||
bodyHtml = '<p class="formMessage error">EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href="https://www.minecraft.net/en-us/eula" target="_blank">https://www.minecraft.net/en-us/eula</a></p>'
|
||||
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
|
||||
}
|
||||
}
|
||||
return new Promise(function (resolve) {
|
||||
@@ -431,11 +466,11 @@ async function openEulaPopup(installPath) {
|
||||
overlay.className = 'modalOverlay'
|
||||
overlay.innerHTML =
|
||||
'<div class="modalCard" role="dialog" aria-modal="true">' +
|
||||
'<header><h3>Minecraft EULA 동의</h3><button type="button" class="modalClose" aria-label="닫기">×</button></header>' +
|
||||
'<header><h3>' + tt('step3.eulaModal.title') + '</h3><button type="button" class="modalClose" aria-label="' + tt('common.close') + '">×</button></header>' +
|
||||
'<div class="modalBody">' + bodyHtml + '</div>' +
|
||||
'<footer class="actionRow">' +
|
||||
'<button type="button" class="secondaryBtn" data-action="reject">비동의</button>' +
|
||||
'<button type="button" class="primaryBtn" data-action="accept">동의</button>' +
|
||||
'<button type="button" class="secondaryBtn" data-action="reject">' + tt('common.reject') + '</button>' +
|
||||
'<button type="button" class="primaryBtn" data-action="accept">' + tt('common.agree') + '</button>' +
|
||||
'</footer>' +
|
||||
'</div>'
|
||||
document.body.appendChild(overlay)
|
||||
@@ -468,18 +503,18 @@ function escapeAttr(text) {
|
||||
|
||||
function renderSubStep34(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-4. 서버 설정 편집</h3>' +
|
||||
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
|
||||
'<button class="secondaryBtn" id="open">편집기 열기</button>' +
|
||||
'<h3>' + tt('step3.sub34.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub34.description') + '</p>' +
|
||||
'<button class="secondaryBtn" id="open">' + tt('step3.sub34.open') + '</button>' +
|
||||
'<div class="formMessage" id="editorMsg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
host.querySelector('#open').addEventListener('click', async function () {
|
||||
var msg = host.querySelector('#editorMsg')
|
||||
try {
|
||||
var result = await installerApi.startServerConfigEditor(state.serverInstall.path)
|
||||
msg.innerHTML = '편집기 주소: <a href="' + result.url + '" target="_blank">' + result.url + '</a>'
|
||||
msg.innerHTML = tt('step3.sub34.openedAt', { url: result.url })
|
||||
} catch (err) {
|
||||
msg.textContent = '편집기 실행 실패: ' + err.message
|
||||
msg.textContent = tt('step3.sub34.openFailed', { message: err.message })
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})
|
||||
@@ -489,12 +524,12 @@ function renderSubStep34(host, back, done) {
|
||||
|
||||
function renderSubStep35(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-5. 포트포워딩 점검</h3>' +
|
||||
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
|
||||
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
|
||||
'<button class="secondaryBtn" id="run">재점검</button>' +
|
||||
'<h3>' + tt('step3.sub35.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub35.description') + '</p>' +
|
||||
'<div class="fieldset"><label>' + tt('step3.sub35.portLabel') + ' <input id="port" type="text" value="25565" /></label></div>' +
|
||||
'<button class="secondaryBtn" id="run">' + tt('step3.sub35.recheck') + '</button>' +
|
||||
'<div class="formMessage" id="resultMsg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
var resultMsg = host.querySelector('#resultMsg')
|
||||
var nextBtn = host.querySelector('#next')
|
||||
var runBtn = host.querySelector('#run')
|
||||
@@ -503,25 +538,26 @@ function renderSubStep35(host, back, done) {
|
||||
async function runCheck() {
|
||||
runBtn.disabled = true
|
||||
resultMsg.classList.remove('success', 'warn', 'error')
|
||||
resultMsg.textContent = '확인 중...'
|
||||
resultMsg.textContent = tt('step3.sub35.checking')
|
||||
var port = Number(host.querySelector('#port').value) || 25565
|
||||
try {
|
||||
var result = await installerApi.checkPortForward(port)
|
||||
state.serverInstall.portStatus = result
|
||||
if (result.status === 'preForwarded') {
|
||||
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { ip: result.externalIp, port: result.port })
|
||||
resultMsg.classList.add('success')
|
||||
} else if (result.status === 'upnpOk') {
|
||||
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { ip: result.externalIp, port: result.port })
|
||||
resultMsg.classList.add('success')
|
||||
} else {
|
||||
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
|
||||
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
|
||||
var ip = result.externalIp || tt('step3.sub35.ipUnknown')
|
||||
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
|
||||
tt('step3.sub35.manualDetail', { ip: ip, port: result.port })
|
||||
resultMsg.classList.add('warn')
|
||||
}
|
||||
nextBtn.disabled = false
|
||||
} catch (err) {
|
||||
resultMsg.textContent = '점검 실패: ' + (err && err.message ? err.message : err)
|
||||
resultMsg.textContent = tt('step3.sub35.checkFailed', { message: (err && err.message) ? err.message : String(err) })
|
||||
resultMsg.classList.add('error')
|
||||
} finally {
|
||||
runBtn.disabled = false
|
||||
@@ -541,7 +577,7 @@ function renderStep4() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>4단계. 유저 클라이언트 설정</h2>' +
|
||||
'<h2>' + tt('step4.heading') + '</h2>' +
|
||||
'<div class="subStep" id="subHost"></div>'
|
||||
pageHost.appendChild(section)
|
||||
var subHost = section.querySelector('#subHost')
|
||||
@@ -564,23 +600,23 @@ function renderSubStep41(host, pack, back, done) {
|
||||
if (platformType === 'vanilla') {
|
||||
state.client.installPlatform = false
|
||||
host.innerHTML =
|
||||
'<h3>4-1. 모드 플랫폼</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong></p>' +
|
||||
'<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.vanillaInfo') + '</p>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.vanillaNoInstall') + '</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
return
|
||||
}
|
||||
|
||||
host.innerHTML =
|
||||
'<h3>4-1. 모드 플랫폼</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>' + platformType + '</strong></p>' +
|
||||
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.info', { platform: platformType }) + '</p>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button type="button" data-choice="install"><strong>권장 플랫폼 설치</strong><br><small>' + platformType + ' 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.</small></button>' +
|
||||
'<button type="button" data-choice="skip"><strong>기본 마인크래프트로 설치</strong><br><small>플랫폼은 설치하지 않고 바닐라로 진행합니다.</small></button>' +
|
||||
'<button type="button" data-choice="install"><strong>' + tt('step4.sub41.installTitle') + '</strong><br><small>' + tt('step4.sub41.installHint', { platform: platformType }) + '</small></button>' +
|
||||
'<button type="button" data-choice="skip"><strong>' + tt('step4.sub41.skipTitle') + '</strong><br><small>' + tt('step4.sub41.skipHint') + '</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
|
||||
var nextBtn = host.querySelector('#next')
|
||||
var choiceButtons = host.querySelectorAll('[data-choice]')
|
||||
@@ -610,10 +646,10 @@ function renderSubStep41(host, pack, back, done) {
|
||||
|
||||
function renderSubStep42(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
|
||||
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
|
||||
'<div class="formMessage" id="msg">설치 중...</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<h3>' + tt('step4.sub42.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub42.description') + '</p>' +
|
||||
'<div class="formMessage" id="msg">' + tt('step4.sub42.installing') + '</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
var msg = host.querySelector('#msg')
|
||||
var nextBtn = host.querySelector('#next')
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
@@ -621,7 +657,7 @@ function renderSubStep42(host, back, done) {
|
||||
|
||||
// 이미 설치됐다면 다시 돌리지 않음
|
||||
if (state.client.clientInstalled) {
|
||||
msg.textContent = '클라이언트 설치 완료.'
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
nextBtn.disabled = false
|
||||
return
|
||||
@@ -634,12 +670,12 @@ function renderSubStep42(host, back, done) {
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform
|
||||
})
|
||||
msg.textContent = '클라이언트 설치 완료.'
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
state.client.clientInstalled = true
|
||||
nextBtn.disabled = false
|
||||
} catch (err) {
|
||||
msg.textContent = '설치 실패: ' + (err && err.message ? err.message : err)
|
||||
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})()
|
||||
@@ -647,9 +683,9 @@ function renderSubStep42(host, back, done) {
|
||||
|
||||
function renderSubStep43(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>4-3. 완료 확인</h3>' +
|
||||
'<p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">5단계로</button></div>'
|
||||
'<h3>' + tt('step4.sub43.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub43.description') + '</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('step4.sub43.goStep5') + '</button></div>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
}
|
||||
@@ -661,19 +697,19 @@ function renderStep5() {
|
||||
section.className = 'page'
|
||||
var multi = state.mode === 'multi'
|
||||
section.innerHTML =
|
||||
'<h2>5단계. 설치 완료</h2>' +
|
||||
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</p>' +
|
||||
'<h2>' + tt('step5.heading') + '</h2>' +
|
||||
'<p>' + tt('step5.summary') + '</p>' +
|
||||
(multi ? '<div class="subStep">' +
|
||||
'<h3>서버</h3>' +
|
||||
'<button class="secondaryBtn" id="openFolder">서버 폴더 열기</button>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> 바탕화면에 서버 실행 바로가기 만들기</label>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> 서버 바로 실행</label>' +
|
||||
'<h3>' + tt('step5.serverHeading') + '</h3>' +
|
||||
'<button class="secondaryBtn" id="openFolder">' + tt('step5.openServerFolder') + '</button>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> ' + tt('step5.shortcut') + '</label>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> ' + tt('step5.startServer') + '</label>' +
|
||||
'</div>' : '') +
|
||||
'<div class="subStep">' +
|
||||
'<h3>마인크래프트 런처</h3>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> 마인크래프트 런처 실행</label>' +
|
||||
'<h3>' + tt('step5.launcherHeading') + '</h3>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> ' + tt('step5.startLauncher') + '</label>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="finish">완료</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="finish">' + tt('step5.finish') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep4)
|
||||
if (multi) {
|
||||
@@ -684,7 +720,7 @@ function renderStep5() {
|
||||
section.querySelector('#finish').addEventListener('click', async function () {
|
||||
var finishBtn = section.querySelector('#finish')
|
||||
finishBtn.disabled = true
|
||||
finishBtn.textContent = '마무리 중…'
|
||||
finishBtn.textContent = tt('step5.finishing')
|
||||
try {
|
||||
if (multi) {
|
||||
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
|
||||
@@ -694,9 +730,16 @@ function renderStep5() {
|
||||
} catch (err) {
|
||||
// 마무리 액션 실패는 무시하고 종료 진행
|
||||
}
|
||||
finishBtn.textContent = '완료됨'
|
||||
finishBtn.textContent = tt('step5.finished')
|
||||
if (installerApi.quitApp) installerApi.quitApp()
|
||||
})
|
||||
}
|
||||
|
||||
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
||||
;(async function () {
|
||||
try {
|
||||
I18N = (await installerApi.loadLocale()) || {}
|
||||
} catch (_) { I18N = {} }
|
||||
applyStaticI18n()
|
||||
renderStep1()
|
||||
})()
|
||||
|
||||
127
locales/installer-rp/ko-kr.json
Normal file
127
locales/installer-rp/ko-kr.json
Normal file
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "마인크래프트 음악퀴즈 리소스팩 간편설치기"
|
||||
},
|
||||
"stepIndicator": {
|
||||
"step1": "1. 음악퀴즈",
|
||||
"step2": "2. 설치",
|
||||
"step3": "3. 완료"
|
||||
},
|
||||
"logViewer": {
|
||||
"heading": "설치 로그",
|
||||
"collapse": "접기",
|
||||
"expand": "펼치기"
|
||||
},
|
||||
"common": {
|
||||
"next": "다음",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"openFolder": "리소스팩 폴더 열기",
|
||||
"loading": "목록을 불러오는 중...",
|
||||
"selectFailed": "선택 실패",
|
||||
"listLoadFailed": "목록 로드 실패: {{message}}",
|
||||
"installFailed": "설치 실패: {{message}}",
|
||||
"noPacks": "등록된 음악퀴즈가 없습니다.",
|
||||
"mcVersionLabel": "마인크래프트 {{version}} · ",
|
||||
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
|
||||
"requestTimeout": "요청 시간 초과",
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||
},
|
||||
"step1": {
|
||||
"heading": "1단계. 음악퀴즈 선택"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "2단계. 리소스팩 설치",
|
||||
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
|
||||
"chipYtdlp": "yt-dlp 준비",
|
||||
"chipFfmpeg": "ffmpeg 준비",
|
||||
"musicHeading": "음악 다운로드",
|
||||
"musicSub": "{{count}}곡",
|
||||
"imageHeading": "사진 다운로드",
|
||||
"imageSub": "{{count}}장",
|
||||
"packageHeading": "리소스팩 빌드",
|
||||
"packageWaiting": "대기 중…",
|
||||
"packageBuilding": "빌드 중…",
|
||||
"packageDone": "설치 완료",
|
||||
"cardWaiting": "대기",
|
||||
"cardDone": "완료",
|
||||
"cardError": "실패"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "3단계. 완료",
|
||||
"message": "리소스팩 설치를 완료했습니다."
|
||||
},
|
||||
"log": {
|
||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
|
||||
"listLoadFailed": "목록 로드 실패 ({{file}}): {{message}}",
|
||||
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||
"packEntry": " - {{key}}: mc={{mc}} 베이스={{base}}",
|
||||
"packEntryUnknownVersion": "?",
|
||||
"packEntryNoBase": "(없음)",
|
||||
"selectedPack": "선택: {{key}}",
|
||||
"ytdlpPreparing": "yt-dlp 준비 중…",
|
||||
"ytdlpPath": "yt-dlp 경로: {{path}}",
|
||||
"ffmpegPreparing": "ffmpeg 준비 중…",
|
||||
"ffmpegPath": "ffmpeg 경로: {{path}}",
|
||||
"cpuDetected": "CPU 코어 {{cores}}개 감지 → 동시 다운로드 {{concurrency}}개",
|
||||
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
||||
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
||||
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
||||
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
||||
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
||||
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
||||
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
|
||||
"baseUrl": " URL: {{url}}",
|
||||
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
||||
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
|
||||
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
|
||||
"installComplete": "설치 완료: {{path}}",
|
||||
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",
|
||||
"ytdlpExists": "yt-dlp.exe 이미 있음: {{path}}",
|
||||
"ytdlpDownloading": "yt-dlp.exe 다운로드 중: {{url}}",
|
||||
"ytdlpReady": "yt-dlp.exe 준비 완료: {{path}}",
|
||||
"ffmpegExists": "ffmpeg.exe 이미 있음: {{path}}",
|
||||
"ffmpegDownloading": "ffmpeg.exe 다운로드 중: {{url}}",
|
||||
"ffmpegExtracting": "ffmpeg zip 압축 해제 중…",
|
||||
"ffmpegReady": "ffmpeg.exe 준비 완료: {{path}}",
|
||||
"baseExtract": "베이스 리소스팩 압축 해제: {{name}}",
|
||||
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
|
||||
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
||||
"ytdlpLine": "yt-dlp> {{line}}"
|
||||
},
|
||||
"progress": {
|
||||
"ytdlpPreparing": "yt-dlp 준비 중",
|
||||
"ffmpegPreparing": "ffmpeg 준비 중",
|
||||
"ready": "준비 완료",
|
||||
"cancelled": "취소됨",
|
||||
"baseDownloading": "베이스 리소스팩 다운로드 중",
|
||||
"buildingWithBase": "베이스에 음악·사진 추가 중",
|
||||
"buildingZip": "zip 빌드 중",
|
||||
"installComplete": "설치 완료"
|
||||
},
|
||||
"pack": {
|
||||
"description": "음악퀴즈 리소스팩 - {{name}}"
|
||||
},
|
||||
"errors": {
|
||||
"selectedPackNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
|
||||
"selectPackFirst": "음악퀴즈를 먼저 선택해주세요.",
|
||||
"currentPackNotFound": "선택된 음악퀴즈를 찾을 수 없습니다.",
|
||||
"cancelledByUser": "사용자가 설치를 취소했습니다.",
|
||||
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
|
||||
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
|
||||
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
|
||||
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
|
||||
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",
|
||||
"ytdlpExit": "yt-dlp 종료 코드 {{code}}: {{stderr}}",
|
||||
"ytdlpNoStderr": "(stderr 없음)",
|
||||
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
|
||||
"imageMetaUnknown": "이미지 크기를 읽지 못함",
|
||||
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
|
||||
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
|
||||
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
|
||||
}
|
||||
}
|
||||
302
locales/installer/ko-kr.json
Normal file
302
locales/installer/ko-kr.json
Normal file
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"common": {
|
||||
"back": "이전",
|
||||
"next": "다음",
|
||||
"ok": "확인",
|
||||
"cancel": "취소",
|
||||
"close": "닫기",
|
||||
"agree": "동의",
|
||||
"reject": "비동의",
|
||||
"apply": "적용",
|
||||
"save": "저장",
|
||||
"load": "불러오기",
|
||||
"expand": "펼치기",
|
||||
"collapse": "접기",
|
||||
"saved": "저장 완료",
|
||||
"saveFailed": "저장 실패",
|
||||
"unknownError": "알 수 없는 오류"
|
||||
},
|
||||
"app": {
|
||||
"browserTitle": "마인크래프트 음악퀴즈 간편설치기",
|
||||
"headerTitle": "마인크래프트 음악퀴즈 간편설치기"
|
||||
},
|
||||
"stepIndicator": {
|
||||
"step1": "1. 음악퀴즈",
|
||||
"step2": "2. 모드",
|
||||
"step3": "3. 서버",
|
||||
"step4": "4. 클라이언트",
|
||||
"step5": "5. 완료"
|
||||
},
|
||||
"logViewer": {
|
||||
"title": "설치 로그"
|
||||
},
|
||||
"step1": {
|
||||
"heading": "1단계. 설치할 음악퀴즈 선택",
|
||||
"loading": "목록을 불러오는 중...",
|
||||
"empty": "등록된 음악퀴즈가 없습니다.",
|
||||
"fetchFailed": "목록을 가져오지 못했습니다: {{message}}",
|
||||
"subtitle": "마인크래프트 {{mc}} / {{platform}}"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "2단계. 싱글 / 멀티 선택",
|
||||
"singleTitle": "싱글",
|
||||
"singleHint": "혼자 즐기는 모드. 4단계만 진행합니다.",
|
||||
"multiTitle": "멀티",
|
||||
"multiHint": "친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다."
|
||||
},
|
||||
"step3": {
|
||||
"heading": "3단계. 서버 관련 설정",
|
||||
"sub31": {
|
||||
"heading": "3-1. 서버 설치 경로",
|
||||
"description": "서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.",
|
||||
"pickFolder": "폴더 선택",
|
||||
"invalidPath": "경로가 유효하지 않습니다.",
|
||||
"confirmed": "경로 확정: {{message}}"
|
||||
},
|
||||
"sub32": {
|
||||
"heading": "3-2. JDK 확인",
|
||||
"description": "JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 \"자동 설치\" 로 Temurin 21 을 받아 설치할 수 있습니다.",
|
||||
"pickFolder": "폴더 선택",
|
||||
"auto": "자동 탐색",
|
||||
"install": "자동 설치",
|
||||
"installCancel": "설치 취소",
|
||||
"found": "JDK 발견: {{path}}",
|
||||
"autoDetected": "JDK 자동 탐색됨: {{path}}",
|
||||
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.",
|
||||
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.",
|
||||
"cancelRequested": "JDK 설치 취소 요청 중...",
|
||||
"downloading": "Temurin 21 다운로드 중... (네트워크 상태에 따라 1~5분)",
|
||||
"installComplete": "JDK 자동 설치 완료: {{path}}",
|
||||
"installCanceled": "JDK 설치 취소됨",
|
||||
"installFailed": "JDK 설치 실패: {{message}}",
|
||||
"installError": "JDK 설치 오류: {{message}}",
|
||||
"pathRequired": "JDK 경로를 입력해 주세요."
|
||||
},
|
||||
"sub33": {
|
||||
"heading": "3-3. 서버 다운로드 및 설치",
|
||||
"description": "선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.",
|
||||
"waiting": "대기 중",
|
||||
"downloading": "다운로드 중...",
|
||||
"ramHeading": "램 검사",
|
||||
"ramChecking": "검사 중...",
|
||||
"eulaPrompt": "EULA 동의가 필요합니다. 팝업을 확인해 주세요.",
|
||||
"eulaRejected": "EULA 동의 실패. 다운로드를 취소했습니다. 이전→다음으로 다시 시도하세요.",
|
||||
"eulaSaveFailed": "EULA 저장 실패: {{message}}",
|
||||
"doneSummary": "다운로드 및 EULA 동의 완료.",
|
||||
"downloadFailed": "다운로드 실패: {{message}}",
|
||||
"ramTooLow": "시스템 램({{system}}MB)이 음악퀴즈 최소 요구치({{min}}MB)에 미치지 못합니다. 설치를 중단합니다.",
|
||||
"ramMinOk": "시스템 램({{system}}MB)이 권장치보다 부족합니다. 최소치({{applied}}MB)로 진행합니다.",
|
||||
"ramMaxOk": "시스템 램({{system}}MB) 충분. {{applied}}MB로 설정."
|
||||
},
|
||||
"eulaModal": {
|
||||
"title": "Minecraft EULA 동의",
|
||||
"fromFile": "서버 파일에 포함된 eula.txt 내용입니다.",
|
||||
"fromMojang": "서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
|
||||
"loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href=\"https://www.minecraft.net/en-us/eula\" target=\"_blank\">https://www.minecraft.net/en-us/eula</a>"
|
||||
},
|
||||
"sub34": {
|
||||
"heading": "3-4. 서버 설정 편집",
|
||||
"description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.",
|
||||
"open": "편집기 열기",
|
||||
"openedAt": "편집기 주소: <a href=\"{{url}}\" target=\"_blank\">{{url}}</a>",
|
||||
"openFailed": "편집기 실행 실패: {{message}}"
|
||||
},
|
||||
"sub35": {
|
||||
"heading": "3-5. 포트포워딩 점검",
|
||||
"description": "서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.",
|
||||
"portLabel": "포트",
|
||||
"recheck": "재점검",
|
||||
"checking": "확인 중...",
|
||||
"preForwarded": "이미 외부 접속 가능: {{ip}}:{{port}}",
|
||||
"upnpOk": "UPnP로 자동 개방 완료: {{ip}}:{{port}}",
|
||||
"manualHint": "직접 포트포워딩을 해주세요.",
|
||||
"manualDetail": "<br><small>외부 IP: {{ip}}, 포트: {{port}}</small>",
|
||||
"checkFailed": "점검 실패: {{message}}",
|
||||
"ipUnknown": "확인 불가"
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
"heading": "4단계. 유저 클라이언트 설정",
|
||||
"sub41": {
|
||||
"heading": "4-1. 모드 플랫폼",
|
||||
"vanillaInfo": "선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong>",
|
||||
"vanillaNoInstall": "바닐라이므로 별도 설치는 필요 없습니다.",
|
||||
"info": "선택한 음악퀴즈의 플랫폼: <strong>{{platform}}</strong>",
|
||||
"installTitle": "권장 플랫폼 설치",
|
||||
"installHint": "{{platform}} 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.",
|
||||
"skipTitle": "기본 마인크래프트로 설치",
|
||||
"skipHint": "플랫폼은 설치하지 않고 바닐라로 진행합니다."
|
||||
},
|
||||
"sub42": {
|
||||
"heading": "4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신",
|
||||
"description": "%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.",
|
||||
"installing": "설치 중...",
|
||||
"done": "클라이언트 설치 완료.",
|
||||
"failed": "설치 실패: {{message}}"
|
||||
},
|
||||
"sub43": {
|
||||
"heading": "4-3. 완료 확인",
|
||||
"description": "모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.",
|
||||
"goStep5": "5단계로"
|
||||
}
|
||||
},
|
||||
"step5": {
|
||||
"heading": "5단계. 설치 완료",
|
||||
"summary": "모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.",
|
||||
"serverHeading": "서버",
|
||||
"openServerFolder": "서버 폴더 열기",
|
||||
"shortcut": "바탕화면에 서버 실행 바로가기 만들기",
|
||||
"startServer": "서버 바로 실행",
|
||||
"launcherHeading": "마인크래프트 런처",
|
||||
"startLauncher": "마인크래프트 런처 실행",
|
||||
"finish": "완료",
|
||||
"finishing": "마무리 중…",
|
||||
"finished": "완료됨"
|
||||
},
|
||||
"configEditor": {
|
||||
"pageTitle": "서버 설정 편집기",
|
||||
"heading": "서버 설정 편집기",
|
||||
"intro": "아래 파일을 직접 편집한 후 \"적용\" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.",
|
||||
"targetLabel": "대상 파일",
|
||||
"applyButton": "적용",
|
||||
"saved": "저장 완료",
|
||||
"saveFailed": "저장 실패",
|
||||
"unknownFile": "알 수 없는 파일",
|
||||
"serverError": "서버 오류: {{message}}"
|
||||
},
|
||||
"errors": {
|
||||
"requestTimeout": "요청 시간 초과",
|
||||
"requestTimeout15s": "요청 시간 초과(15s)",
|
||||
"canceled": "취소되었습니다.",
|
||||
"canceledShort": "취소됨",
|
||||
"packNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
|
||||
"packNotFound2": "음악퀴즈를 찾을 수 없습니다.",
|
||||
"installPathRequired": "서버 설치 경로를 입력해 주세요.",
|
||||
"installPathHangul": "경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.",
|
||||
"installPathHangulShort": "경로에 한글이 포함되면 안 됩니다.",
|
||||
"jdkBusy": "이미 JDK 설치가 진행 중입니다.",
|
||||
"javaExeMissing": "설치 후 java 실행 파일을 찾지 못했습니다: {{path}}",
|
||||
"javaSpawnFailed": "Java 실행 실패: {{message}}",
|
||||
"fabricInstallerExit": "fabric-installer 종료 코드 {{code}}{{detail}}",
|
||||
"fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.",
|
||||
"fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.",
|
||||
"portAllocFail": "포트를 할당할 수 없습니다.",
|
||||
"upnpTimeout": "UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.",
|
||||
"parseResponseFailed": "응답 파싱 실패: {{snippet}}"
|
||||
},
|
||||
"log": {
|
||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
|
||||
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||
"selectedPack": "선택: {{key}}",
|
||||
"jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...",
|
||||
"jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)",
|
||||
"jdkExtracting": "JDK 압축 해제 중...",
|
||||
"jdkDoneRoot": "JDK 자동 설치 완료: {{path}}",
|
||||
"jdkCanceled": "JDK 설치가 취소되었습니다.",
|
||||
"jdkInstallFailedLog": "JDK 설치 실패: {{message}}",
|
||||
"jdkCancelRequested": "JDK 설치 취소 요청을 보냈습니다.",
|
||||
"labelDownload": "{{label}} 다운로드: {{url}}",
|
||||
"labelExtract": "{{label}} 압축 해제: {{dir}}",
|
||||
"labelServerFile": "서버 파일",
|
||||
"labelMap": "맵",
|
||||
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
|
||||
"skipMapZip": "맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.",
|
||||
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
|
||||
"modsIndexFetch": "모드 목록 조회: {{url}}",
|
||||
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",
|
||||
"modDownload": "모드 다운로드: {{file}}",
|
||||
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
|
||||
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
|
||||
"serverInstallPath": "서버 설치 경로: {{path}}",
|
||||
"runBatMissing": "run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.",
|
||||
"runBatAlreadyInjected": "run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.",
|
||||
"runBatNoJava": "run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.",
|
||||
"runBatInjected": "run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.",
|
||||
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
|
||||
"eulaAccepted": "EULA 동의 저장 완료.",
|
||||
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
|
||||
"portCheckStart": "포트포워딩 점검 시작: 포트 {{port}}",
|
||||
"upnpCleanup": "이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...",
|
||||
"externalIpHttp": "외부 IP 확인(HTTP): {{ip}}",
|
||||
"externalIpHttpFail": "외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...",
|
||||
"externalIpUpnp": "외부 IP 확인(UPnP): {{ip}}",
|
||||
"externalIpUpnpFail": "UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.",
|
||||
"probeStart": "외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...",
|
||||
"probeResult": "1차 점검 결과: {{verdict}} ({{detail}})",
|
||||
"probeVerdictSuccess": "성공",
|
||||
"probeVerdictFail": "실패",
|
||||
"probeVerdictUnknown": "확인 불가",
|
||||
"probePreForwarded": "외부에서 {{addr}}:{{port}} 접근 확인됨. 사용자 규칙으로 포워딩 됨.",
|
||||
"ipUnknown": "(IP 미상)",
|
||||
"upnpTryOpen": "UPnP로 포트 {{port}} 자동 개방 시도(TCP)...",
|
||||
"upnpReqOk": "UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.",
|
||||
"upnpTryFail": "UPnP 시도 실패: {{message}}",
|
||||
"upnpFailDetail": "UPnP 실패: {{message}}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.",
|
||||
"upnpRecheck": "UPnP 적용 후 재점검 {{attempt}}/3...",
|
||||
"upnpDone": "UPnP로 포트 {{port}} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).",
|
||||
"upnpCleanupTest": "테스트용 UPnP 매핑을 정리합니다.",
|
||||
"upnpFailReason1": "UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.",
|
||||
"upnpFailReason2": "외부 포트체크 결과를 받지 못했습니다({{detail}}). UPnP 매핑은 등록됐을 수 있습니다.",
|
||||
"upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}",
|
||||
"upnpExternalTimeout": "UPnP externalIp 조회 타임아웃(8s).",
|
||||
"upnpExternalErr": "UPnP externalIp 오류: {{message}}",
|
||||
"portInUse": "포트 {{port}}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.",
|
||||
"listenerBindFail": "임시 리스너 바인딩 실패: {{message}}",
|
||||
"detailListenerHit": "임시 리스너 도달={{value}}",
|
||||
"detailListenerSkip": "임시 리스너=skip(포트 사용중)",
|
||||
"detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}",
|
||||
"detailIfconfigFail": "ifconfig.co 실패={{error}}",
|
||||
"detailNone": "결과 없음",
|
||||
"upnpClientFailRemove": "UPnP 클라이언트 생성 실패(매핑 제거 단계): {{message}}",
|
||||
"upnpRemoveTimeout": "UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.",
|
||||
"upnpRemoveAttempt": "UPnP 매핑 제거 시도 결과: {{message}} (없으면 정상)",
|
||||
"upnpRemoveDone": "UPnP 매핑 제거 완료(포트 {{port}}).",
|
||||
"platformDownload": "플랫폼({{type}}) 다운로드: {{url}}",
|
||||
"platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)",
|
||||
"platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.",
|
||||
"fabricFetchInstallerList": "Fabric installer 최신 버전 조회 중...",
|
||||
"fabricInstallerDownload": "Fabric installer {{version}} 다운로드: {{url}}",
|
||||
"javaUsed": "Java 사용: {{path}}",
|
||||
"fabricInstallStart": "Fabric 자동 설치 시작: {{mc}} / loader {{loader}} → {{dir}}",
|
||||
"fabricInstallDone": "Fabric 자동 설치 완료.",
|
||||
"launcherProfilesMissing": "launcher_profiles.json을 찾을 수 없습니다: {{path}}",
|
||||
"javaArgsUpdated": "JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): \"{{before}}\" → \"{{after}}\"",
|
||||
"lastVersionId": "launcher_profiles 의 lastVersionId = {{id}}",
|
||||
"versionMissingWarn": "경고: .minecraft/versions/{{id}} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.",
|
||||
"launcherProfilesUpdated": "launcher_profiles.json 갱신: 프로필 \"{{profile}}\", gameDir={{dir}}",
|
||||
"minecraftRootMissing": ".minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.",
|
||||
"settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}",
|
||||
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 보존(이미 존재) {{skipped}}개.",
|
||||
"settingCopyError": "기존 설정 복사 중 오류: {{message}}",
|
||||
"runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.",
|
||||
"runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.",
|
||||
"runtimeLinkCreated": "링크 생성: .mc_custom/{{dir}} → .minecraft/{{dir}}",
|
||||
"runtimeLinkFail": "링크 생성 실패 ({{dir}}): {{message}}",
|
||||
"shortcutCreated": "바로가기 생성: {{path}}",
|
||||
"shortcutFailed": "바로가기 생성 실패",
|
||||
"shortcutDescription": "음악퀴즈 서버 실행",
|
||||
"runBatMissingPath": "run.bat을 찾을 수 없습니다: {{path}}",
|
||||
"serverStartRequested": "서버 실행 요청 완료.",
|
||||
"launcherUrlSchemeNonWin": "마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).",
|
||||
"launcherFail": "런처 실행 실패: {{message}}",
|
||||
"launcherExecShell": "마인크래프트 런처 실행({{label}}, 셸 경유): {{path}}",
|
||||
"launcherExec": "마인크래프트 런처 실행({{label}}): {{path}}",
|
||||
"launcherCandFail": "{{path}} 실행 실패: {{message}}",
|
||||
"launcherAppsFolderTry": "AppsFolder 로 MS Store 런처 실행 시도: {{aumid}}",
|
||||
"launcherAppsFolderFail": "AppsFolder 실행 실패: {{message}}",
|
||||
"launcherUrlSchemeFallback": "마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).",
|
||||
"launcherUrlSchemeFail": "URL 스킴 실행 실패: {{message}}.",
|
||||
"launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요."
|
||||
},
|
||||
"candidates": {
|
||||
"winProgramFiles86": "Win32 설치(Program Files (x86))",
|
||||
"winProgramFiles": "Win32 설치(Program Files)",
|
||||
"winLegacy86": "Win32 설치(legacy Minecraft 폴더)",
|
||||
"winLegacy": "Win32 설치(legacy Minecraft 폴더)",
|
||||
"xboxGamePass": "Xbox / Game Pass",
|
||||
"npmPortable": "npm/portable",
|
||||
"appAliasMinecraft": "App Execution Alias(Minecraft.exe)",
|
||||
"appAliasLauncher": "App Execution Alias(MinecraftLauncher.exe)"
|
||||
}
|
||||
}
|
||||
166
locales/server/ko-kr.json
Normal file
166
locales/server/ko-kr.json
Normal 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/<파일명></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/<폴더이름>/ 안의 모든 .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": "(가수 미상)"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,22 @@
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
// listEditor.ejs 에서 주입되는 사전 (locales/server/ko-kr.json 의 listEditor + common 섹션).
|
||||
// 키가 비어 있어도 lookup 함수가 키를 그대로 반환해 UI 가 깨지지는 않는다.
|
||||
function tt(key, params) {
|
||||
var parts = key.split('.')
|
||||
var cur = (typeof I18N !== 'undefined') ? I18N : {}
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
|
||||
else { cur = null; break }
|
||||
}
|
||||
var tpl = (typeof cur === 'string') ? cur : key
|
||||
if (!params) return tpl
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
||||
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
|
||||
})
|
||||
}
|
||||
|
||||
var state = {
|
||||
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
|
||||
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
|
||||
@@ -87,10 +103,10 @@
|
||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||
'<div class="rowMeta">' +
|
||||
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="(제목 없음)" title="더블클릭해서 수정">' +
|
||||
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="' + escapeHtml(tt('titleFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
|
||||
escapeHtml(entry.title || '') +
|
||||
'</div>' +
|
||||
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="(가수 미상)" title="더블클릭해서 수정">' +
|
||||
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="' + escapeHtml(tt('artistFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
|
||||
escapeHtml(entry.artist || '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
@@ -116,7 +132,7 @@
|
||||
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
||||
'</div>' +
|
||||
'<div class="cardCaption">' +
|
||||
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || '<span class="muted">(제목 없음)</span>') + '</div>' +
|
||||
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || ('<span class="muted">' + escapeHtml(tt('titleFallback')) + '</span>')) + '</div>' +
|
||||
'<div class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
|
||||
'</div>'
|
||||
attachDraggable(card, 'image', idx)
|
||||
@@ -330,7 +346,7 @@
|
||||
if (!url) return
|
||||
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
|
||||
if (url === prev.url) { closeAllModals(); return }
|
||||
setStatus('edit-music-status', '메타데이터 가져오는 중…')
|
||||
setStatus('edit-music-status', tt('metaLoading'))
|
||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -339,8 +355,8 @@
|
||||
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
|
||||
}).then(function (result) {
|
||||
if (!result.ok || !result.body || !result.body.ok) {
|
||||
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
|
||||
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
|
||||
var msg = (result.body && result.body.message) ? result.body.message : tt('metaFailedShort')
|
||||
ask(tt('metaFailedTitle'), tt('metaFailedAsk', { message: msg }), function () {
|
||||
state.music[editingIdx].url = url
|
||||
markDirty()
|
||||
closeAllModals()
|
||||
@@ -360,7 +376,7 @@
|
||||
closeAllModals()
|
||||
renderMusic()
|
||||
}).catch(function (err) {
|
||||
setStatus('edit-music-status', '실패: ' + err.message, true)
|
||||
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -389,17 +405,16 @@
|
||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||
if (state.music.length === 0) {
|
||||
setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true)
|
||||
setStatus('status-image', tt('imageFromMusicEmpty'), true)
|
||||
return
|
||||
}
|
||||
ask('사진목록 가져오기',
|
||||
'저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n'
|
||||
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
|
||||
ask(tt('imageFromMusicTitle'),
|
||||
tt('imageFromMusicConfirm', { count: state.music.length }),
|
||||
function () {
|
||||
state.images = state.music.map(function (m) { return { url: m.url } })
|
||||
markDirty()
|
||||
renderImage()
|
||||
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
|
||||
setStatus('status-image', tt('fetchedCount', { count: state.images.length }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -431,7 +446,8 @@
|
||||
var action = btn.getAttribute('data-action')
|
||||
var target = btn.getAttribute('data-target')
|
||||
if (action === 'clear') {
|
||||
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
|
||||
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
|
||||
ask(tt('clearTitle'), tt('clearConfirm', { type: typeLabel }), function () {
|
||||
if (target === 'music') { state.music = []; renderMusic() }
|
||||
else { state.images = []; renderImage() }
|
||||
markDirty()
|
||||
@@ -457,7 +473,7 @@
|
||||
}
|
||||
})
|
||||
var statusId = 'status-' + target
|
||||
setStatus(statusId, '저장 중…')
|
||||
setStatus(statusId, tt('saving'))
|
||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -465,10 +481,10 @@
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||
}).then(function (result) {
|
||||
if (result.ok && result.body.ok) { setStatus(statusId, '저장 완료'); markClean() }
|
||||
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
|
||||
if (result.ok && result.body.ok) { setStatus(statusId, tt('saved')); markClean() }
|
||||
else setStatus(statusId, tt('saveFailed', { message: result.body.message || '' }), true)
|
||||
}).catch(function (err) {
|
||||
setStatus(statusId, '저장 실패: ' + err.message, true)
|
||||
setStatus(statusId, tt('saveFailed', { message: err.message }), true)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -476,11 +492,12 @@
|
||||
var input = document.getElementById(target + '-playlist-url')
|
||||
var url = input.value.trim()
|
||||
if (!url) {
|
||||
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true)
|
||||
setStatus('status-' + target, tt('fetchEnterUrl'), true)
|
||||
return
|
||||
}
|
||||
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () {
|
||||
setStatus('status-' + target, '불러오는 중…')
|
||||
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
|
||||
ask(tt('fetchTitle'), tt('fetchConfirm', { type: typeLabel }), function () {
|
||||
setStatus('status-' + target, tt('fetchLoading'))
|
||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -489,7 +506,7 @@
|
||||
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||
}).then(function (result) {
|
||||
if (!result.ok || !result.body.ok) {
|
||||
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true)
|
||||
setStatus('status-' + target, tt('failed', { message: result.body.message || '' }), true)
|
||||
return
|
||||
}
|
||||
var entries = result.body.entries || []
|
||||
@@ -503,9 +520,9 @@
|
||||
renderImage()
|
||||
}
|
||||
markDirty()
|
||||
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
|
||||
setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
|
||||
}).catch(function (err) {
|
||||
setStatus('status-' + target, '실패: ' + err.message, true)
|
||||
setStatus('status-' + target, tt('failed', { message: err.message }), true)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -527,9 +544,7 @@
|
||||
if (!dirty) return
|
||||
e.preventDefault()
|
||||
var href = a.getAttribute('href')
|
||||
ask('저장되지 않은 변경사항',
|
||||
'저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?',
|
||||
function () {
|
||||
ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
|
||||
markClean()
|
||||
window.location.href = href
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
|
||||
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
|
||||
@@ -31,7 +34,7 @@ export async function ensureFfmpegExe(
|
||||
): Promise<string> {
|
||||
const target = getFfmpegExePath()
|
||||
if (await canExecute(target)) {
|
||||
log?.(`ffmpeg.exe 이미 있음: ${target}`)
|
||||
log?.(t('log.ffmpegExists', { path: target }))
|
||||
return target
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
@@ -46,14 +49,14 @@ export async function ensureFfmpegExe(
|
||||
await fs.rm(zipPath, { force: true })
|
||||
await fs.rm(extractDir, { recursive: true, force: true })
|
||||
|
||||
log?.(`ffmpeg.exe 다운로드 중: ${FFMPEG_ZIP_URL}`)
|
||||
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
|
||||
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
|
||||
log?.('ffmpeg zip 압축 해제 중…')
|
||||
log?.(t('log.ffmpegExtracting'))
|
||||
await extractZip(zipPath, { dir: extractDir })
|
||||
|
||||
const found = await findFile(extractDir, 'ffmpeg.exe')
|
||||
if (!found) {
|
||||
throw new Error('zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.')
|
||||
throw new Error(t('errors.ffmpegNotInZip'))
|
||||
}
|
||||
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
|
||||
try {
|
||||
@@ -63,14 +66,15 @@ export async function ensureFfmpegExe(
|
||||
}
|
||||
|
||||
const ok = await probeVersion(target)
|
||||
if (!ok) throw new Error('ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
|
||||
log?.(`ffmpeg.exe 준비 완료: ${target}`)
|
||||
if (!ok) throw new Error(t('errors.ffmpegVerifyFailed'))
|
||||
log?.(t('log.ffmpegReady', { path: target }))
|
||||
return target
|
||||
} catch (err) {
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
throw new Error(
|
||||
'ffmpeg.exe 자동 설치 실패: ' +
|
||||
(err instanceof Error ? err.message : String(err))
|
||||
t('errors.ffmpegInstallFailed', {
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
// 임시 파일/폴더 정리
|
||||
@@ -114,7 +118,7 @@ async function findFile(root: string, name: string): Promise<string | null> {
|
||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error('redirect 가 너무 많습니다.'))
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const lib = url.startsWith('https://') ? https : http
|
||||
|
||||
@@ -4,6 +4,9 @@ import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import { URL } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
|
||||
const MAX_SIDE = 1024
|
||||
@@ -30,7 +33,7 @@ export function ytIdFromUrl(url: string): string {
|
||||
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error('redirect 가 너무 많습니다.'))
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const target = new URL(url)
|
||||
@@ -56,7 +59,7 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
|
||||
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,7 +94,7 @@ export async function normalizeToCover(buffer: Buffer, outPath: string): Promise
|
||||
const meta = await img.metadata()
|
||||
const w = meta.width ?? 0
|
||||
const h = meta.height ?? 0
|
||||
if (w <= 0 || h <= 0) throw new Error('이미지 크기를 읽지 못함')
|
||||
if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown'))
|
||||
const s = Math.min(w, h)
|
||||
const left = Math.floor((w - s) / 2)
|
||||
const top = Math.floor((h - s) / 2)
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
|
||||
import { normalizePackDefinition } from '../shared/store.js'
|
||||
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
|
||||
import { loadEnv, getManifestUrl } from '../shared/env.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
import type { RpFetchedPack } from './types.js'
|
||||
import { ensureYtDlpExe } from './ytdlp.js'
|
||||
import { ensureFfmpegExe } from './ffmpeg.js'
|
||||
@@ -19,6 +20,9 @@ import { downloadImage, normalizeToCover, coverFileName } from './images.js'
|
||||
import { buildResourcepackZip } from './pack.js'
|
||||
|
||||
loadEnv()
|
||||
const i18n = loadComponentI18n('installer-rp')
|
||||
const t = i18n.t
|
||||
export const localeDict = i18n.dict
|
||||
|
||||
interface RpInstallerState {
|
||||
manifestUrl: string
|
||||
@@ -154,7 +158,7 @@ function fetchBuffer(url: string): Promise<Buffer> {
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
|
||||
request.on('timeout', () => request.destroy(new Error(t('common.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,7 +173,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
||||
state.manifestUrl = manifestUrlInput
|
||||
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
||||
}
|
||||
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
|
||||
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
|
||||
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
||||
const results: RpFetchedPack[] = []
|
||||
for (const entry of manifest.packs ?? []) {
|
||||
@@ -181,7 +185,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
||||
const [listRaw, packRaw] = await Promise.all([
|
||||
fetchJson<Partial<PackList>>(listUrl),
|
||||
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
|
||||
sendLog(`팩 정의 로드 실패 (${entry.file}): ${(err as Error).message} — mcVersion 폴백`)
|
||||
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
|
||||
return null
|
||||
})
|
||||
])
|
||||
@@ -202,31 +206,37 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
||||
list
|
||||
})
|
||||
} catch (error) {
|
||||
sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`)
|
||||
sendLog(t('log.listLoadFailed', { file: entry.file, message: (error as Error).message }))
|
||||
}
|
||||
}
|
||||
state.packs.clear()
|
||||
for (const item of results) state.packs.set(item.key, item)
|
||||
sendLog(`로드된 음악퀴즈: ${results.length}개`)
|
||||
sendLog(t('log.packsLoaded', { count: results.length }))
|
||||
for (const item of results) {
|
||||
sendLog(` - ${item.key}: mc=${item.mcVersion || '?'} 베이스=${item.resourcepackPath || '(없음)'}`)
|
||||
sendLog(t('log.packEntry', {
|
||||
key: item.key,
|
||||
mc: item.mcVersion || t('log.packEntryUnknownVersion'),
|
||||
base: item.resourcepackPath || t('log.packEntryNoBase')
|
||||
}))
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
||||
if (!state.packs.has(packKey)) {
|
||||
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
|
||||
throw new Error(t('errors.selectedPackNotFound'))
|
||||
}
|
||||
state.selectedKey = packKey
|
||||
sendLog(`선택: ${packKey}`)
|
||||
sendLog(t('log.selectedPack', { key: packKey }))
|
||||
})
|
||||
|
||||
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||
|
||||
// ── IPC: 2단계 설치 ──────────────────────────────────
|
||||
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
||||
if (!state.selectedKey) throw new Error('음악퀴즈를 먼저 선택해주세요.')
|
||||
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
||||
const pack = state.packs.get(state.selectedKey)
|
||||
if (!pack) throw new Error('선택된 음악퀴즈를 찾을 수 없습니다.')
|
||||
if (!pack) throw new Error(t('errors.currentPackNotFound'))
|
||||
state.cancelRequested = false
|
||||
|
||||
const tempRoot = path.join(getMcCustomDir(), '.temp')
|
||||
@@ -237,16 +247,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
try {
|
||||
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
|
||||
sendLog('yt-dlp 준비 중…')
|
||||
sendProgress({ phase: 'prep', message: 'yt-dlp 준비 중' })
|
||||
sendLog(t('log.ytdlpPreparing'))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
|
||||
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
||||
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
|
||||
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
|
||||
throwIfCancelled()
|
||||
sendLog('ffmpeg 준비 중…')
|
||||
sendProgress({ phase: 'prep', message: 'ffmpeg 준비 중' })
|
||||
sendLog(t('log.ffmpegPreparing'))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
|
||||
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
||||
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
|
||||
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
|
||||
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
|
||||
@@ -256,8 +266,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
const cpuCount = os.cpus()?.length ?? 0
|
||||
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
|
||||
nextMusicStartAt = Date.now()
|
||||
sendLog(`CPU 코어 ${cpuCount}개 감지 → 동시 다운로드 ${concurrency}개`)
|
||||
sendLog(`음악 다운로드 시작 (${musicTotal}곡, 동시 ${concurrency}개, 시차 ${MUSIC_START_STAGGER_MS}ms)`)
|
||||
sendLog(t('log.cpuDetected', { cores: cpuCount, concurrency }))
|
||||
sendLog(t('log.musicStart', { total: musicTotal, concurrency, stagger: MUSIC_START_STAGGER_MS }))
|
||||
|
||||
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
||||
const musicList = pack.list.music
|
||||
@@ -272,7 +282,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
if (state.cancelRequested) return
|
||||
const entry = musicList[i]
|
||||
const idx = i + 1
|
||||
sendLog(`${idx}번 노래 다운로드 시작`)
|
||||
sendLog(t('log.musicTrackStart', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||
let child: ChildProcess | null = null
|
||||
try {
|
||||
@@ -296,16 +306,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
}
|
||||
})
|
||||
if (child) state.activeChildren.delete(child)
|
||||
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
|
||||
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
} catch (err) {
|
||||
if (child) state.activeChildren.delete(child)
|
||||
if (state.cancelRequested) {
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
||||
return
|
||||
}
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
|
||||
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,19 +329,19 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
// 2-3. 사진 다운로드 + painting variant 정규화
|
||||
const paintingDir = path.join(tempRoot, 'painting')
|
||||
await fsp.mkdir(paintingDir, { recursive: true })
|
||||
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
|
||||
sendLog(t('log.imageStart', { total: imageTotal }))
|
||||
for (let i = 0; i < imageTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.images[i]
|
||||
const idx = i + 1
|
||||
sendLog(`${idx}번 사진 다운로드 중…`)
|
||||
sendLog(t('log.imageDownloading', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
||||
let buf: Buffer
|
||||
try {
|
||||
buf = await downloadImage(entry.url)
|
||||
} catch (err) {
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 사진 다운로드 실패: ${(err as Error).message}`)
|
||||
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
throwIfCancelled()
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
|
||||
@@ -340,9 +350,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
await normalizeToCover(buf, outPath)
|
||||
} catch (err) {
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 사진 정규화 실패: ${(err as Error).message}`)
|
||||
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
sendLog(`${idx}번 사진 완료: ${path.basename(outPath)}`)
|
||||
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
||||
}
|
||||
|
||||
@@ -354,18 +364,18 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
|
||||
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
|
||||
baseZipPath = path.join(tempRoot, 'base.zip')
|
||||
sendLog(`베이스 리소스팩 다운로드: ${cleaned}`)
|
||||
sendLog(` URL: ${baseUrl}`)
|
||||
sendProgress({ phase: 'package', message: '베이스 리소스팩 다운로드 중' })
|
||||
sendLog(t('log.baseDownload', { path: cleaned }))
|
||||
sendLog(t('log.baseUrl', { url: baseUrl }))
|
||||
sendProgress({ phase: 'package', message: t('progress.baseDownloading') })
|
||||
try {
|
||||
const buf = await fetchBuffer(baseUrl)
|
||||
await fsp.writeFile(baseZipPath, buf)
|
||||
sendLog(`베이스 리소스팩 받음 (${(buf.length / 1024).toFixed(1)} KB)`)
|
||||
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
|
||||
} catch (err) {
|
||||
throw new Error(`베이스 리소스팩 다운로드 실패: ${(err as Error).message}`)
|
||||
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
|
||||
}
|
||||
} else {
|
||||
sendLog('베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성')
|
||||
sendLog(t('log.baseAbsent'))
|
||||
}
|
||||
|
||||
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
|
||||
@@ -373,8 +383,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
||||
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
|
||||
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||
sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`)
|
||||
sendProgress({ phase: 'package', message: baseZipPath ? '베이스에 음악·사진 추가 중' : 'zip 빌드 중' })
|
||||
sendLog(t('log.buildingZip', { name: resourcepackName }))
|
||||
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
|
||||
await buildResourcepackZip({
|
||||
musicDir,
|
||||
paintingDir,
|
||||
@@ -387,8 +397,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
})
|
||||
|
||||
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||
sendLog(`설치 완료: ${resourcepackPath}`)
|
||||
sendProgress({ phase: 'package', message: '설치 완료', done: true })
|
||||
sendLog(t('log.installComplete', { path: resourcepackPath }))
|
||||
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
|
||||
return { resourcepackPath }
|
||||
} finally {
|
||||
// 임시 파일 정리
|
||||
@@ -398,7 +408,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
ipcMain.handle('rp:install:cancel', async () => {
|
||||
state.cancelRequested = true
|
||||
sendLog(`취소 요청됨. 실행 중 프로세스 ${state.activeChildren.size}개 중단…`)
|
||||
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
|
||||
for (const child of state.activeChildren) {
|
||||
if (!child.killed) child.kill()
|
||||
}
|
||||
@@ -406,7 +416,7 @@ ipcMain.handle('rp:install:cancel', async () => {
|
||||
|
||||
function throwIfCancelled(): void {
|
||||
if (state.cancelRequested) {
|
||||
throw new Error('사용자가 설치를 취소했습니다.')
|
||||
throw new Error(t('errors.cancelledByUser'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
export interface DownloadMusicOptions {
|
||||
ytdlpExe: string
|
||||
@@ -58,7 +61,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line) continue
|
||||
opts.log?.(`yt-dlp> ${line}`)
|
||||
opts.log?.(t('log.ytdlpLine', { line }))
|
||||
const m = line.match(/\[download\]\s+([\d.]+)%/)
|
||||
if (m) {
|
||||
const pct = Math.min(100, Math.max(0, parseFloat(m[1])))
|
||||
@@ -76,11 +79,16 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', async (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`yt-dlp 가 신호 ${signal} 로 종료됨`))
|
||||
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`))
|
||||
reject(new Error(
|
||||
t('errors.ytdlpExit', {
|
||||
code: code ?? '',
|
||||
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
|
||||
})
|
||||
))
|
||||
return
|
||||
}
|
||||
// .ogg 가 실제로 생성됐는지 확인
|
||||
@@ -88,7 +96,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
||||
await fs.access(outPath)
|
||||
resolve(outPath)
|
||||
} catch {
|
||||
reject(new Error(`예상 출력파일이 없음: ${outPath}`))
|
||||
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,9 @@ import path from 'node:path'
|
||||
import archiver from 'archiver'
|
||||
import extract from 'extract-zip'
|
||||
import { resolveResourcePackFormat } from './packFormat.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
const NAMESPACE = 'musicquiz'
|
||||
|
||||
@@ -45,7 +48,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
|
||||
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
|
||||
if (opts.baseZipPath) {
|
||||
opts.log?.(`베이스 리소스팩 압축 해제: ${path.basename(opts.baseZipPath)}`)
|
||||
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
|
||||
await extract(opts.baseZipPath, { dir: root })
|
||||
}
|
||||
|
||||
@@ -57,13 +60,13 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
|
||||
const resolved = resolveResourcePackFormat(opts.mcVersion)
|
||||
if (resolved.matched) {
|
||||
opts.log?.(`pack_format = ${resolved.format} (mcVersion ${resolved.matched})`)
|
||||
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
|
||||
} else {
|
||||
opts.log?.(`pack_format = ${resolved.format} (mcVersion "${opts.mcVersion}" 매칭 실패, 최신 폴백)`)
|
||||
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
|
||||
}
|
||||
const mcmeta = {
|
||||
pack: {
|
||||
description: `음악퀴즈 리소스팩 - ${opts.packName}`,
|
||||
description: t('pack.description', { name: opts.packName }),
|
||||
pack_format: resolved.format,
|
||||
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
|
||||
}
|
||||
@@ -82,7 +85,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
const parsed = JSON.parse(existing)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
soundsJson = parsed as Record<string, unknown>
|
||||
opts.log?.(`기존 sounds.json 병합 (${Object.keys(soundsJson).length}개 항목)`)
|
||||
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
|
||||
}
|
||||
} catch {
|
||||
// 없으면 새로 생성.
|
||||
|
||||
@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { RpFetchedPack } from './types.js'
|
||||
|
||||
const api = {
|
||||
/** i18n 사전을 렌더러에 전달. */
|
||||
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('rp:i18n:dict'),
|
||||
|
||||
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
|
||||
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
|
||||
ipcRenderer.invoke('rp:packs:load', manifestUrl),
|
||||
|
||||
@@ -4,6 +4,9 @@ import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
/**
|
||||
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
|
||||
@@ -27,7 +30,7 @@ export async function ensureYtDlpExe(
|
||||
): Promise<string> {
|
||||
const target = getYtDlpExePath()
|
||||
if (await canExecute(target)) {
|
||||
log?.(`yt-dlp.exe 이미 있음: ${target}`)
|
||||
log?.(t('log.ytdlpExists', { path: target }))
|
||||
return target
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
@@ -35,20 +38,21 @@ export async function ensureYtDlpExe(
|
||||
installPromise = (async () => {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
log?.(`yt-dlp.exe 다운로드 중: ${YT_DLP_DOWNLOAD_URL}`)
|
||||
log?.(t('log.ytdlpDownloading', { url: YT_DLP_DOWNLOAD_URL }))
|
||||
await downloadToFile(YT_DLP_DOWNLOAD_URL, target)
|
||||
const okVersion = await probeVersion(target)
|
||||
if (!okVersion) {
|
||||
throw new Error('yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
|
||||
throw new Error(t('errors.ytdlpVerifyFailed'))
|
||||
}
|
||||
log?.(`yt-dlp.exe 준비 완료: ${target}`)
|
||||
log?.(t('log.ytdlpReady', { path: target }))
|
||||
return target
|
||||
} catch (err) {
|
||||
// 부분 다운로드 흔적 정리
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
throw new Error(
|
||||
'yt-dlp.exe 자동 설치 실패: ' +
|
||||
(err instanceof Error ? err.message : String(err))
|
||||
t('errors.ytdlpInstallFailed', {
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
installPromise = null
|
||||
@@ -80,7 +84,7 @@ function probeVersion(bin: string): Promise<boolean> {
|
||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error('redirect 가 너무 많습니다.'))
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const lib = url.startsWith('https://') ? https : http
|
||||
|
||||
@@ -21,9 +21,14 @@ import type {
|
||||
import type { Manifest, PackDefinition } from '../shared/types.js'
|
||||
import { normalizePackDefinition } from '../shared/store.js'
|
||||
import { loadEnv, getManifestUrl } from '../shared/env.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
loadEnv()
|
||||
|
||||
const i18n = loadComponentI18n('installer')
|
||||
const t = i18n.t
|
||||
export const localeDict = i18n.dict
|
||||
|
||||
interface InstallerState {
|
||||
manifestUrl: string
|
||||
baseUrl: string
|
||||
@@ -100,7 +105,7 @@ function fetchBuffer(url: string): Promise<Buffer> {
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
|
||||
request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +129,7 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
||||
state.manifestUrl = manifestUrlInput
|
||||
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
||||
}
|
||||
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
|
||||
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
|
||||
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
||||
const results: FetchedPack[] = []
|
||||
for (const entry of manifest.packs ?? []) {
|
||||
@@ -135,21 +140,21 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
||||
const pack = normalizePackDefinition(raw)
|
||||
results.push({ key: entry.file, name: entry.name || pack.name, pack })
|
||||
} catch (error) {
|
||||
sendLog(`pack 로드 실패 (${entry.file}): ${(error as Error).message}`)
|
||||
sendLog(t('log.packLoadFail', { file: entry.file, message: (error as Error).message }))
|
||||
}
|
||||
}
|
||||
state.packs.clear()
|
||||
for (const item of results) state.packs.set(item.key, item)
|
||||
sendLog(`로드된 음악퀴즈: ${results.length}개`)
|
||||
sendLog(t('log.packsLoaded', { count: results.length }))
|
||||
return results
|
||||
})
|
||||
|
||||
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
||||
if (!state.packs.has(packKey)) {
|
||||
throw new Error('선택한 음악퀴즈를 찾을 수 없습니다.')
|
||||
throw new Error(t('errors.packNotFound'))
|
||||
}
|
||||
state.selectedKey = packKey
|
||||
sendLog(`선택: ${packKey}`)
|
||||
sendLog(t('log.selectedPack', { key: packKey }))
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:pickFolder', async (): Promise<string | null> => {
|
||||
@@ -163,10 +168,10 @@ ipcMain.handle('dialog:pickFolder', async (): Promise<string | null> => {
|
||||
|
||||
ipcMain.handle('install:validatePath', async (_event, target: string) => {
|
||||
if (!target || target.trim().length === 0) {
|
||||
return { ok: false, message: '서버 설치 경로를 입력해 주세요.' }
|
||||
return { ok: false, message: t('errors.installPathRequired') }
|
||||
}
|
||||
if (containsHangul(target)) {
|
||||
return { ok: false, message: '경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.' }
|
||||
return { ok: false, message: t('errors.installPathHangul') }
|
||||
}
|
||||
const absolute = path.resolve(target)
|
||||
state.installPath = absolute
|
||||
@@ -225,7 +230,7 @@ function downloadStream(
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error('취소되었습니다.'))
|
||||
reject(new Error(t('errors.canceled')))
|
||||
return
|
||||
}
|
||||
const u = new URL(url)
|
||||
@@ -233,7 +238,7 @@ function downloadStream(
|
||||
const fileStream = fs.createWriteStream(target)
|
||||
let settled = false
|
||||
const onAbort = () => {
|
||||
try { req.destroy(new Error('취소되었습니다.')) } catch { /* noop */ }
|
||||
try { req.destroy(new Error(t('errors.canceled'))) } catch { /* noop */ }
|
||||
try { fileStream.close() } catch { /* noop */ }
|
||||
}
|
||||
signal.addEventListener('abort', onAbort)
|
||||
@@ -279,13 +284,13 @@ function downloadStream(
|
||||
fileStream.close(() => {})
|
||||
if (!settled) { settled = true; reject(err) }
|
||||
})
|
||||
req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
|
||||
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; message?: string }> => {
|
||||
if (jdkInstall.inProgress) {
|
||||
return { ok: false, message: '이미 JDK 설치가 진행 중입니다.' }
|
||||
return { ok: false, message: t('errors.jdkBusy') }
|
||||
}
|
||||
jdkInstall.inProgress = true
|
||||
const controller = new AbortController()
|
||||
@@ -298,20 +303,24 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me
|
||||
try {
|
||||
// Adoptium API v3: latest GA JDK 21 Windows x64. 본문은 307 로 GitHub 릴리즈로 리다이렉트.
|
||||
const url = 'https://api.adoptium.net/v3/binary/latest/21/ga/windows/x64/jdk/hotspot/normal/eclipse?project=jdk'
|
||||
sendLog('JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...')
|
||||
sendLog(t('log.jdkInstallStart'))
|
||||
let lastPctReported = -1
|
||||
await downloadStream(url, tempZip, controller.signal, (loaded, total) => {
|
||||
if (total > 0) {
|
||||
const pct = Math.floor((loaded / total) * 100)
|
||||
if (pct >= lastPctReported + 5) {
|
||||
lastPctReported = pct
|
||||
sendLog(`JDK 다운로드: ${pct}% (${Math.floor(loaded / 1024 / 1024)}MB / ${Math.floor(total / 1024 / 1024)}MB)`)
|
||||
sendLog(t('log.jdkDownloadProgress', {
|
||||
percent: pct,
|
||||
loaded: Math.floor(loaded / 1024 / 1024),
|
||||
total: Math.floor(total / 1024 / 1024)
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
if (controller.signal.aborted) throw new Error('취소되었습니다.')
|
||||
if (controller.signal.aborted) throw new Error(t('errors.canceled'))
|
||||
|
||||
sendLog('JDK 압축 해제 중...')
|
||||
sendLog(t('log.jdkExtracting'))
|
||||
await fsp.rm(destDir, { recursive: true, force: true })
|
||||
await fsp.mkdir(destDir, { recursive: true })
|
||||
await extractZip(tempZip, { dir: destDir })
|
||||
@@ -323,19 +332,19 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me
|
||||
if (innerJdk) javaRoot = path.join(destDir, innerJdk.name)
|
||||
const javaExe = path.join(javaRoot, 'bin', process.platform === 'win32' ? 'java.exe' : 'java')
|
||||
if (!fs.existsSync(javaExe)) {
|
||||
throw new Error(`설치 후 java 실행 파일을 찾지 못했습니다: ${javaExe}`)
|
||||
throw new Error(t('errors.javaExeMissing', { path: javaExe }))
|
||||
}
|
||||
|
||||
sendLog(`JDK 자동 설치 완료: ${javaRoot}`)
|
||||
sendLog(t('log.jdkDoneRoot', { path: javaRoot }))
|
||||
return { ok: true, path: javaRoot }
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message || String(err)
|
||||
if (controller.signal.aborted || /취소/.test(msg)) {
|
||||
sendLog('JDK 설치가 취소되었습니다.')
|
||||
sendLog(t('log.jdkCanceled'))
|
||||
try { await fsp.rm(destDir, { recursive: true, force: true }) } catch { /* noop */ }
|
||||
return { ok: false, message: '취소됨' }
|
||||
return { ok: false, message: t('errors.canceledShort') }
|
||||
}
|
||||
sendLog(`JDK 설치 실패: ${msg}`)
|
||||
sendLog(t('log.jdkInstallFailedLog', { message: msg }))
|
||||
return { ok: false, message: msg }
|
||||
} finally {
|
||||
try { await fsp.rm(tempZip, { force: true }) } catch { /* noop */ }
|
||||
@@ -348,7 +357,7 @@ ipcMain.handle('jdk:install', async (): Promise<{ ok: boolean; path?: string; me
|
||||
ipcMain.handle('jdk:cancelInstall', async (): Promise<{ ok: boolean }> => {
|
||||
if (jdkInstall.controller) {
|
||||
jdkInstall.controller.abort()
|
||||
sendLog('JDK 설치 취소 요청을 보냈습니다.')
|
||||
sendLog(t('log.jdkCancelRequested'))
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -368,9 +377,9 @@ async function downloadAndExtractZip(url: string, label: string, extractDir: str
|
||||
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mq-zip-'))
|
||||
const tempZip = path.join(tempDir, 'package.zip')
|
||||
try {
|
||||
sendLog(`${label} 다운로드: ${url}`)
|
||||
sendLog(t('log.labelDownload', { label, url }))
|
||||
await downloadFile(url, tempZip)
|
||||
sendLog(`${label} 압축 해제: ${extractDir}`)
|
||||
sendLog(t('log.labelExtract', { label, dir: extractDir }))
|
||||
await extractZip(tempZip, { dir: extractDir })
|
||||
} finally {
|
||||
await fsp.rm(tempDir, { recursive: true, force: true })
|
||||
@@ -379,36 +388,36 @@ async function downloadAndExtractZip(url: string, label: string, extractDir: str
|
||||
|
||||
async function downloadServerZip(pack: PackDefinition, targetDir: string): Promise<void> {
|
||||
if (!pack.serverPath) {
|
||||
sendLog('서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.')
|
||||
sendLog(t('log.skipServerZip'))
|
||||
return
|
||||
}
|
||||
const url = resolveManifestRelative(pack.serverPath, 'servers')
|
||||
await downloadAndExtractZip(url, '서버 파일', targetDir)
|
||||
await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir)
|
||||
}
|
||||
|
||||
async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.mapPath) {
|
||||
sendLog('맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.')
|
||||
sendLog(t('log.skipMapZip'))
|
||||
return
|
||||
}
|
||||
const url = resolveManifestRelative(pack.mapPath, 'maps')
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
await downloadAndExtractZip(url, '맵', savesDir)
|
||||
await downloadAndExtractZip(url, t('log.labelMap'), savesDir)
|
||||
}
|
||||
|
||||
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.modsFolder) {
|
||||
sendLog('modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.')
|
||||
sendLog(t('log.skipModsFolder'))
|
||||
return
|
||||
}
|
||||
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
|
||||
sendLog(`모드 목록 조회: ${indexUrl}`)
|
||||
sendLog(t('log.modsIndexFetch', { url: indexUrl }))
|
||||
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
|
||||
const files = Array.isArray(listing.files)
|
||||
? listing.files.filter((name): name is string => typeof name === 'string' && /\.jar$/i.test(name))
|
||||
: []
|
||||
if (files.length === 0) {
|
||||
sendLog(`/file/mods/${pack.modsFolder}/ 안에 .jar 파일이 없습니다.`)
|
||||
sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder }))
|
||||
return
|
||||
}
|
||||
const modsDir = path.join(customRoot, 'mods')
|
||||
@@ -416,33 +425,33 @@ async function downloadModsFolder(pack: PackDefinition, customRoot: string): Pro
|
||||
for (const fileName of files) {
|
||||
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
|
||||
const target = path.join(modsDir, fileName)
|
||||
sendLog(`모드 다운로드: ${fileName}`)
|
||||
sendLog(t('log.modDownload', { file: fileName }))
|
||||
await downloadFile(url, target)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.resourcepackPath) {
|
||||
sendLog('resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.')
|
||||
sendLog(t('log.skipResourcepack'))
|
||||
return
|
||||
}
|
||||
const url = `${state.baseUrl}/file/resourcepacks/${pack.resourcepackPath.replace(/^\/+/, '')}`
|
||||
const target = path.join(customRoot, 'resourcepacks', pack.resourcepackPath.replace(/^\/+/, ''))
|
||||
await fsp.mkdir(path.dirname(target), { recursive: true })
|
||||
sendLog(`리소스팩 다운로드: ${url}`)
|
||||
sendLog(t('log.resourcepackDownload', { url }))
|
||||
await downloadFile(url, target)
|
||||
}
|
||||
|
||||
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
|
||||
const pack = state.packs.get(payload.packKey)
|
||||
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||
if (!pack) throw new Error(t('errors.packNotFound2'))
|
||||
if (containsHangul(payload.installPath)) {
|
||||
throw new Error('경로에 한글이 포함되면 안 됩니다.')
|
||||
throw new Error(t('errors.installPathHangulShort'))
|
||||
}
|
||||
const installPath = path.resolve(payload.installPath)
|
||||
state.installPath = installPath
|
||||
await fsp.mkdir(installPath, { recursive: true })
|
||||
sendLog(`서버 설치 경로: ${installPath}`)
|
||||
sendLog(t('log.serverInstallPath', { path: installPath }))
|
||||
|
||||
await downloadServerZip(pack.pack, installPath)
|
||||
// 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다.
|
||||
@@ -469,20 +478,20 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) =
|
||||
async function injectUpnpToRunBat(installPath: string): Promise<void> {
|
||||
const runBat = path.join(installPath, 'run.bat')
|
||||
if (!fs.existsSync(runBat)) {
|
||||
sendLog('run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.')
|
||||
sendLog(t('log.runBatMissing'))
|
||||
return
|
||||
}
|
||||
const MARKER = 'REM === UPNP MANAGED BY MUSICQUIZ INSTALLER ==='
|
||||
const original = await fsp.readFile(runBat, 'utf8')
|
||||
if (original.includes(MARKER)) {
|
||||
sendLog('run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.')
|
||||
sendLog(t('log.runBatAlreadyInjected'))
|
||||
return
|
||||
}
|
||||
|
||||
const lines = original.split(/\r?\n/)
|
||||
const javaIdx = lines.findIndex((line) => /^\s*java(\.exe)?[\s"]/i.test(line))
|
||||
if (javaIdx === -1) {
|
||||
sendLog('run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.')
|
||||
sendLog(t('log.runBatNoJava'))
|
||||
return
|
||||
}
|
||||
let pauseIdx = -1
|
||||
@@ -521,7 +530,7 @@ async function injectUpnpToRunBat(installPath: string): Promise<void> {
|
||||
// bat 파일은 CRLF 가 안전.
|
||||
const output = merged.join('\r\n')
|
||||
await fsp.writeFile(runBat, output, 'utf8')
|
||||
sendLog('run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.')
|
||||
sendLog(t('log.runBatInjected'))
|
||||
}
|
||||
|
||||
ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => {
|
||||
@@ -542,7 +551,7 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm
|
||||
const buffer = await fetchBuffer(url)
|
||||
return { url, html: buffer.toString('utf8') }
|
||||
} catch (error) {
|
||||
sendLog(`Minecraft EULA 페이지 조회 실패: ${(error as Error).message}`)
|
||||
sendLog(t('log.mojangEulaFetchFail', { message: (error as Error).message }))
|
||||
return { url, html: '' }
|
||||
}
|
||||
})
|
||||
@@ -550,12 +559,12 @@ ipcMain.handle('server:fetchMinecraftEula', async (): Promise<{ url: string; htm
|
||||
ipcMain.handle('server:acceptEula', async (_event, installPath: string) => {
|
||||
const target = path.join(installPath, 'eula.txt')
|
||||
await fsp.writeFile(target, `# Generated by music quiz installer\neula=true\n`, 'utf8')
|
||||
sendLog('EULA 동의 저장 완료.')
|
||||
sendLog(t('log.eulaAccepted'))
|
||||
})
|
||||
|
||||
ipcMain.handle('server:checkRam', async (_event, packKey: string): Promise<RamCheckResult> => {
|
||||
const pack = state.packs.get(packKey)
|
||||
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||
if (!pack) throw new Error(t('errors.packNotFound2'))
|
||||
const systemRamMb = Math.floor(os.totalmem() / (1024 * 1024))
|
||||
if (systemRamMb >= pack.pack.serverMaxRam) {
|
||||
return { systemRamMb, decision: 'maxOk', appliedRamMb: pack.pack.serverMaxRam }
|
||||
@@ -578,14 +587,14 @@ ipcMain.handle('server:configEditor', async (_event, installPath: string) => {
|
||||
} catch (error) {
|
||||
res.statusCode = 500
|
||||
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
||||
res.end(`서버 오류: ${(error as Error).message}`)
|
||||
res.end(t('configEditor.serverError', { message: (error as Error).message }))
|
||||
}
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(port, '127.0.0.1', resolve))
|
||||
state.configEditorServer = server
|
||||
state.configEditorPort = port
|
||||
const url = `http://127.0.0.1:${port}/`
|
||||
sendLog(`서버 설정 편집기 실행: ${url}`)
|
||||
sendLog(t('log.configEditorOpen', { url }))
|
||||
await shell.openExternal(url)
|
||||
return { url }
|
||||
})
|
||||
@@ -599,7 +608,7 @@ async function pickPort(): Promise<number> {
|
||||
const address = probe.address()
|
||||
probe.close(() => {
|
||||
if (address && typeof address === 'object') resolve(address.port)
|
||||
else reject(new Error('포트를 할당할 수 없습니다.'))
|
||||
else reject(new Error(t('errors.portAllocFail')))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -619,7 +628,7 @@ async function handleConfigEditorRequest(installPath: string, req: http.Incoming
|
||||
const target = url.searchParams.get('name')
|
||||
if (!target || !SERVER_CONFIG_FILES.includes(target)) {
|
||||
res.statusCode = 400
|
||||
res.end('알 수 없는 파일')
|
||||
res.end(t('configEditor.unknownFile'))
|
||||
return
|
||||
}
|
||||
const filePath = path.join(installPath, target)
|
||||
@@ -640,7 +649,7 @@ async function handleConfigEditorRequest(installPath: string, req: http.Incoming
|
||||
const content = params.get('content') ?? ''
|
||||
if (!SERVER_CONFIG_FILES.includes(target)) {
|
||||
res.statusCode = 400
|
||||
res.end('알 수 없는 파일')
|
||||
res.end(t('configEditor.unknownFile'))
|
||||
return
|
||||
}
|
||||
const filePath = path.join(installPath, target)
|
||||
@@ -668,15 +677,17 @@ function renderConfigEditorPage(fileSet: string[]): string {
|
||||
const optionMarkup = safeList
|
||||
.map((file, index) => `<option value="${file}" ${index === 0 ? 'selected' : ''}>${file}</option>`)
|
||||
.join('')
|
||||
const savedText = JSON.stringify(t('configEditor.saved'))
|
||||
const saveFailedText = JSON.stringify(t('configEditor.saveFailed'))
|
||||
return `<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"/><title>서버 설정 편집기</title>
|
||||
<html lang="ko"><head><meta charset="utf-8"/><title>${t('configEditor.pageTitle')}</title>
|
||||
<style>body{font-family:sans-serif;background:#0d1117;color:#e6edf3;padding:24px;}select,textarea,button{font:inherit;}textarea{width:100%;height:60vh;background:#161b22;color:#e6edf3;border:1px solid #30363d;padding:12px;border-radius:8px;}button{background:#2f81f7;color:#fff;border:none;padding:10px 16px;border-radius:8px;cursor:pointer;margin-top:12px;}small{color:#8b949e;}</style>
|
||||
</head><body>
|
||||
<h1>서버 설정 편집기</h1>
|
||||
<p><small>아래 파일을 직접 편집한 후 "적용" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.</small></p>
|
||||
<label>대상 파일 <select id="file">${optionMarkup}</select></label>
|
||||
<h1>${t('configEditor.heading')}</h1>
|
||||
<p><small>${t('configEditor.intro')}</small></p>
|
||||
<label>${t('configEditor.targetLabel')} <select id="file">${optionMarkup}</select></label>
|
||||
<textarea id="content"></textarea>
|
||||
<button id="save">적용</button>
|
||||
<button id="save">${t('configEditor.applyButton')}</button>
|
||||
<p id="status"><small></small></p>
|
||||
<script>
|
||||
const file=document.getElementById('file');
|
||||
@@ -684,7 +695,7 @@ const content=document.getElementById('content');
|
||||
const status=document.querySelector('#status small');
|
||||
async function load(){const r=await fetch('/file?name='+encodeURIComponent(file.value));content.value=await r.text();}
|
||||
file.addEventListener('change',load);
|
||||
document.getElementById('save').addEventListener('click',async()=>{const body=new URLSearchParams();body.set('name',file.value);body.set('content',content.value);const r=await fetch('/save',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body.toString()});status.textContent=r.ok?'저장 완료':'저장 실패';});
|
||||
document.getElementById('save').addEventListener('click',async()=>{const body=new URLSearchParams();body.set('name',file.value);body.set('content',content.value);const r=await fetch('/save',{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:body.toString()});status.textContent=r.ok?${savedText}:${saveFailedText};});
|
||||
load();
|
||||
</script></body></html>`
|
||||
}
|
||||
@@ -700,72 +711,75 @@ function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
|
||||
ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortForwardResult> => {
|
||||
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
|
||||
sendLog(`포트포워딩 점검 시작: 포트 ${targetPort}`)
|
||||
sendLog(t('log.portCheckStart', { port: targetPort }))
|
||||
|
||||
// 1차 점검 전에 우리가 이전 실행에서 만든 UPnP 매핑이 남아 있으면 먼저 제거한다.
|
||||
// 이렇게 해야 "사용자 라우터 규칙이 활성화돼서 외부 접근이 가능한 상태" 와 "UPnP 매핑 덕분에 접근 가능한 상태" 가 구별된다.
|
||||
// 사용자 규칙이 비활성/없으면 1차 점검은 false 가 되어 UPnP 시도 단계로 자연스럽게 넘어간다.
|
||||
sendLog('이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...')
|
||||
sendLog(t('log.upnpCleanup'))
|
||||
await removeUpnpMapping(targetPort)
|
||||
|
||||
// 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백.
|
||||
let externalIp = await detectExternalIpHttp()
|
||||
if (externalIp) {
|
||||
sendLog(`외부 IP 확인(HTTP): ${externalIp}`)
|
||||
sendLog(t('log.externalIpHttp', { ip: externalIp }))
|
||||
} else {
|
||||
sendLog('외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...')
|
||||
sendLog(t('log.externalIpHttpFail'))
|
||||
externalIp = await detectExternalIpUpnp()
|
||||
if (externalIp) sendLog(`외부 IP 확인(UPnP): ${externalIp}`)
|
||||
else sendLog('UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.')
|
||||
if (externalIp) sendLog(t('log.externalIpUpnp', { ip: externalIp }))
|
||||
else sendLog(t('log.externalIpUpnpFail'))
|
||||
}
|
||||
|
||||
// 1차 점검: 외부에서 이미 접근 가능한지 (서버가 떠 있거나, 우리가 임시 리스너 띄워서 검증).
|
||||
sendLog('외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...')
|
||||
sendLog(t('log.probeStart'))
|
||||
let probe = await probePortFromOutside(targetPort, externalIp)
|
||||
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
||||
sendLog(`1차 점검 결과: ${probe.reachable === true ? '성공' : probe.reachable === false ? '실패' : '확인 불가'} (${probe.detail})`)
|
||||
const verdict = probe.reachable === true
|
||||
? t('log.probeVerdictSuccess')
|
||||
: probe.reachable === false ? t('log.probeVerdictFail') : t('log.probeVerdictUnknown')
|
||||
sendLog(t('log.probeResult', { verdict, detail: probe.detail }))
|
||||
|
||||
if (probe.reachable === true) {
|
||||
sendLog(`외부에서 ${externalIp || '(IP 미상)'}:${targetPort} 접근 확인됨. 사용자 규칙으로 포워딩 됨.`)
|
||||
sendLog(t('log.probePreForwarded', { addr: externalIp || t('log.ipUnknown'), port: targetPort }))
|
||||
return { status: 'preForwarded', externalIp, port: targetPort }
|
||||
}
|
||||
|
||||
// UPnP 시도.
|
||||
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`)
|
||||
sendLog(t('log.upnpTryOpen', { port: targetPort }))
|
||||
try {
|
||||
await openPortViaUpnp(targetPort)
|
||||
sendLog('UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.')
|
||||
sendLog(t('log.upnpReqOk'))
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message || String(error)
|
||||
sendLog(`UPnP 시도 실패: ${msg}`)
|
||||
sendLog(t('log.upnpTryFail', { message: msg }))
|
||||
return {
|
||||
status: 'upnpFailed',
|
||||
externalIp,
|
||||
port: targetPort,
|
||||
message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.`
|
||||
message: t('log.upnpFailDetail', { message: msg })
|
||||
}
|
||||
}
|
||||
|
||||
// NAT 반영 지연을 고려해 최대 3회 재점검.
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
await sleep(1500)
|
||||
sendLog(`UPnP 적용 후 재점검 ${attempt}/3...`)
|
||||
sendLog(t('log.upnpRecheck', { attempt }))
|
||||
probe = await probePortFromOutside(targetPort, externalIp)
|
||||
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
||||
if (probe.reachable === true) {
|
||||
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).`)
|
||||
sendLog(t('log.upnpDone', { port: targetPort }))
|
||||
await removeUpnpMapping(targetPort)
|
||||
return { status: 'upnpOk', externalIp, port: targetPort }
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트 목적으로 만든 매핑 정리. 실제 개방은 run.bat 이 담당.
|
||||
sendLog('테스트용 UPnP 매핑을 정리합니다.')
|
||||
sendLog(t('log.upnpCleanupTest'))
|
||||
await removeUpnpMapping(targetPort)
|
||||
|
||||
const reason = probe.reachable === false
|
||||
? 'UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.'
|
||||
: `외부 포트체크 결과를 받지 못했습니다(${probe.detail}). UPnP 매핑은 등록됐을 수 있습니다.`
|
||||
? t('log.upnpFailReason1')
|
||||
: t('log.upnpFailReason2', { detail: probe.detail })
|
||||
sendLog(reason)
|
||||
return { status: 'upnpFailed', externalIp, port: targetPort, message: reason }
|
||||
})
|
||||
@@ -792,12 +806,12 @@ function detectExternalIpUpnp(): Promise<string> {
|
||||
try {
|
||||
client = natUpnp.createClient()
|
||||
} catch (err) {
|
||||
sendLog(`UPnP 클라이언트 생성 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.upnpClientFail', { message: (err as Error).message }))
|
||||
finish('')
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
sendLog('UPnP externalIp 조회 타임아웃(8s).')
|
||||
sendLog(t('log.upnpExternalTimeout'))
|
||||
try { client && client.close() } catch {}
|
||||
finish('')
|
||||
}, 8000)
|
||||
@@ -805,7 +819,7 @@ function detectExternalIpUpnp(): Promise<string> {
|
||||
clearTimeout(timer)
|
||||
try { client && client.close() } catch {}
|
||||
if (err || !ip) {
|
||||
if (err) sendLog(`UPnP externalIp 오류: ${err.message}`)
|
||||
if (err) sendLog(t('log.upnpExternalErr', { message: err.message }))
|
||||
finish('')
|
||||
} else {
|
||||
finish(ip)
|
||||
@@ -846,9 +860,9 @@ async function probePortFromOutside(
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'EADDRINUSE') {
|
||||
sendLog(`포트 ${port}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.`)
|
||||
sendLog(t('log.portInUse', { port }))
|
||||
} else {
|
||||
sendLog(`임시 리스너 바인딩 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.listenerBindFail', { message: (err as Error).message }))
|
||||
}
|
||||
try { server && server.close() } catch {}
|
||||
server = null
|
||||
@@ -886,20 +900,20 @@ async function probePortFromOutside(
|
||||
let reachable: boolean | null = null
|
||||
const details: string[] = []
|
||||
if (listenerBound) {
|
||||
details.push(`임시 리스너 도달=${gotInboundConnection ? 'yes' : 'no'}`)
|
||||
details.push(t('log.detailListenerHit', { value: gotInboundConnection ? 'yes' : 'no' }))
|
||||
if (gotInboundConnection) reachable = true
|
||||
} else {
|
||||
details.push('임시 리스너=skip(포트 사용중)')
|
||||
details.push(t('log.detailListenerSkip'))
|
||||
}
|
||||
|
||||
let detectedIp = ''
|
||||
if ('ok' in externalResult && externalResult.ok) {
|
||||
details.push(`ifconfig.co reachable=${externalResult.reachable} ip=${externalResult.ip || '?'}`)
|
||||
details.push(t('log.detailIfconfig', { reachable: String(externalResult.reachable), ip: externalResult.ip || '?' }))
|
||||
detectedIp = externalResult.ip || ''
|
||||
if (externalResult.reachable === true) reachable = true
|
||||
else if (reachable !== true && externalResult.reachable === false) reachable = false
|
||||
} else if ('ok' in externalResult && !externalResult.ok) {
|
||||
details.push(`ifconfig.co 실패=${(externalResult as { error: string }).error}`)
|
||||
details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error }))
|
||||
}
|
||||
|
||||
// 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false.
|
||||
@@ -907,7 +921,7 @@ async function probePortFromOutside(
|
||||
|
||||
return {
|
||||
reachable,
|
||||
detail: details.join(', ') || '결과 없음',
|
||||
detail: details.join(', ') || t('log.detailNone'),
|
||||
detectedIp: detectedIp || hintIp || ''
|
||||
}
|
||||
}
|
||||
@@ -934,12 +948,12 @@ function fetchIfconfigCoPort(port: number): Promise<{ ok: true; reachable: boole
|
||||
const ip = typeof json.ip === 'string' ? json.ip : ''
|
||||
resolve({ ok: true, reachable, ip })
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: `응답 파싱 실패: ${text.slice(0, 80)}` })
|
||||
resolve({ ok: false, error: t('errors.parseResponseFailed', { snippet: text.slice(0, 80) }) })
|
||||
}
|
||||
})
|
||||
})
|
||||
req.on('error', (err) => resolve({ ok: false, error: err.message }))
|
||||
req.on('timeout', () => req.destroy(new Error('요청 시간 초과(15s)')))
|
||||
req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout15s'))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -951,20 +965,20 @@ function removeUpnpMapping(port: number): Promise<void> {
|
||||
try {
|
||||
client = natUpnp.createClient()
|
||||
} catch (err) {
|
||||
sendLog(`UPnP 클라이언트 생성 실패(매핑 제거 단계): ${(err as Error).message}`)
|
||||
sendLog(t('log.upnpClientFailRemove', { message: (err as Error).message }))
|
||||
done()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
try { client && client.close() } catch {}
|
||||
sendLog(`UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.`)
|
||||
sendLog(t('log.upnpRemoveTimeout'))
|
||||
done()
|
||||
}, 8000)
|
||||
client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => {
|
||||
clearTimeout(timer)
|
||||
try { client && client.close() } catch {}
|
||||
if (err) sendLog(`UPnP 매핑 제거 시도 결과: ${err.message} (없으면 정상)`)
|
||||
else sendLog(`UPnP 매핑 제거 완료(포트 ${port}).`)
|
||||
if (err) sendLog(t('log.upnpRemoveAttempt', { message: err.message }))
|
||||
else sendLog(t('log.upnpRemoveDone', { port }))
|
||||
done()
|
||||
})
|
||||
})
|
||||
@@ -988,7 +1002,7 @@ function openPortViaUpnp(port: number): Promise<void> {
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
try { client && client.close() } catch {}
|
||||
done(new Error('UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.'))
|
||||
done(new Error(t('errors.upnpTimeout')))
|
||||
}, 15000)
|
||||
client.portMapping(
|
||||
{ public: port, private: port, ttl: 0, description: 'MusicQuiz Server', protocol: 'tcp' },
|
||||
@@ -1007,7 +1021,7 @@ function sleep(ms: number): Promise<void> {
|
||||
|
||||
ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) => {
|
||||
const pack = state.packs.get(payload.packKey)
|
||||
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||
if (!pack) throw new Error(t('errors.packNotFound2'))
|
||||
const customRoot = path.join(getAppDataDir(), '.mc_custom')
|
||||
await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true })
|
||||
await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
|
||||
@@ -1023,11 +1037,11 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
|
||||
const cacheDir = path.join(customRoot, 'platform-cache')
|
||||
await fsp.mkdir(cacheDir, { recursive: true })
|
||||
const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
|
||||
sendLog(`플랫폼(${pack.pack.platform.type}) 다운로드: ${platformUrl}`)
|
||||
sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl }))
|
||||
await downloadFile(platformUrl, installerPath)
|
||||
sendLog(`플랫폼 설치파일 저장: ${installerPath} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)`)
|
||||
sendLog(t('log.platformSaved', { path: installerPath }))
|
||||
} else if (!payload.installPlatform) {
|
||||
sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
|
||||
sendLog(t('log.platformSkipped'))
|
||||
}
|
||||
|
||||
await downloadModsFolder(pack.pack, customRoot)
|
||||
@@ -1051,17 +1065,17 @@ interface FabricInstallerMeta {
|
||||
async function installFabricLoader(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
const loaderVersion = pack.platform.loaderVersion
|
||||
if (!loaderVersion) {
|
||||
throw new Error('Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.')
|
||||
throw new Error(t('errors.fabricLoaderRequired'))
|
||||
}
|
||||
|
||||
// 1) 최신 fabric-installer 메타데이터 조회.
|
||||
sendLog('Fabric installer 최신 버전 조회 중...')
|
||||
sendLog(t('log.fabricFetchInstallerList'))
|
||||
const installerList = await fetchJson<FabricInstallerMeta[]>('https://meta.fabricmc.net/v2/versions/installer')
|
||||
if (!installerList || installerList.length === 0) {
|
||||
throw new Error('Fabric installer 목록을 받지 못했습니다.')
|
||||
throw new Error(t('errors.fabricInstallerListEmpty'))
|
||||
}
|
||||
const latest = installerList.find((item) => item.stable) || installerList[0]
|
||||
sendLog(`Fabric installer ${latest.version} 다운로드: ${latest.url}`)
|
||||
sendLog(t('log.fabricInstallerDownload', { version: latest.version, url: latest.url }))
|
||||
|
||||
// 2) installer jar 캐시.
|
||||
const cacheDir = path.join(customRoot, 'platform-cache')
|
||||
@@ -1071,7 +1085,7 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
|
||||
|
||||
// 3) Java 실행파일 확보.
|
||||
const javaCmd = await findJavaExecutable()
|
||||
sendLog(`Java 사용: ${javaCmd}`)
|
||||
sendLog(t('log.javaUsed', { path: javaCmd }))
|
||||
|
||||
// 4) fabric-installer CLI 자동 실행.
|
||||
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
|
||||
@@ -1083,9 +1097,9 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
|
||||
'-dir', customRoot,
|
||||
'-noprofile'
|
||||
]
|
||||
sendLog(`Fabric 자동 설치 시작: ${pack.mcVersion} / loader ${loaderVersion} → ${customRoot}`)
|
||||
sendLog(t('log.fabricInstallStart', { mc: pack.mcVersion, loader: loaderVersion, dir: customRoot }))
|
||||
await runJavaProcess(javaCmd, args)
|
||||
sendLog('Fabric 자동 설치 완료.')
|
||||
sendLog(t('log.fabricInstallDone'))
|
||||
}
|
||||
|
||||
async function findJavaExecutable(): Promise<string> {
|
||||
@@ -1157,13 +1171,13 @@ function runJavaProcess(cmd: string, args: string[]): Promise<void> {
|
||||
if (stderrTail.length > 4000) stderrTail = stderrTail.slice(-4000)
|
||||
emitLines(chunk, '[fabric-err]')
|
||||
})
|
||||
child.on('error', (err) => reject(new Error(`Java 실행 실패: ${err.message}`)))
|
||||
child.on('error', (err) => reject(new Error(t('errors.javaSpawnFailed', { message: err.message }))))
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
const detail = stderrTail.trim().split(/\r?\n/).slice(-3).join(' | ')
|
||||
reject(new Error(`fabric-installer 종료 코드 ${code}${detail ? ' — ' + detail : ''}`))
|
||||
reject(new Error(t('errors.fabricInstallerExit', { code: code ?? '', detail: detail ? ' — ' + detail : '' })))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1291,7 +1305,7 @@ function resolveLastVersionId(pack: PackDefinition): string {
|
||||
async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Promise<void> {
|
||||
const launcherPath = path.join(getAppDataDir(), '.minecraft', 'launcher_profiles.json')
|
||||
if (!fs.existsSync(launcherPath)) {
|
||||
sendLog(`launcher_profiles.json을 찾을 수 없습니다: ${launcherPath}`)
|
||||
sendLog(t('log.launcherProfilesMissing', { path: launcherPath }))
|
||||
return
|
||||
}
|
||||
const raw = await fsp.readFile(launcherPath, 'utf8')
|
||||
@@ -1303,14 +1317,14 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
const ramMerged = mergeRamArgs(existingJavaArgs, pack.serverMaxRam)
|
||||
const javaArgs = mergeJvmTuningFlags(ramMerged, DEFAULT_JVM_TUNING_FLAGS)
|
||||
if (existingJavaArgs !== javaArgs) {
|
||||
sendLog(`JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): "${existingJavaArgs}" → "${javaArgs}"`)
|
||||
sendLog(t('log.javaArgsUpdated', { before: existingJavaArgs, after: javaArgs }))
|
||||
}
|
||||
const lastVersionId = resolveLastVersionId(pack)
|
||||
sendLog(`launcher_profiles 의 lastVersionId = ${lastVersionId}`)
|
||||
sendLog(t('log.lastVersionId', { id: lastVersionId }))
|
||||
// 해당 version 폴더 존재 확인. 없으면 런처가 "Unable to prepare assets for download" 로 실패한다.
|
||||
const versionDir = path.join(getAppDataDir(), '.minecraft', 'versions', lastVersionId)
|
||||
if (!fs.existsSync(versionDir)) {
|
||||
sendLog(`경고: .minecraft/versions/${lastVersionId} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.`)
|
||||
sendLog(t('log.versionMissingWarn', { id: lastVersionId }))
|
||||
}
|
||||
json.profiles[profileKey] = {
|
||||
...existingProfile,
|
||||
@@ -1321,7 +1335,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
javaArgs
|
||||
}
|
||||
await fsp.writeFile(launcherPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8')
|
||||
sendLog(`launcher_profiles.json 갱신: 프로필 "${profileKey}", gameDir=${gameDir}`)
|
||||
sendLog(t('log.launcherProfilesUpdated', { profile: profileKey, dir: gameDir }))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1333,7 +1347,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
const mcRoot = path.join(getAppDataDir(), '.minecraft')
|
||||
if (!fs.existsSync(mcRoot)) {
|
||||
sendLog('.minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.')
|
||||
sendLog(t('log.minecraftRootMissing'))
|
||||
return
|
||||
}
|
||||
let copied = 0
|
||||
@@ -1352,12 +1366,12 @@ async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
await fsp.copyFile(src, dst)
|
||||
copied += 1
|
||||
} catch (err) {
|
||||
sendLog(`설정 복사 실패 (${entry.name}): ${(err as Error).message}`)
|
||||
sendLog(t('log.settingCopyFail', { name: entry.name, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
sendLog(`기존 마인크래프트 설정 복사: 새로 복사 ${copied}개 / 보존(이미 존재) ${skipped}개.`)
|
||||
sendLog(t('log.settingCopySummary', { copied, skipped }))
|
||||
} catch (err) {
|
||||
sendLog(`기존 설정 복사 중 오류: ${(err as Error).message}`)
|
||||
sendLog(t('log.settingCopyError', { message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1375,23 +1389,23 @@ async function linkMinecraftRuntimeDirs(customRoot: string): Promise<void> {
|
||||
const src = path.join(mcRoot, dir)
|
||||
const dst = path.join(customRoot, dir)
|
||||
if (!fs.existsSync(src)) {
|
||||
sendLog(`.minecraft/${dir} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.`)
|
||||
sendLog(t('log.runtimeDirMissing', { dir }))
|
||||
continue
|
||||
}
|
||||
let existing: import('node:fs').Stats | null = null
|
||||
try { existing = await fsp.lstat(dst) } catch { existing = null }
|
||||
if (existing) {
|
||||
if (existing.isSymbolicLink()) continue // 이미 링크됨
|
||||
sendLog(`.mc_custom/${dir} 가 실제 폴더로 이미 존재 — 건너뜀.`)
|
||||
sendLog(t('log.runtimeDirExists', { dir }))
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// 'junction' 은 Windows 에서 권한 없이 만들 수 있는 디렉터리 링크.
|
||||
// 다른 OS 에서는 Node 가 알아서 일반 symlink 로 처리.
|
||||
await fsp.symlink(src, dst, 'junction')
|
||||
sendLog(`링크 생성: .mc_custom/${dir} → .minecraft/${dir}`)
|
||||
sendLog(t('log.runtimeLinkCreated', { dir }))
|
||||
} catch (err) {
|
||||
sendLog(`링크 생성 실패 (${dir}): ${(err as Error).message}`)
|
||||
sendLog(t('log.runtimeLinkFail', { dir, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1409,20 +1423,20 @@ ipcMain.handle('finish:desktopShortcut', async () => {
|
||||
const ok = require('electron').shell.writeShortcutLink(shortcutPath, 'create', {
|
||||
target: runBat,
|
||||
cwd: state.installPath,
|
||||
description: '음악퀴즈 서버 실행'
|
||||
description: t('log.shortcutDescription')
|
||||
})
|
||||
sendLog(ok ? `바로가기 생성: ${shortcutPath}` : '바로가기 생성 실패')
|
||||
sendLog(ok ? t('log.shortcutCreated', { path: shortcutPath }) : t('log.shortcutFailed'))
|
||||
})
|
||||
|
||||
ipcMain.handle('finish:startServer', async () => {
|
||||
if (!state.installPath) return
|
||||
const runBat = path.join(state.installPath, 'run.bat')
|
||||
if (!fs.existsSync(runBat)) {
|
||||
sendLog(`run.bat을 찾을 수 없습니다: ${runBat}`)
|
||||
sendLog(t('log.runBatMissingPath', { path: runBat }))
|
||||
return
|
||||
}
|
||||
spawn('cmd.exe', ['/c', 'start', '', runBat], { cwd: state.installPath, detached: true, stdio: 'ignore' }).unref()
|
||||
sendLog('서버 실행 요청 완료.')
|
||||
sendLog(t('log.serverStartRequested'))
|
||||
})
|
||||
|
||||
ipcMain.handle('finish:startLauncher', async () => {
|
||||
@@ -1435,9 +1449,9 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await shell.openExternal('minecraft://')
|
||||
sendLog('마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).')
|
||||
sendLog(t('log.launcherUrlSchemeNonWin'))
|
||||
} catch (err) {
|
||||
sendLog(`런처 실행 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.launcherFail', { message: (err as Error).message }))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1449,15 +1463,15 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
type LauncherCandidate = { label: string; path: string; viaShell: boolean }
|
||||
const candidates: LauncherCandidate[] = [
|
||||
// Win32 설치판 — 실행 파일 직접 spawn.
|
||||
{ label: 'Win32 설치(Program Files (x86))', path: path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Win32 설치(Program Files)', path: path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Win32 설치(legacy Minecraft 폴더)', path: path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Win32 설치(legacy Minecraft 폴더)', path: path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: 'Xbox / Game Pass', path: 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', viaShell: false },
|
||||
{ label: 'npm/portable', path: path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: t('candidates.winProgramFiles86'), path: path.join(programFilesX86, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: t('candidates.winProgramFiles'), path: path.join(programFiles, 'Minecraft Launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: t('candidates.winLegacy86'), path: path.join(programFilesX86, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: t('candidates.winLegacy'), path: path.join(programFiles, 'Minecraft', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
{ label: t('candidates.xboxGamePass'), path: 'C:\\XboxGames\\Minecraft Launcher\\Content\\Minecraft.exe', viaShell: false },
|
||||
{ label: t('candidates.npmPortable'), path: path.join(localAppData, 'Programs', 'minecraft-launcher', 'MinecraftLauncher.exe'), viaShell: false },
|
||||
// App Execution Alias(MS Store 설치 시 자동 생성, reparse point 라 cmd /c start 로 띄워야 안정적).
|
||||
{ label: 'App Execution Alias(Minecraft.exe)', path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'Minecraft.exe'), viaShell: true },
|
||||
{ label: 'App Execution Alias(MinecraftLauncher.exe)', path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'MinecraftLauncher.exe'), viaShell: true }
|
||||
{ label: t('candidates.appAliasMinecraft'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'Minecraft.exe'), viaShell: true },
|
||||
{ label: t('candidates.appAliasLauncher'), path: path.join(localAppData, 'Microsoft', 'WindowsApps', 'MinecraftLauncher.exe'), viaShell: true }
|
||||
]
|
||||
|
||||
for (const cand of candidates) {
|
||||
@@ -1466,15 +1480,15 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
if (!exists) continue
|
||||
try {
|
||||
if (cand.viaShell) {
|
||||
sendLog(`마인크래프트 런처 실행(${cand.label}, 셸 경유): ${cand.path}`)
|
||||
sendLog(t('log.launcherExecShell', { label: cand.label, path: cand.path }))
|
||||
spawn('cmd.exe', ['/c', 'start', '', cand.path], { detached: true, stdio: 'ignore' }).unref()
|
||||
} else {
|
||||
sendLog(`마인크래프트 런처 실행(${cand.label}): ${cand.path}`)
|
||||
sendLog(t('log.launcherExec', { label: cand.label, path: cand.path }))
|
||||
spawn(cand.path, [], { detached: true, stdio: 'ignore' }).unref()
|
||||
}
|
||||
return
|
||||
} catch (err) {
|
||||
sendLog(`${cand.path} 실행 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.launcherCandFail', { path: cand.path, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1482,24 +1496,26 @@ ipcMain.handle('finish:startLauncher', async () => {
|
||||
// 마인크래프트 런처(Java) PFN: Microsoft.4297127D64EC6_8wekyb3d8bbwe, AppId: Minecraft.
|
||||
try {
|
||||
const aumid = 'shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft'
|
||||
sendLog(`AppsFolder 로 MS Store 런처 실행 시도: ${aumid}`)
|
||||
sendLog(t('log.launcherAppsFolderTry', { aumid }))
|
||||
spawn('explorer.exe', [aumid], { detached: true, stdio: 'ignore' }).unref()
|
||||
return
|
||||
} catch (err) {
|
||||
sendLog(`AppsFolder 실행 실패: ${(err as Error).message}`)
|
||||
sendLog(t('log.launcherAppsFolderFail', { message: (err as Error).message }))
|
||||
}
|
||||
|
||||
// 마지막 수단: minecraft:// URL 스킴. 런처가 없으면 MS Store 가 열린다.
|
||||
try {
|
||||
sendLog('마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).')
|
||||
sendLog(t('log.launcherUrlSchemeFallback'))
|
||||
await shell.openExternal('minecraft://')
|
||||
} catch (err) {
|
||||
sendLog(`URL 스킴 실행 실패: ${(err as Error).message}.`)
|
||||
sendLog(t('log.launcherUrlSchemeFail', { message: (err as Error).message }))
|
||||
}
|
||||
|
||||
sendLog('Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 "Minecraft Launcher" 를 설치한 뒤 다시 시도해 주세요.')
|
||||
sendLog(t('log.launcherAllFail'))
|
||||
})
|
||||
|
||||
ipcMain.handle('i18n:dict', () => localeDict)
|
||||
|
||||
ipcMain.handle('app:quit', () => {
|
||||
// 모든 창을 닫고 앱 종료. macOS에서도 종료(설치기는 한 번 쓰고 끝이니 잔류시키지 않음).
|
||||
app.quit()
|
||||
|
||||
@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types.js'
|
||||
|
||||
const api = {
|
||||
// i18n
|
||||
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('i18n:dict'),
|
||||
|
||||
// 1단계
|
||||
loadPacks: (manifestUrl?: string): Promise<FetchedPack[]> =>
|
||||
ipcRenderer.invoke('packs:load', manifestUrl),
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
6
src/server/i18n.ts
Normal file
6
src/server/i18n.ts
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string> {
|
||||
// 검증
|
||||
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<string> {
|
||||
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<boolean> {
|
||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||
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<YtPlaylistEntry | nul
|
||||
child.on('error', (err) => 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<YtPlaylistEntry
|
||||
child.on('error', (err) => 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)
|
||||
|
||||
93
src/shared/i18n.ts
Normal file
93
src/shared/i18n.ts
Normal 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] ?? '')
|
||||
}
|
||||
@@ -3,29 +3,29 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>음악퀴즈 목록</title>
|
||||
<title><%= t('site.indexTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<main class="pageWrap">
|
||||
<section class="hero">
|
||||
<h1>마인크래프트 음악퀴즈</h1>
|
||||
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p>
|
||||
<h1><%= t('site.heroTitle') %></h1>
|
||||
<p><%= t('site.heroSubtitle') %></p>
|
||||
</section>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (packs.length === 0) { %>
|
||||
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
||||
<p class="muted"><%= t('site.empty') %></p>
|
||||
<% } %>
|
||||
<% packs.forEach(function (entry) { %>
|
||||
<article class="packCard">
|
||||
<h2><%= entry.name %></h2>
|
||||
<p class="muted">파일: <%= entry.file %>.json</p>
|
||||
<p class="muted"><%= t('site.fileLabel', { file: entry.file }) %></p>
|
||||
<% if (entry.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
|
||||
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
|
||||
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li>
|
||||
<li><%= t('site.mcVersion') %> <strong><%= entry.definition.mcVersion %></strong></li>
|
||||
<li><%= t('site.platform') %> <strong><%= entry.definition.platform.type %></strong></li>
|
||||
<li><%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</article>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 대시보드</title>
|
||||
<title><%= t('dashboard.browserTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -11,36 +11,36 @@
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<h1>음악퀴즈 목록</h1>
|
||||
<h1><%= t('dashboard.title') %></h1>
|
||||
<div class="dashboardActions">
|
||||
<a class="secondaryButton" href="/op/list">음악목록 수정</a>
|
||||
<a class="secondaryButton" href="/op/datapack">데이터팩 수정</a>
|
||||
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
|
||||
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
|
||||
<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>
|
||||
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button>
|
||||
<button type="button" class="secondaryButton" id="deleteToggle"><%= t('dashboard.deletePack') %></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (items.length === 0) { %>
|
||||
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p>
|
||||
<p class="muted"><%= t('dashboard.emptyHint') %></p>
|
||||
<% } %>
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard editableCard" data-key="<%= item.key %>">
|
||||
<label class="cardCheckbox" hidden>
|
||||
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
|
||||
<span>선택</span>
|
||||
<span><%= t('dashboard.select') %></span>
|
||||
</label>
|
||||
<a class="cardLink" href="/op/dashboard/<%= item.key %>">
|
||||
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||
<p class="muted"><%= item.key %>.json</p>
|
||||
<% if (item.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li>MC <%= item.definition.mcVersion %></li>
|
||||
<li>플랫폼 <%= item.definition.platform.type %></li>
|
||||
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
|
||||
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</a>
|
||||
@@ -48,8 +48,8 @@
|
||||
<% }) %>
|
||||
</section>
|
||||
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
|
||||
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
|
||||
<button type="submit" class="dangerButton">삭제 확인</button>
|
||||
<button type="button" class="secondaryButton" id="deleteCancel"><%= t('common.cancel') %></button>
|
||||
<button type="submit" class="dangerButton"><%= t('dashboard.confirmDelete') %></button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>데이터팩 수정</title>
|
||||
<title><%= t('datapack.browserTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -12,21 +12,21 @@
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
|
||||
<h1 style="margin-top:20px;">데이터팩 수정</h1>
|
||||
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('datapack.title') %></h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dpControls">
|
||||
<button type="button" class="primaryButton" id="pickPackBtn">음악퀴즈 선택</button>
|
||||
<span class="muted" id="pickedLabel">선택된 음악퀴즈 없음</span>
|
||||
<button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
|
||||
<span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
|
||||
</section>
|
||||
|
||||
<p class="muted" id="countLabel"></p>
|
||||
|
||||
<section class="dpActions" hidden id="dpActions">
|
||||
<button type="button" class="secondaryButton" id="exportBtn">데이터팩 출력</button>
|
||||
<button type="button" class="secondaryButton" id="copyBtn">복사</button>
|
||||
<button type="button" class="secondaryButton" id="exportBtn"><%= t('datapack.export') %></button>
|
||||
<button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
|
||||
<span class="statusText" id="dp-status"></span>
|
||||
</section>
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<!-- 음악퀴즈 선택 팝업 -->
|
||||
<div class="modalOverlay" id="pickModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header><h3>음악퀴즈 선택</h3>
|
||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||
<header><h3><%= t('datapack.modalPickTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<div class="cardRow horizontalScroll" id="pickList">
|
||||
@@ -47,8 +47,8 @@
|
||||
<p class="muted"><%= item.key %>.json</p>
|
||||
<% if (item.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li>MC <%= item.definition.mcVersion %></li>
|
||||
<li>플랫폼 <%= item.definition.platform.type %></li>
|
||||
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</article>
|
||||
@@ -58,6 +58,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
|
||||
// 데이터팩 출력 본문의 "총 N곡" 패턴은 datapackOutput.summary 와 동일.
|
||||
var SUMMARY_PATTERN = <%- JSON.stringify(localeDict.datapackOutput.summary) %>;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var pickModal = document.getElementById('pickModal')
|
||||
@@ -75,12 +80,10 @@
|
||||
card.addEventListener('click', function () {
|
||||
pickedKey = card.getAttribute('data-key')
|
||||
var name = card.getAttribute('data-name')
|
||||
document.getElementById('pickedLabel').textContent = '선택: ' + name
|
||||
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
|
||||
pickModal.hidden = true
|
||||
document.getElementById('dpActions').hidden = false
|
||||
// 곡 수 미리 가져오기
|
||||
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
|
||||
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
|
||||
document.getElementById('countLabel').textContent = ''
|
||||
document.getElementById('codeOut').hidden = true
|
||||
})
|
||||
@@ -88,30 +91,30 @@
|
||||
document.getElementById('exportBtn').addEventListener('click', function () {
|
||||
if (!pickedKey) return
|
||||
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')
|
||||
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
s.textContent = '실패: ' + res.text; s.classList.add('error')
|
||||
s.textContent = I18N.failed.replace('{{message}}', res.text); s.classList.add('error')
|
||||
return
|
||||
}
|
||||
var out = document.getElementById('codeOut')
|
||||
out.textContent = res.text
|
||||
out.hidden = false
|
||||
// 첫줄/둘째줄에서 카운트 가져와 표기
|
||||
// 첫줄/둘째줄에서 곡 개수를 추출해 카운트 라벨에 표시.
|
||||
var m = res.text.match(/총\s+(\d+)곡/)
|
||||
if (m) document.getElementById('countLabel').textContent = '총 ' + m[1] + '개의 음악을 찾았습니다.'
|
||||
s.textContent = '출력 완료'
|
||||
if (m) document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', m[1])
|
||||
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 () {
|
||||
var out = document.getElementById('codeOut')
|
||||
if (out.hidden) return
|
||||
navigator.clipboard.writeText(out.textContent).then(function () {
|
||||
var s = document.getElementById('dp-status')
|
||||
s.textContent = '복사됨'
|
||||
s.textContent = I18N.copied
|
||||
s.classList.remove('error')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -12,27 +12,27 @@
|
||||
<main class="pageWrap">
|
||||
<section class="editorHeader">
|
||||
<div>
|
||||
<p class="eyebrow">PACK EDITOR</p>
|
||||
<p class="eyebrow"><%= t('editor.eyebrow') %></p>
|
||||
<h1><%= pack.name %></h1>
|
||||
</div>
|
||||
<a class="ghostLink" href="/op/dashboard">목록으로</a>
|
||||
<a class="ghostLink" href="/op/dashboard"><%= t('common.backToList') %></a>
|
||||
</section>
|
||||
|
||||
<form method="post" class="editorForm" id="editorForm">
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>음악퀴즈 이름</span>
|
||||
<span><%= t('editor.displayName') %></span>
|
||||
<input name="displayName" value="<%= pack.name %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>JSON 파일 이름 (확장자 제외)</span>
|
||||
<span><%= t('editor.fileName') %></span>
|
||||
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>마인크래프트 버전</span>
|
||||
<span><%= t('editor.mcVersion') %></span>
|
||||
<select name="mcVersion" required>
|
||||
<% releases.forEach(function (release) { %>
|
||||
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
|
||||
@@ -40,7 +40,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>모드 플랫폼</span>
|
||||
<span><%= t('editor.platformType') %></span>
|
||||
<select name="platformType" id="platformType">
|
||||
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
|
||||
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
|
||||
@@ -48,62 +48,75 @@
|
||||
</select>
|
||||
</label>
|
||||
<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" />
|
||||
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/<파일명></code>으로 해석됩니다.</small>
|
||||
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
|
||||
</label>
|
||||
<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 || '' %>">
|
||||
<option value="">불러오는 중...</option>
|
||||
<option value=""><%= t('common.loading') %></option>
|
||||
</select>
|
||||
<small class="muted">선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.</small>
|
||||
<small class="muted"><%= t('editor.platformLoaderHint') %></small>
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최소 램 (MB)</span>
|
||||
<span><%= t('editor.serverMinRam') %></span>
|
||||
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최대 램 (MB)</span>
|
||||
<span><%= t('editor.serverMaxRam') %></span>
|
||||
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 최소 램 (MB)</span>
|
||||
<span><%= t('editor.clientMinRam') %></span>
|
||||
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 권장 램 (MB)</span>
|
||||
<span><%= t('editor.clientRecommendedRam') %></span>
|
||||
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>맵 파일 (.zip)</span>
|
||||
<span><%= t('editor.mapPath') %></span>
|
||||
<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>
|
||||
<span>서버 파일 (.zip)</span>
|
||||
<span><%= t('editor.serverPath') %></span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>모드 폴더 이름</span>
|
||||
<span><%= t('editor.modsFolder') %></span>
|
||||
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
|
||||
<small class="muted">/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
|
||||
<small class="muted"><%- t('editor.modsFolderHint') %></small>
|
||||
</label>
|
||||
<label>
|
||||
<span>리소스팩 (.zip)</span>
|
||||
<span><%= t('editor.resourcepackPath') %></span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<button class="primaryButton" type="submit">저장</button>
|
||||
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
|
||||
</form>
|
||||
</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>
|
||||
(function () {
|
||||
var platformSelect = document.getElementById('platformType')
|
||||
@@ -136,7 +149,7 @@
|
||||
|
||||
function populateLoaderOptions(versions, preselect) {
|
||||
if (!versions || versions.length === 0) {
|
||||
loaderSelect.innerHTML = '<option value="">호환 로더 없음</option>'
|
||||
loaderSelect.innerHTML = '<option value="">' + I18N.loaderEmpty + '</option>'
|
||||
return
|
||||
}
|
||||
var html = ''
|
||||
@@ -156,7 +169,7 @@
|
||||
function loadFabricLoaders() {
|
||||
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
|
||||
if (!mc) {
|
||||
loaderSelect.innerHTML = '<option value="">마인크래프트 버전을 먼저 선택하세요</option>'
|
||||
loaderSelect.innerHTML = '<option value="">' + I18N.loaderPickMc + '</option>'
|
||||
return
|
||||
}
|
||||
if (loaderCache[mc]) {
|
||||
@@ -164,7 +177,7 @@
|
||||
return
|
||||
}
|
||||
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))
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
@@ -181,7 +194,8 @@
|
||||
})
|
||||
.catch(function (err) {
|
||||
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)
|
||||
if (clientMin > clientReco) {
|
||||
event.preventDefault()
|
||||
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
|
||||
alert(I18N.ramOrderInvalid)
|
||||
return
|
||||
}
|
||||
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
|
||||
event.preventDefault()
|
||||
alert('Fabric 로더 버전을 선택해 주세요.')
|
||||
alert(I18N.fabricLoaderRequired)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>음악목록 수정</title>
|
||||
<title><%= t('list.browserTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -12,14 +12,14 @@
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
|
||||
<h1 style="margin-top:20px;">음악목록 수정</h1>
|
||||
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('list.title') %></h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (items.length === 0) { %>
|
||||
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
||||
<p class="muted"><%= t('site.empty') %></p>
|
||||
<% } %>
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard">
|
||||
@@ -28,9 +28,9 @@
|
||||
<p class="muted"><%= item.key %>.json</p>
|
||||
<% if (item.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li>MC <%= item.definition.mcVersion %></li>
|
||||
<li>플랫폼 <%= item.definition.platform.type %></li>
|
||||
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
|
||||
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</a>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -12,31 +12,31 @@
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<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>
|
||||
<p class="muted"><%= packKey %>.json</p>
|
||||
</div>
|
||||
<div class="dirtyMark" id="dirty-mark" hidden title="저장되지 않은 변경사항이 있습니다">*</div>
|
||||
<div class="dirtyMark" id="dirty-mark" hidden title="<%= t('listEditor.dirtyTooltip') %>">*</div>
|
||||
</section>
|
||||
|
||||
<div class="tabBar">
|
||||
<button type="button" class="tabBtn active" data-tab="music">음악목록</button>
|
||||
<button type="button" class="tabBtn" data-tab="image">사진목록</button>
|
||||
<button type="button" class="tabBtn active" data-tab="music"><%= t('listEditor.tabMusic') %></button>
|
||||
<button type="button" class="tabBtn" data-tab="image"><%= t('listEditor.tabImage') %></button>
|
||||
</div>
|
||||
|
||||
<!-- 음악 탭 -->
|
||||
<section class="tabPanel" id="tab-music">
|
||||
<div class="listActionsRow">
|
||||
<button type="button" class="primaryButton" data-action="save" data-target="music">목록 저장</button>
|
||||
<button type="button" class="dangerButton" data-action="clear" 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"><%= t('listEditor.clearList') %></button>
|
||||
<span class="statusText" id="status-music"></span>
|
||||
</div>
|
||||
|
||||
<div class="playlistRow">
|
||||
<input type="url" class="textInput" id="music-playlist-url"
|
||||
placeholder="유튜브 플레이리스트 URL"
|
||||
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
|
||||
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>
|
||||
|
||||
<ol class="trackList" id="music-list"></ol>
|
||||
@@ -45,17 +45,17 @@
|
||||
<!-- 사진 탭 -->
|
||||
<section class="tabPanel" id="tab-image" hidden>
|
||||
<div class="listActionsRow">
|
||||
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
|
||||
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
|
||||
<button type="button" class="secondaryButton" id="image-from-music">음악목록에서 가져오기</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"><%= t('listEditor.clearList') %></button>
|
||||
<button type="button" class="secondaryButton" id="image-from-music"><%= t('listEditor.imageFromMusic') %></button>
|
||||
<span class="statusText" id="status-image"></span>
|
||||
</div>
|
||||
|
||||
<div class="playlistRow">
|
||||
<input type="url" class="textInput" id="image-playlist-url"
|
||||
placeholder="유튜브 플레이리스트 URL"
|
||||
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
|
||||
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 class="imageGrid" id="image-list"></div>
|
||||
@@ -64,22 +64,22 @@
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||
<button type="button" data-ctx="edit">수정</button>
|
||||
<button type="button" data-ctx="delete">삭제</button>
|
||||
<button type="button" data-ctx="edit"><%= t('common.edit') %></button>
|
||||
<button type="button" data-ctx="delete"><%= t('common.delete') %></button>
|
||||
</div>
|
||||
|
||||
<!-- Confirm modal -->
|
||||
<div class="modalOverlay" id="confirmModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header><h3 id="confirm-title">확인</h3>
|
||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||
<header><h3 id="confirm-title"><%= t('listEditor.modalConfirmTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<p id="confirm-message"></p>
|
||||
</div>
|
||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
||||
<button type="button" class="primaryButton" id="confirm-ok">확인</button>
|
||||
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
|
||||
<button type="button" class="primaryButton" id="confirm-ok"><%= t('common.ok') %></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,21 +87,21 @@
|
||||
<!-- Edit modal (music) -->
|
||||
<div class="modalOverlay" id="editMusicModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header><h3>음악 항목 수정</h3>
|
||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||
<header><h3><%= t('listEditor.musicEditTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<label>유튜브 영상 주소
|
||||
<label><%= t('listEditor.musicEditUrl') %>
|
||||
<input type="url" id="edit-music-url" class="textInput" />
|
||||
</label>
|
||||
<p class="muted" style="margin-top:6px;font-size:12px;">
|
||||
저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.
|
||||
<%= t('listEditor.musicEditHint') %>
|
||||
</p>
|
||||
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
|
||||
</div>
|
||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
||||
<button type="button" class="primaryButton" id="edit-music-save">저장</button>
|
||||
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
|
||||
<button type="button" class="primaryButton" id="edit-music-save"><%= t('common.save') %></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,21 +109,21 @@
|
||||
<!-- Edit modal (image) -->
|
||||
<div class="modalOverlay" id="editImageModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header><h3>사진 항목 수정</h3>
|
||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||
<header><h3><%= t('listEditor.imageEditTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<div class="segmentedRow">
|
||||
<button type="button" class="segBtn active" data-seg="yt">유튜브 주소</button>
|
||||
<button type="button" class="segBtn" data-seg="img">이미지 주소</button>
|
||||
<button type="button" class="segBtn active" data-seg="yt"><%= t('listEditor.imageSegYt') %></button>
|
||||
<button type="button" class="segBtn" data-seg="img"><%= t('listEditor.imageSegImg') %></button>
|
||||
</div>
|
||||
<label>주소
|
||||
<label><%= t('listEditor.imageEditUrl') %>
|
||||
<input type="url" id="edit-image-url" class="textInput" />
|
||||
</label>
|
||||
</div>
|
||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
||||
<button type="button" class="primaryButton" id="edit-image-save">저장</button>
|
||||
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
|
||||
<button type="button" class="primaryButton" id="edit-image-save"><%= t('common.save') %></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,6 +131,8 @@
|
||||
<script>
|
||||
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||
var INITIAL = <%- JSON.stringify(list) %>;
|
||||
var I18N = <%- JSON.stringify(localeDict.listEditor) %>;
|
||||
I18N.common = <%- JSON.stringify(localeDict.common) %>;
|
||||
</script>
|
||||
<script src="/static/listEditor.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 로그인</title>
|
||||
<title><%= t('login.title') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody centerLayout">
|
||||
<main class="loginCard">
|
||||
<h1>관리자 로그인</h1>
|
||||
<h1><%= t('login.title') %></h1>
|
||||
<% if (error) { %>
|
||||
<p class="errorBanner"><%= error %></p>
|
||||
<% } %>
|
||||
<form method="post" action="/op" class="loginForm">
|
||||
<label>
|
||||
<span>비밀번호</span>
|
||||
<span><%= t('login.password') %></span>
|
||||
<input name="password" type="password" autocomplete="current-password" required autofocus />
|
||||
</label>
|
||||
<button class="primaryButton" type="submit">로그인</button>
|
||||
<button class="primaryButton" type="submit"><%= t('login.submit') %></button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<header class="topNav">
|
||||
<a class="navBrand" href="/op/dashboard">
|
||||
<span class="navLogo">🎵</span>
|
||||
<span class="navTitle">관리자 페이지</span>
|
||||
<span class="navTitle"><%= t('nav.brand') %></span>
|
||||
</a>
|
||||
<div class="navUser">
|
||||
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
|
||||
<div class="navUserMenu" id="userMenu" hidden>
|
||||
<form method="post" action="/op/logout">
|
||||
<button type="submit" class="dangerLink">로그아웃</button>
|
||||
<button type="submit" class="dangerLink"><%= t('nav.logout') %></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user