Compare commits
38 Commits
401d72622e
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ae771668de | |||
| 40c47fbeb3 | |||
| 6e170646a7 | |||
| 3017e77479 | |||
| c8da4207fc | |||
|
|
dfb60046c8 | ||
|
|
6472b12d58 | ||
| bc974ecd24 | |||
| 132700720d | |||
| c527efc42f | |||
| 4ee0a59f2b | |||
| 06b35abcb1 | |||
| ca1c5f237f | |||
| 5ea9b49630 | |||
| 49f320fa71 | |||
| 848fac500e | |||
| 212e70cd56 | |||
| 3ca93abae9 | |||
| a8b9b689c2 | |||
| 1665f05c55 | |||
| 40b2ff81f5 | |||
| 9cb7c05b43 | |||
| 671831535b | |||
| 506e506cfa | |||
| 9db70d0bea | |||
| c8911a9a62 | |||
| 2a500a381f | |||
| ea72051e43 | |||
| c0472bb57b | |||
| de08f9a810 | |||
| af884706d4 | |||
| 2344c4b8d2 | |||
| f9cf373550 | |||
| f92dc02879 | |||
| 5e418a5c21 | |||
| 6cd402121b | |||
| 135bc98840 | |||
| c2fcc2fbbf |
4
.env.build
Normal file
4
.env.build
Normal file
@@ -0,0 +1,4 @@
|
||||
# 빌드용 환경변수 — `npm run dist:win` / `npm run dist:win:rp` 로 패키징될 때
|
||||
# 설치기 exe 의 `resources/.env.build` 로 함께 배포되어 런타임에 로드됨.
|
||||
# 서버 운영용(PORT/HOST/SESSION_SECRET) 값은 여기 두지 말고 `.env` 에.
|
||||
SITE_BASE_URL=https://mc.tkrmagid.kr
|
||||
37
.env.build.example
Normal file
37
.env.build.example
Normal file
@@ -0,0 +1,37 @@
|
||||
# =============================================================================
|
||||
# 음악퀴즈 통합 패키지 — 빌드용 환경변수 템플릿
|
||||
#
|
||||
# 이 파일은 `npm run dist:win` / `npm run dist:win:rp` 로 exe 를 패키징할 때
|
||||
# 설치기(installer / installer-rp) 안에 함께 묶이는 값들입니다.
|
||||
# 개발 실행에서 쓰는 `.env` 와는 분리되어 있어, 운영 도메인 같은 값을 빌드용
|
||||
# 으로만 관리할 수 있습니다.
|
||||
#
|
||||
# 사용법:
|
||||
# 1) 이 파일을 복사해 `.env.build` 로 만든다.
|
||||
# 2) 운영 도메인 등 배포에 들어갈 값으로 채운다.
|
||||
# 3) `npm run dist:win` 또는 `npm run dist:win:rp` 로 빌드한다.
|
||||
# → electron-builder 가 `.env.build` 를 패키지된 exe 의
|
||||
# `resources/.env.build` 로 함께 배포.
|
||||
# → 런타임에서 `env.ts` 가 우선 로드.
|
||||
#
|
||||
# `.env.build` 는 .gitignore 로 제외되어 있습니다.
|
||||
# 서버(express) 운영용 PORT / HOST / SESSION_SECRET 같은 변수는 여기 두지 말고
|
||||
# 서버 측 `.env` 에 두세요. 이 파일은 설치기 exe 에 묶이는 값 전용입니다.
|
||||
# =============================================================================
|
||||
|
||||
# ----- 사이트 도메인(설치기가 manifest 를 받아갈 주소) -----
|
||||
|
||||
# 설치기 두 종(installer / installer-rp) 이 첫 화면에서 자동으로 채워 넣는
|
||||
# manifest 의 호스트. 프로토콜 + 호스트(+포트) 까지만 적고 슬래시는 끝에 붙이지 않음.
|
||||
# 예) 운영 도메인 : https://mq.example.com
|
||||
# 로컬 개발 : http://127.0.0.1:3000
|
||||
SITE_BASE_URL=https://mq.example.com
|
||||
|
||||
# 위 SITE_BASE_URL 로부터 자동으로 `${SITE_BASE_URL}/manifest.json` 이 생성됩니다.
|
||||
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
|
||||
# MANIFEST_URL=https://mq.example.com/manifest.json
|
||||
|
||||
# ----- 리소스팩 설치기 -----
|
||||
|
||||
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.
|
||||
# MUSIC_CONCURRENCY=
|
||||
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
41
electron-builder-rp.yml
Normal file
41
electron-builder-rp.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
appId: kr.tkrmagid.musicquiz.installer-rp
|
||||
productName: MusicQuizResourcepackInstaller
|
||||
# 루트 package.json 의 "main" 은 메인 설치기를 가리키므로, 패키지된 앱이
|
||||
# 리소스팩 설치기를 진입점으로 쓰도록 빌드 시 main 을 덮어쓴다.
|
||||
extraMetadata:
|
||||
main: dist/installer-rp/main.js
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer-rp/**
|
||||
- dist/shared/**
|
||||
- installer-rp/**
|
||||
# rp 의 index.html 은 메인 설치기와 동일한 styles.css 를 공유함
|
||||
# (`<link href="../installer/styles.css">`). asar 안에 해당 파일이 없으면
|
||||
# UI 가 무스타일로 렌더링되므로 그 한 파일만 명시적으로 포함.
|
||||
- installer/styles.css
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
|
||||
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
|
||||
- "!node_modules/@img/sharp-linux-*"
|
||||
- "!node_modules/@img/sharp-linuxmusl-*"
|
||||
- "!node_modules/@img/sharp-libvips-linux-*"
|
||||
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
|
||||
# 메인 설치기와 동일하게 빌드 전용 `.env.build` 와 locales 를 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env.build
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: portable
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
icon: build/icon.ico
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
@@ -2,22 +2,37 @@ appId: kr.tkrmagid.musicquiz.installer
|
||||
productName: MusicQuizInstaller
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer/**
|
||||
- dist/shared/**
|
||||
- installer/**
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
|
||||
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
|
||||
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
|
||||
- "!node_modules/@img/sharp-linux-*"
|
||||
- "!node_modules/@img/sharp-linuxmusl-*"
|
||||
- "!node_modules/@img/sharp-libvips-linux-*"
|
||||
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
|
||||
# 빌드 전용 `.env.build` 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||
# `.env` 는 서버/개발 실행용이라 빌드 산출물에는 포함되지 않으며, 패키지된 exe
|
||||
# 는 `resources/.env.build` 를 우선 로드함(없으면 `resources/.env` 로 폴백).
|
||||
# 패키징 후 운영자가 `resources/.env.build` 만 교체해서 도메인을 바꿀 수 있음.
|
||||
# locales/ 폴더는 i18n.ts 가 process.resourcesPath/locales/<component>/ko-kr.json
|
||||
# 을 찾아 로드하므로, 빌드된 .exe 에서도 한국어 사전이 적용되도록 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env
|
||||
- .env.build
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-Setup.${ext}
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
target: portable
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
icon: build/icon.ico
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
|
||||
Binary file not shown.
@@ -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) {
|
||||
})
|
||||
}
|
||||
|
||||
renderStep1()
|
||||
;(async function () {
|
||||
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
|
||||
applyStaticI18n()
|
||||
renderStep1()
|
||||
})()
|
||||
|
||||
@@ -2,10 +2,33 @@
|
||||
|
||||
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,
|
||||
mode: null, // 'single' | 'multi'
|
||||
// mode==='multi' 일 때만 의미가 있다.
|
||||
// 'host' → 서버를 직접 연다. 기존 멀티 흐름 (step3 + step4) 그대로.
|
||||
// 'participant' → 친구 서버에 접속만 한다. step3 (서버 설치) 를 건너뛰고
|
||||
// client 측에서도 맵은 받지 않는다 (참가자라 서버에 이미 있음).
|
||||
role: null, // 'host' | 'participant' | null
|
||||
serverInstall: {
|
||||
path: '',
|
||||
jdk: '',
|
||||
@@ -30,14 +53,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 +102,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 +112,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 +143,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,54 +154,105 @@ 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]')
|
||||
|
||||
function applySelection(mode) {
|
||||
function applyMode(mode) {
|
||||
state.mode = mode
|
||||
modeButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-mode') === mode) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
// 모드가 바뀌면 이전에 골랐던 역할은 의미가 없어진다. 멀티→싱글 전환 시 잔존하던
|
||||
// role 이 다음 단계 분기에 영향 주지 않도록 명시적으로 초기화.
|
||||
if (mode !== 'multi') state.role = null
|
||||
}
|
||||
|
||||
modeButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applySelection(btn.getAttribute('data-mode'))
|
||||
applyMode(btn.getAttribute('data-mode'))
|
||||
})
|
||||
})
|
||||
|
||||
if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode)
|
||||
if (state.mode === 'single' || state.mode === 'multi') {
|
||||
applyMode(state.mode)
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.mode) return
|
||||
state.stepDone[2] = true
|
||||
if (state.mode === 'single') renderStep4()
|
||||
else renderStep3()
|
||||
// 멀티는 호스트/참가자 선택 탭을 거친다. 싱글은 곧장 클라이언트(step4) 로.
|
||||
if (state.mode === 'multi') renderStep2Role()
|
||||
else renderStep4()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
}
|
||||
|
||||
function renderStep2Role() {
|
||||
// 스텝 인디케이터는 여전히 2 단계 안쪽이다 — 호스트/참가자 선택은 모드 선택의
|
||||
// 하위 결정이기 때문. 별도 탭으로 분리해서 한 화면에 한 결정만 보이도록 한다.
|
||||
setActiveStep(2)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + tt('step2.roleHeading') + '</h2>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button type="button" data-role="host"><strong>' + tt('step2.hostTitle') + '</strong><br><small>' + tt('step2.hostHint') + '</small></button>' +
|
||||
'<button type="button" data-role="participant"><strong>' + tt('step2.participantTitle') + '</strong><br><small>' + tt('step2.participantHint') + '</small></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 roleButtons = section.querySelectorAll('[data-role]')
|
||||
|
||||
function applyRole(role) {
|
||||
state.role = role
|
||||
roleButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-role') === role) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
}
|
||||
|
||||
roleButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applyRole(btn.getAttribute('data-role'))
|
||||
})
|
||||
})
|
||||
|
||||
if (state.role === 'host' || state.role === 'participant') applyRole(state.role)
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.role) return
|
||||
// 호스트는 서버 설치(step3) 부터, 참가자는 클라이언트(step4) 로 바로.
|
||||
if (state.role === 'host') renderStep3()
|
||||
else renderStep4()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep2)
|
||||
}
|
||||
|
||||
function renderStep3() {
|
||||
setActiveStep(3)
|
||||
clearPage()
|
||||
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')
|
||||
|
||||
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) }
|
||||
// step3 는 멀티+호스트 만 진입하므로 sub31 의 back 은 역할 선택 탭으로.
|
||||
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2Role, show32) }
|
||||
function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) }
|
||||
function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) }
|
||||
function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) }
|
||||
@@ -180,12 +268,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 +284,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 +298,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 +317,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 +325,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 +340,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 +357,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 +393,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 +404,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 +436,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 +448,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 +478,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,44 +489,40 @@ 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
|
||||
async function openEulaPopup(installPath) {
|
||||
var read = await installerApi.readEula(installPath)
|
||||
// eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 EULA 페이지를 받아서
|
||||
// 표시한다 — 사용자가 실제 서버 약관을 보고 동의하도록.
|
||||
async function openEulaPopup(_installPath) {
|
||||
var bodyHtml = ''
|
||||
if (read.exists) {
|
||||
bodyHtml = '<p class="formMessage">서버 파일에 포함된 eula.txt 내용입니다.</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) {
|
||||
var overlay = document.createElement('div')
|
||||
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)
|
||||
@@ -456,30 +543,24 @@ async function openEulaPopup(installPath) {
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text).replace(/[&<>"']/g, function (ch) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
|
||||
})
|
||||
}
|
||||
|
||||
function escapeAttr(text) {
|
||||
return String(text).replace(/&/g, '&').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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,39 +570,48 @@ 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')
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
|
||||
// 25565 는 마인크래프트 자바판 기본 포트라 클라이언트에서 생략 가능 →
|
||||
// 사용자에게도 ip 만 보여주는 게 깔끔하다.
|
||||
function formatServerAddress(ip, port) {
|
||||
var safeIp = ip || tt('step3.sub35.ipUnknown')
|
||||
if (Number(port) === 25565) return safeIp
|
||||
return safeIp + ':' + port
|
||||
}
|
||||
|
||||
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
|
||||
var address = formatServerAddress(result.externalIp, result.port)
|
||||
if (result.status === 'preForwarded') {
|
||||
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else if (result.status === 'upnpOk') {
|
||||
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else {
|
||||
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
|
||||
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
|
||||
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
|
||||
tt('step3.sub35.manualDetail', { address: address })
|
||||
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,87 +631,58 @@ 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')
|
||||
function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
|
||||
|
||||
function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
|
||||
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) }
|
||||
function show43() {
|
||||
subHost.innerHTML = ''
|
||||
renderSubStep43(subHost, show42, function () {
|
||||
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
|
||||
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
state.client.installPlatform = platformType !== 'vanilla'
|
||||
|
||||
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
|
||||
// 멀티+참가자 는 직전 화면이 역할 선택 탭이므로 거기로, 싱글은 모드 탭으로.
|
||||
function backToPrevStep() {
|
||||
if (state.mode === 'multi' && state.role === 'host') renderStep3()
|
||||
else if (state.mode === 'multi') renderStep2Role()
|
||||
else renderStep2()
|
||||
}
|
||||
|
||||
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
|
||||
function goStep5() {
|
||||
state.stepDone[4] = true
|
||||
renderStep5()
|
||||
})
|
||||
}
|
||||
show41()
|
||||
}
|
||||
|
||||
function renderSubStep41(host, pack, back, done) {
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
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>'
|
||||
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>' +
|
||||
'<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>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
|
||||
var nextBtn = host.querySelector('#next')
|
||||
var choiceButtons = host.querySelectorAll('[data-choice]')
|
||||
|
||||
function applyChoice(choice) {
|
||||
state.client.installPlatform = choice === 'install'
|
||||
choiceButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-choice') === choice) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
}
|
||||
|
||||
choiceButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applyChoice(btn.getAttribute('data-choice'))
|
||||
})
|
||||
})
|
||||
|
||||
if (typeof state.client.installPlatform === 'boolean') {
|
||||
applyChoice(state.client.installPlatform ? 'install' : 'skip')
|
||||
}
|
||||
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
nextBtn.addEventListener('click', done)
|
||||
show42()
|
||||
}
|
||||
|
||||
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)
|
||||
nextBtn.addEventListener('click', done)
|
||||
|
||||
// 이미 설치됐다면 다시 돌리지 않음
|
||||
if (state.client.clientInstalled) {
|
||||
msg.textContent = '클라이언트 설치 완료.'
|
||||
// 이번에 실제로 보내야 할 payload. 이전 진입에서 같은 payload 로 이미 끝났으면
|
||||
// 다시 돌리지 않지만, packKey / installPlatform / skipMap 중 하나라도 다르면
|
||||
// (예: 참가자 → 싱글 로 뒤로가서 변경) 재설치한다.
|
||||
var payload = {
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform,
|
||||
// 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다.
|
||||
skipMap: state.mode === 'multi' && state.role === 'participant'
|
||||
}
|
||||
var last = state.client.lastInstall
|
||||
if (last
|
||||
&& last.packKey === payload.packKey
|
||||
&& last.installPlatform === payload.installPlatform
|
||||
&& last.skipMap === payload.skipMap) {
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
nextBtn.disabled = false
|
||||
return
|
||||
@@ -630,53 +691,45 @@ function renderSubStep42(host, back, done) {
|
||||
// 페이지 진입 즉시 자동 설치
|
||||
;(async function () {
|
||||
try {
|
||||
await installerApi.installClient({
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform
|
||||
})
|
||||
msg.textContent = '클라이언트 설치 완료.'
|
||||
await installerApi.installClient(payload)
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
state.client.clientInstalled = true
|
||||
state.client.lastInstall = payload
|
||||
nextBtn.disabled = false
|
||||
} catch (err) {
|
||||
msg.textContent = '설치 실패: ' + (err && err.message ? err.message : err)
|
||||
// 실패한 호출은 "마지막 성공" 기록에 남기지 않는다. 다음 진입 시 재시도.
|
||||
state.client.lastInstall = null
|
||||
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
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>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
}
|
||||
|
||||
function renderStep5() {
|
||||
setActiveStep(5)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
var multi = state.mode === 'multi'
|
||||
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
|
||||
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
|
||||
var showServerActions = state.mode === 'multi' && state.role === 'host'
|
||||
section.innerHTML =
|
||||
'<h2>5단계. 설치 완료</h2>' +
|
||||
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</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>' +
|
||||
'<h2>' + tt('step5.heading') + '</h2>' +
|
||||
'<p>' + tt('step5.summary') + '</p>' +
|
||||
(showServerActions ? '<div class="subStep">' +
|
||||
'<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) {
|
||||
if (showServerActions) {
|
||||
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||
installerApi.openServerFolder()
|
||||
})
|
||||
@@ -684,9 +737,9 @@ 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 (showServerActions) {
|
||||
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
|
||||
if (section.querySelector('#startServer').checked) await installerApi.startServer()
|
||||
}
|
||||
@@ -694,9 +747,16 @@ function renderStep5() {
|
||||
} catch (err) {
|
||||
// 마무리 액션 실패는 무시하고 종료 진행
|
||||
}
|
||||
finishBtn.textContent = '완료됨'
|
||||
finishBtn.textContent = tt('step5.finished')
|
||||
if (installerApi.quitApp) installerApi.quitApp()
|
||||
})
|
||||
}
|
||||
|
||||
renderStep1()
|
||||
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
||||
;(async function () {
|
||||
try {
|
||||
I18N = (await installerApi.loadLocale()) || {}
|
||||
} catch (_) { I18N = {} }
|
||||
applyStaticI18n()
|
||||
renderStep1()
|
||||
})()
|
||||
|
||||
129
locales/installer-rp/ko-kr.json
Normal file
129
locales/installer-rp/ko-kr.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"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": "너무 많은 요청."
|
||||
},
|
||||
"step1": {
|
||||
"heading": "음악퀴즈 선택"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "리소스팩 설치",
|
||||
"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": "완료",
|
||||
"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}}",
|
||||
"baseShaderOverrideStripped": "베이스 리소스팩의 vanilla 셰이더 오버라이드 제거: assets/minecraft/shaders/{{path}} — mcVersion {{mc}} (pack_format {{format}}) 의 새 GLSL API 와 호환되지 않아 결과 zip 에서 제외했습니다.",
|
||||
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
|
||||
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
|
||||
"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}}"
|
||||
}
|
||||
}
|
||||
295
locales/installer/ko-kr.json
Normal file
295
locales/installer/ko-kr.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"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": "설치할 음악퀴즈 선택",
|
||||
"loading": "목록을 불러오는 중...",
|
||||
"empty": "등록된 음악퀴즈가 없습니다.",
|
||||
"fetchFailed": "목록을 가져오지 못했습니다: {{message}}",
|
||||
"subtitle": "마인크래프트 {{mc}} / {{platform}}"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "싱글 / 멀티 선택",
|
||||
"singleTitle": "싱글",
|
||||
"singleHint": "싱글 맵으로 혼자 플레이할때",
|
||||
"multiTitle": "멀티",
|
||||
"multiHint": "버킷 서버로 친구들과 같이 플레이할때",
|
||||
"roleHeading": "호스트 / 참가자",
|
||||
"hostTitle": "호스트",
|
||||
"hostHint": "내가 서버를 직접 열고 친구들을 초대할 때",
|
||||
"participantTitle": "참가자",
|
||||
"participantHint": "친구가 연 서버에 접속만 할 때"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "서버 관련 설정",
|
||||
"sub31": {
|
||||
"heading": "서버 설치 경로",
|
||||
"description": "서버를 생성할 폴더를 선택하세요.",
|
||||
"pickFolder": "폴더 선택",
|
||||
"invalidPath": "경로가 유효하지 않습니다.",
|
||||
"confirmed": "경로 확정: {{message}}"
|
||||
},
|
||||
"sub32": {
|
||||
"heading": "JDK 확인",
|
||||
"description": "JDK 자동탐색 or 설치",
|
||||
"pickFolder": "폴더 선택",
|
||||
"auto": "자동 탐색",
|
||||
"install": "자동 설치",
|
||||
"installCancel": "설치 취소",
|
||||
"found": "JDK 발견: {{path}}",
|
||||
"autoDetected": "JDK 자동 탐색됨: {{path}}",
|
||||
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.",
|
||||
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.",
|
||||
"cancelRequested": "JDK 설치 취소 요청 중...",
|
||||
"downloading": "JDK 다운로드 중...",
|
||||
"installComplete": "JDK 자동 설치 완료: {{path}}",
|
||||
"installCanceled": "JDK 설치 취소됨",
|
||||
"installFailed": "JDK 설치 실패: {{message}}",
|
||||
"installError": "JDK 설치 오류: {{message}}",
|
||||
"pathRequired": "JDK 경로를 입력해 주세요."
|
||||
},
|
||||
"sub33": {
|
||||
"heading": "서버 다운로드 및 설치",
|
||||
"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 동의",
|
||||
"fromMojang": "마인크래프트 서버를 실행하려면 아래 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": "서버 설정 편집",
|
||||
"description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.",
|
||||
"open": "편집기 열기",
|
||||
"openedAt": "편집기 주소: <a href=\"{{url}}\" target=\"_blank\">{{url}}</a>",
|
||||
"openFailed": "편집기 실행 실패: {{message}}"
|
||||
},
|
||||
"sub35": {
|
||||
"heading": "포트포워딩",
|
||||
"description": "UPNP를 개방해 외부 접속을 허용합니다.",
|
||||
"portLabel": "포트",
|
||||
"recheck": "재점검",
|
||||
"checking": "확인 중...",
|
||||
"preForwarded": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)",
|
||||
"upnpOk": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)",
|
||||
"manualHint": "직접 포트포워딩을 해주세요.",
|
||||
"manualDetail": "<br><small>외부 주소: {{address}}</small>",
|
||||
"checkFailed": "점검 실패: {{message}}",
|
||||
"ipUnknown": "확인 불가"
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
"heading": "클라이언트 설정",
|
||||
"sub42": {
|
||||
"heading": "다운로드 및 적용",
|
||||
"description": "클라이언트 설정",
|
||||
"installing": "설치 중...",
|
||||
"done": "클라이언트 설치 완료.",
|
||||
"failed": "설치 실패: {{message}}"
|
||||
}
|
||||
},
|
||||
"step5": {
|
||||
"heading": "설치 완료",
|
||||
"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 비어 있음 또는 참가자 모드).",
|
||||
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
|
||||
"mapInstalledAs": "맵을 saves/{{name}} 으로 설치했습니다.",
|
||||
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
|
||||
"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 자동 설치 완료.",
|
||||
"fabricAlreadyInstalled": "Fabric 이미 설치돼 있어 건너뜁니다: {{id}} ({{dir}})",
|
||||
"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}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{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)"
|
||||
}
|
||||
}
|
||||
170
locales/server/ko-kr.json
Normal file
170
locales/server/ko-kr.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"common": {
|
||||
"back": "← 돌아가기",
|
||||
"backToList": "목록으로",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"ok": "확인",
|
||||
"delete": "삭제",
|
||||
"edit": "수정",
|
||||
"close": "x",
|
||||
"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": "더블클릭해서 수정",
|
||||
"aliasBtn": "별칭",
|
||||
"aliasBtnWithCount": "별칭 ({{count}})",
|
||||
"aliasModalTitle": "별칭 - {{title}}",
|
||||
"aliasBack": "← 돌아가기",
|
||||
"aliasAdd": "별칭 추가",
|
||||
"aliasPlaceholder": "별칭 입력",
|
||||
"aliasRemove": "삭제",
|
||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||
"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}}개의 음악을 찾았습니다.",
|
||||
"hint": "music_quiz 데이터팩의 data/mq/function/init/songs.mcfunction 파일에 아래 코드를 그대로 덮어쓰세요.",
|
||||
"export": "코드 출력",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"exporting": "출력 중…",
|
||||
"exported": "출력 완료",
|
||||
"failed": "실패: {{message}}",
|
||||
"modalPickTitle": "음악퀴즈 선택",
|
||||
"imagesZip": "이미지.zip 출력",
|
||||
"imagesZipSizeLabel": "크기",
|
||||
"imagesZipDownloading": "이미지.zip 생성 중…",
|
||||
"imagesZipDone": "이미지.zip 다운로드 완료"
|
||||
},
|
||||
"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 가 너무 많습니다."
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
@@ -9,7 +9,9 @@
|
||||
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||
"installer": "tsc -p tsconfig.installer.json && electron .",
|
||||
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
|
||||
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
||||
"preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5",
|
||||
"dist:win": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml",
|
||||
"dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
|
||||
@@ -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) || '',
|
||||
@@ -83,20 +99,28 @@
|
||||
li.dataset.index = String(idx)
|
||||
// 기본 상태에서는 contenteditable 을 켜지 않는다. 더블클릭 시에만 편집 모드 ON.
|
||||
// 이렇게 해야 어디를 눌러도 드래그가 시작될 수 있다.
|
||||
var aliasCount = Array.isArray(entry.aliases) ? entry.aliases.length : 0
|
||||
var aliasLabel = aliasCount > 0
|
||||
? tt('aliasBtnWithCount', { count: aliasCount })
|
||||
: tt('aliasBtn')
|
||||
li.innerHTML =
|
||||
'<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>' +
|
||||
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
|
||||
escapeHtml(aliasLabel) +
|
||||
'</button>' +
|
||||
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||
attachDraggable(li, 'music', idx)
|
||||
attachInlineEdit(li, idx)
|
||||
attachAliasBtn(li, idx)
|
||||
ol.appendChild(li)
|
||||
})
|
||||
}
|
||||
@@ -116,7 +140,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)
|
||||
@@ -324,13 +348,26 @@
|
||||
if (e.target === m) closeAllModals()
|
||||
})
|
||||
})
|
||||
// ESC 로 열린 모달 닫기. 별칭 모달은 "돌아가기" 와 같은 저장 후 닫기 의미.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') return
|
||||
var aliasOpen = aliasModal && !aliasModal.hidden
|
||||
var anyOpen = document.querySelector('.modalOverlay:not([hidden])')
|
||||
if (!anyOpen) return
|
||||
e.preventDefault()
|
||||
if (aliasOpen) {
|
||||
closeAliasModalSaving()
|
||||
return
|
||||
}
|
||||
closeAllModals()
|
||||
})
|
||||
|
||||
document.getElementById('edit-music-save').addEventListener('click', function () {
|
||||
var url = document.getElementById('edit-music-url').value.trim()
|
||||
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 +376,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 +397,7 @@
|
||||
closeAllModals()
|
||||
renderMusic()
|
||||
}).catch(function (err) {
|
||||
setStatus('edit-music-status', '실패: ' + err.message, true)
|
||||
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -386,20 +423,123 @@
|
||||
renderImage()
|
||||
})
|
||||
|
||||
// ── 별칭 모달 ─────────────────────────────────────
|
||||
// 음악 행의 "별칭" 버튼을 누르면 열린다. 헤더의 "← 돌아가기" 버튼 (또는 닫기 동작)이
|
||||
// 호출되면 현재 인풋박스들에 입력된 값을 정규화해 state.music[idx].aliases 에 저장.
|
||||
var aliasModal = document.getElementById('aliasModal')
|
||||
var aliasRowsHost = document.getElementById('alias-rows')
|
||||
var aliasModalTitleEl = document.getElementById('alias-modal-title')
|
||||
var aliasBackBtn = document.getElementById('alias-back')
|
||||
var aliasAddBtn = document.getElementById('alias-add')
|
||||
var aliasEditingIdx = -1
|
||||
|
||||
function attachAliasBtn(li, idx) {
|
||||
var btn = li.querySelector('[data-alias-open]')
|
||||
if (!btn) return
|
||||
// 버튼에서 시작하는 mousedown 은 행 드래그로 전파되지 않도록 차단.
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation()
|
||||
openAliasModal(idx)
|
||||
})
|
||||
}
|
||||
|
||||
function openAliasModal(idx) {
|
||||
if (!state.music[idx]) return
|
||||
aliasEditingIdx = idx
|
||||
var entry = state.music[idx]
|
||||
aliasModalTitleEl.textContent = tt('aliasModalTitle', { title: entry.title || tt('titleFallback') })
|
||||
aliasRowsHost.innerHTML = ''
|
||||
var existing = Array.isArray(entry.aliases) ? entry.aliases : []
|
||||
if (existing.length === 0) {
|
||||
// 빈 상태에서도 입력 시작을 쉽게 하려고 첫 줄 하나는 미리 만들어 둔다.
|
||||
appendAliasRow('')
|
||||
} else {
|
||||
existing.forEach(function (a) { appendAliasRow(a) })
|
||||
}
|
||||
aliasModal.hidden = false
|
||||
}
|
||||
|
||||
function appendAliasRow(value) {
|
||||
var row = document.createElement('div')
|
||||
row.className = 'aliasRow'
|
||||
var input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
input.className = 'textInput aliasInput'
|
||||
input.placeholder = tt('aliasPlaceholder')
|
||||
input.value = value || ''
|
||||
var removeBtn = document.createElement('button')
|
||||
removeBtn.type = 'button'
|
||||
removeBtn.className = 'aliasRowRemove'
|
||||
removeBtn.title = tt('aliasRemove')
|
||||
removeBtn.textContent = '−'
|
||||
removeBtn.addEventListener('click', function () { row.remove() })
|
||||
row.appendChild(input)
|
||||
row.appendChild(removeBtn)
|
||||
aliasRowsHost.appendChild(row)
|
||||
return input
|
||||
}
|
||||
|
||||
function readAliasInputs() {
|
||||
var seen = Object.create(null)
|
||||
var out = []
|
||||
var inputs = aliasRowsHost.querySelectorAll('.aliasInput')
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
var v = (inputs[i].value || '').trim()
|
||||
if (!v) continue
|
||||
if (seen[v]) continue
|
||||
seen[v] = true
|
||||
out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function closeAliasModalSaving() {
|
||||
if (aliasEditingIdx < 0 || !state.music[aliasEditingIdx]) {
|
||||
aliasModal.hidden = true
|
||||
aliasEditingIdx = -1
|
||||
return
|
||||
}
|
||||
var nextAliases = readAliasInputs()
|
||||
var prev = state.music[aliasEditingIdx].aliases || []
|
||||
var changed = prev.length !== nextAliases.length
|
||||
if (!changed) {
|
||||
for (var i = 0; i < prev.length; i++) {
|
||||
if (prev[i] !== nextAliases[i]) { changed = true; break }
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
state.music[aliasEditingIdx].aliases = nextAliases
|
||||
markDirty()
|
||||
renderMusic()
|
||||
}
|
||||
aliasModal.hidden = true
|
||||
aliasEditingIdx = -1
|
||||
}
|
||||
|
||||
aliasAddBtn.addEventListener('click', function () {
|
||||
var input = appendAliasRow('')
|
||||
input.focus()
|
||||
})
|
||||
aliasBackBtn.addEventListener('click', closeAliasModalSaving)
|
||||
// 모달 바깥 클릭으로 닫혀도 입력값은 보존(저장)되도록 처리.
|
||||
aliasModal.addEventListener('click', function (e) {
|
||||
if (e.target === aliasModal) closeAliasModalSaving()
|
||||
})
|
||||
|
||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||
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 +571,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 +598,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 +606,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 +617,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 +631,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 +645,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 +669,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
|
||||
})
|
||||
|
||||
@@ -407,12 +407,42 @@ body.siteBody.centerLayout {
|
||||
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.trackRow {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 80px 1fr auto;
|
||||
grid-template-columns: 36px 80px 1fr auto auto;
|
||||
gap: 12px; align-items: center;
|
||||
padding: 8px 12px; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.aliasBtn {
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aliasBtn:hover { border-color: var(--accent); }
|
||||
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* 별칭 모달 */
|
||||
.aliasModalHeader {
|
||||
display: grid !important;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.aliasModalHeader h3 { text-align: center; }
|
||||
.aliasModalHeader .ghostLink {
|
||||
background: transparent; border: none; color: var(--accent); cursor: pointer;
|
||||
font-size: 13px; padding: 4px 8px;
|
||||
}
|
||||
.aliasModalHeader .ghostLink:hover { text-decoration: underline; }
|
||||
.aliasRowList { display: flex; flex-direction: column; gap: 8px; }
|
||||
.aliasRow { display: flex; gap: 8px; align-items: center; }
|
||||
.aliasRow .aliasInput { flex: 1; }
|
||||
.aliasRowRemove {
|
||||
background: var(--bg-card); border: 1px solid var(--border); color: var(--danger);
|
||||
width: 32px; height: 32px; border-radius: 6px; cursor: pointer;
|
||||
font-size: 16px; line-height: 1; flex-shrink: 0;
|
||||
}
|
||||
.aliasRowRemove:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
|
||||
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
|
||||
.rowMeta { min-width: 0; }
|
||||
|
||||
@@ -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
|
||||
@@ -54,9 +58,9 @@ function pickMusicConcurrency(): number {
|
||||
* - 동시 N개를 모두 t=0 에 시작하면 카드들이 0% 에서 같이 정지된 듯 보임.
|
||||
* - 시차를 두고 시작하면 "1번 끝남 → 4번 시작 → 2번 끝남 → 5번 시작" 식으로
|
||||
* 유저 입장에서 항상 뭔가 새로 시작/완료되는 흐름이 보임.
|
||||
* - 너무 길면 동시성 이득을 깎아먹음. 2.5s 가 체감/속도 균형점.
|
||||
* - 너무 길면 동시성 이득을 깎아먹음. 2s 가 체감/속도 균형점.
|
||||
*/
|
||||
const MUSIC_START_STAGGER_MS = 2500
|
||||
const MUSIC_START_STAGGER_MS = 2000
|
||||
|
||||
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
|
||||
let musicStartChain: Promise<void> = Promise.resolve()
|
||||
@@ -94,9 +98,12 @@ function deriveBaseUrl(manifestUrl: string): string {
|
||||
}
|
||||
|
||||
function createMainWindow(): void {
|
||||
// 메인 설치기와 동일한 아이콘 사용. dev/prod, Windows/기타 분기까지 같은 규칙.
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 680,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -154,7 +161,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 +176,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 +188,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 +209,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 +250,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 +269,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 +285,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 +309,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 +332,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 +353,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 +367,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 +386,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 +400,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 +411,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 +419,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 })))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,10 @@ import { promises as fs, createWriteStream } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import archiver from 'archiver'
|
||||
import extract from 'extract-zip'
|
||||
import { resolveResourcePackFormat } from './packFormat.js'
|
||||
import { resolveResourcePackFormat, MIN_SUPPORTED_FORMAT, LATEST_KNOWN_FORMAT } 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,18 +60,52 @@ 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}`,
|
||||
|
||||
// 호환 범위는 1.21.6 (=MIN_SUPPORTED_FORMAT) 부터 알려진 최신까지 선언한다.
|
||||
// 빌드 타깃이 LATEST_KNOWN_FORMAT 보다 높으면(테이블 갱신 전 신버전) 그 값까지 확장.
|
||||
// (셰이더 제거 판정에도 maxFmt 를 쓰므로 mcmeta 작성보다 먼저 계산해 둔다.)
|
||||
const minFmt = Math.min(MIN_SUPPORTED_FORMAT, resolved.format)
|
||||
const maxFmt = Math.max(LATEST_KNOWN_FORMAT, resolved.format)
|
||||
|
||||
// 1-a) 선언 호환 범위의 max 가 64 를 넘으면(=1.21.9+ 클라이언트에서도 로드 가능)
|
||||
// 구버전 베이스팩의 assets/minecraft/shaders/* 가 새 GLSL API 와 충돌해 컴파일에
|
||||
// 실패한다. 결과적으로 "리소스 새로고침 실패" 가 다시 뜨므로, 이 경우엔 해당
|
||||
// 디렉터리를 결과 zip 에서 제거한다. 텍스처/모델 등 나머지 자산은 그대로 유지.
|
||||
if (opts.baseZipPath && maxFmt > 64) {
|
||||
const vanillaShaderDir = path.join(root, 'assets', 'minecraft', 'shaders')
|
||||
try {
|
||||
const stat = await fs.stat(vanillaShaderDir)
|
||||
if (stat.isDirectory()) {
|
||||
const entries = await fs.readdir(vanillaShaderDir)
|
||||
if (entries.length > 0) {
|
||||
await fs.rm(vanillaShaderDir, { recursive: true, force: true })
|
||||
opts.log?.(t('log.baseShaderOverrideStripped', {
|
||||
path: entries.join(', '),
|
||||
mc: opts.mcVersion,
|
||||
format: maxFmt
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 없으면 정상. 무시.
|
||||
}
|
||||
}
|
||||
// pack_format <= 64 인 MC 는 supported_formats 를, > 64 인 MC 는 min_format/max_format 을
|
||||
// 읽는다. 어느 한쪽만 두면 반대편 클라이언트에서 거부되므로 양쪽 모두 기록한다.
|
||||
const packMeta: Record<string, unknown> = {
|
||||
description: t('pack.description', { name: opts.packName }),
|
||||
pack_format: resolved.format,
|
||||
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
|
||||
}
|
||||
supported_formats: { min_inclusive: minFmt, max_inclusive: maxFmt },
|
||||
min_format: minFmt,
|
||||
max_format: maxFmt
|
||||
}
|
||||
const mcmeta = { pack: packMeta }
|
||||
await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n')
|
||||
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
|
||||
|
||||
// 2) 음악 파일 복사 + sounds.json 생성/병합
|
||||
const musicFiles = (await fs.readdir(opts.musicDir))
|
||||
@@ -82,7 +119,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 {
|
||||
// 없으면 새로 생성.
|
||||
|
||||
@@ -24,6 +24,12 @@ const TABLE: Array<readonly [string, number]> = [
|
||||
/** 테이블에서 마지막(=최신) 항목의 포맷. 알 수 없는 mcVersion 에 대한 폴백. */
|
||||
export const LATEST_KNOWN_FORMAT: number = TABLE[TABLE.length - 1][1]
|
||||
|
||||
/**
|
||||
* 리소스팩이 호환된다고 선언할 최소 pack_format.
|
||||
* 1.21.6 (=63) 부터를 지원 범위 하한으로 둔다.
|
||||
*/
|
||||
export const MIN_SUPPORTED_FORMAT = 63
|
||||
|
||||
export interface ResolvedFormat {
|
||||
/** 매칭된 mcVersion 키 (없으면 null). */
|
||||
matched: string | null
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ServerInstallPayload {
|
||||
export interface ClientInstallPayload {
|
||||
packKey: string
|
||||
installPlatform: boolean
|
||||
/** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */
|
||||
skipMap?: boolean
|
||||
}
|
||||
|
||||
export interface RamCheckResult {
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
45
src/server/datapack.ts
Normal file
45
src/server/datapack.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { MusicListEntry, PackList } from '../shared/types.js'
|
||||
|
||||
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */
|
||||
function escapeSnbtString(input: string): string {
|
||||
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
|
||||
function aliasListSnbt(aliases: string[]): string {
|
||||
if (!Array.isArray(aliases) || aliases.length === 0) return '[]'
|
||||
const parts = aliases.map((a) => `"${escapeSnbtString(a)}"`)
|
||||
return `[${parts.join(',')}]`
|
||||
}
|
||||
|
||||
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */
|
||||
function entrySnbt(entry: MusicListEntry): string {
|
||||
const title = escapeSnbtString(entry.title ?? '')
|
||||
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
|
||||
const author = escapeSnbtString(entry.artist ?? '')
|
||||
const alias = aliasListSnbt(entry.aliases ?? [])
|
||||
return `{title:"${title}", author:"${author}", alias:${alias}}`
|
||||
}
|
||||
|
||||
/**
|
||||
* list.music 으로부터 `data/mq/function/init/songs.mcfunction` 본문을 생성.
|
||||
* 운영자는 mc_datapack 의 music_quiz 데이터팩에서 이 파일만 이 내용으로
|
||||
* 덮어쓰면 된다 — 나머지 파일은 launcher 가 관여하지 않는다.
|
||||
*/
|
||||
export function buildSongsMcfunction(list: PackList): string {
|
||||
const lines: string[] = []
|
||||
lines.push('# 곡 한 개 = 한 줄.')
|
||||
lines.push('# 필수 — title, author, alias')
|
||||
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
|
||||
lines.push('# 의 audio.volume 사용)')
|
||||
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
|
||||
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}')
|
||||
lines.push('data modify storage mq:main songs set value []')
|
||||
for (const entry of list.music) {
|
||||
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('# 곡 개수는 songs 배열 길이에서 자동 계산됨')
|
||||
lines.push('execute store result storage mq:main max_index int 1 run data get storage mq:main songs')
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
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
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import archiver from 'archiver'
|
||||
import {
|
||||
createPack,
|
||||
deletePackKeys,
|
||||
@@ -16,6 +17,8 @@ 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'
|
||||
import { buildSongsMcfunction } from '../datapack.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
@@ -46,7 +49,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 +109,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 +145,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 +166,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 +182,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 +206,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 {
|
||||
@@ -222,37 +225,71 @@ opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
||||
opRouter.get('/op/datapack', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const keys = await listPackKeys()
|
||||
const items = await Promise.all(keys.map(async (key) => ({
|
||||
key,
|
||||
definition: await loadPackDefinition(key)
|
||||
})))
|
||||
const items = await Promise.all(keys.map(async (key) => {
|
||||
const definition = await loadPackDefinition(key)
|
||||
const list = await loadPackList(key)
|
||||
return { key, definition, musicCount: list.music.length }
|
||||
}))
|
||||
res.render('op/datapack', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 데이터팩 출력: 임시 포맷의 mcfunction 텍스트를 반환.
|
||||
// 데이터팩 출력: list.music 으로부터 init/songs.mcfunction 본문만 만들어
|
||||
// text/plain 으로 반환한다. 운영자가 mc_datapack 의 해당 파일에 붙여넣는다.
|
||||
opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
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. 실제 포맷 확정되면 교체 예정.`)
|
||||
list.music.forEach((entry, index) => {
|
||||
const title = entry.title || '(제목 없음)'
|
||||
const artist = entry.artist || '(가수 미상)'
|
||||
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
|
||||
})
|
||||
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
||||
res.type('text/plain; charset=utf-8').send(buildSongsMcfunction(list))
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// painting_variant JSON 들을 zip 으로 묶어 내려준다.
|
||||
// query.size 로 width/height (블록 단위, 기본 4, 1~16) 지정. 음악 개수만큼 cover_NN.json 생성.
|
||||
opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
|
||||
return
|
||||
}
|
||||
const sizeRaw = Number(pickFirstValue(req.query.size))
|
||||
const size = Number.isFinite(sizeRaw) && sizeRaw >= 1 && sizeRaw <= 16
|
||||
? Math.floor(sizeRaw)
|
||||
: 4
|
||||
const list = await loadPackList(packKey)
|
||||
const total = list.music.length
|
||||
|
||||
res.setHeader('Content-Type', 'application/zip')
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${packKey}-painting-variants.zip"`
|
||||
)
|
||||
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||
archive.on('error', (err) => next(err))
|
||||
archive.pipe(res)
|
||||
for (let i = 1; i <= total; i++) {
|
||||
const nn = String(i).padStart(2, '0')
|
||||
const json = {
|
||||
asset_id: `musicquiz:cover_${nn}`,
|
||||
width: size,
|
||||
height: size,
|
||||
title: { text: `Cover ${nn}` },
|
||||
author: { text: 'music quiz' }
|
||||
}
|
||||
archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` })
|
||||
}
|
||||
await archive.finalize()
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
@@ -287,7 +324,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)
|
||||
|
||||
@@ -4,30 +4,42 @@ import dotenv from 'dotenv'
|
||||
import { projectRoot } from './paths.js'
|
||||
|
||||
/**
|
||||
* `.env` 를 읽어 `process.env` 에 주입.
|
||||
* `.env` / `.env.build` 를 읽어 `process.env` 에 주입.
|
||||
*
|
||||
* 탐색 순서(처음 발견된 것만 사용):
|
||||
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/.env`
|
||||
* — electron-builder 의 extraResources 로 빌드 시점 `.env` 가 함께 배포됨.
|
||||
* 2. `<프로젝트 루트>/.env`
|
||||
* — 개발 실행(npm start / npm run installer*) 및 서버 운영용.
|
||||
* 여러 파일을 순서대로 읽되 `override:false` 로 병합하므로 **먼저 로드된 값이
|
||||
* 우선**. 두 도메인(패키지 빌드용 vs 개발/서버용) 이 한 함수에서 자연스럽게
|
||||
* 분리됨:
|
||||
*
|
||||
* - 이미 설정된 환경변수는 덮어쓰지 않음(쉘/systemd 에서 넘긴 값이 우선).
|
||||
* - 파일이 없으면 조용히 통과.
|
||||
* 1. 패키징된 Electron 앱: `process.resourcesPath/.env.build`
|
||||
* — electron-builder 가 빌드 시점 `.env.build` 를 함께 배포. 패키지된 exe
|
||||
* 에서 가장 먼저 적용되는 값.
|
||||
* 2. 패키징된 Electron 앱: `process.resourcesPath/.env`
|
||||
* — 운영자가 패키징 후 직접 `.env` 를 옆에 두고 덮어쓰는 경우 폴백.
|
||||
* 3. `<프로젝트 루트>/.env`
|
||||
* — 개발 실행(npm start / npm run installer*) 및 서버 운영용. 서버의
|
||||
* `PORT/HOST/SESSION_SECRET` 처럼 dev 에서 반드시 살아 있어야 하는 값들이
|
||||
* 있어, `.env.build` 보다 먼저 로드해 우선권을 줌.
|
||||
* 4. `<프로젝트 루트>/.env.build`
|
||||
* — dev 환경에서 빌드용 값(예: 운영 도메인 SITE_BASE_URL)을 테스트하고
|
||||
* 싶을 때 사용. `.env` 에 없는 키만 채움.
|
||||
*
|
||||
* - 이미 설정된 환경변수는 덮어쓰지 않음(쉘/systemd 에서 넘긴 값이 최우선).
|
||||
* - 존재하지 않는 후보는 조용히 건너뜀.
|
||||
* - 서버/설치기/리소스팩설치기 진입점에서 한 번씩 호출.
|
||||
*/
|
||||
export function loadEnv(): void {
|
||||
const candidates: string[] = []
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
|
||||
candidates.push(path.join(resourcesPath, '.env.build'))
|
||||
candidates.push(path.join(resourcesPath, '.env'))
|
||||
}
|
||||
candidates.push(path.join(projectRoot, '.env'))
|
||||
candidates.push(path.join(projectRoot, '.env.build'))
|
||||
|
||||
for (const envPath of candidates) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath, override: false, quiet: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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] ?? '')
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||
export const fileDirPath = path.join(projectRoot, 'file')
|
||||
export const fileListDirPath = path.join(fileDirPath, 'list')
|
||||
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
|
||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||
export const publicDirPath = path.join(projectRoot, 'public')
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
||||
: fallback.mcVersion,
|
||||
platform: {
|
||||
type: platformType,
|
||||
// fabric 은 downloadUrl 을 쓰지 않고 loaderVersion 기반으로 자동 설치한다.
|
||||
downloadUrl: platformType !== 'fabric'
|
||||
// vanilla 외에는 fabric/forge/neoforge 모두 downloadUrl 을 보관한다.
|
||||
downloadUrl: platformType !== 'vanilla'
|
||||
&& typeof platform.downloadUrl === 'string'
|
||||
&& platform.downloadUrl.trim().length > 0
|
||||
? platform.downloadUrl.trim()
|
||||
@@ -229,6 +229,21 @@ function sanitizeNumber(value: unknown): number {
|
||||
return Math.floor(n)
|
||||
}
|
||||
|
||||
/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */
|
||||
function sanitizeAliases(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of value) {
|
||||
const s = sanitizeStr(item)
|
||||
if (!s) continue
|
||||
if (seen.has(s)) continue
|
||||
seen.add(s)
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function normalizePackList(input: unknown): PackList {
|
||||
const fallback = defaultPackList()
|
||||
if (!input || typeof input !== 'object') return fallback
|
||||
@@ -244,7 +259,8 @@ export function normalizePackList(input: unknown): PackList {
|
||||
url: sanitizeStr(entry.url),
|
||||
title: sanitizeStr(entry.title),
|
||||
artist: sanitizeStr(entry.artist),
|
||||
durationSec: sanitizeNumber(entry.durationSec)
|
||||
durationSec: sanitizeNumber(entry.durationSec),
|
||||
aliases: sanitizeAliases(entry.aliases)
|
||||
}))
|
||||
.filter((entry) => entry.url.length > 0),
|
||||
images: images
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface MusicListEntry {
|
||||
artist: string
|
||||
/** 노래 길이 (초). */
|
||||
durationSec: number
|
||||
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
export interface ImageListEntry {
|
||||
|
||||
@@ -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,26 @@
|
||||
<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>
|
||||
|
||||
<p class="muted"><%= t('datapack.hint') %></p>
|
||||
|
||||
<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="imagesZipBtn"><%= t('datapack.imagesZip') %></button>
|
||||
<label class="muted" for="imagesZipSize" style="margin-left:4px;"><%= t('datapack.imagesZipSizeLabel') %></label>
|
||||
<input type="number" id="imagesZipSize" value="4" min="1" max="16" style="width:60px;" />
|
||||
<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,19 +41,22 @@
|
||||
<!-- 음악퀴즈 선택 팝업 -->
|
||||
<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">
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard pickable" data-key="<%= item.key %>" data-name="<%= item.definition ? item.definition.name : item.key %>">
|
||||
<article class="packCard pickable"
|
||||
data-key="<%= item.key %>"
|
||||
data-name="<%= item.definition ? item.definition.name : item.key %>"
|
||||
data-music-count="<%= item.musicCount %>">
|
||||
<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><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</article>
|
||||
@@ -58,6 +66,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var pickModal = document.getElementById('pickModal')
|
||||
@@ -71,47 +82,67 @@
|
||||
pickModal.addEventListener('click', function (e) {
|
||||
if (e.target === pickModal) pickModal.hidden = true
|
||||
})
|
||||
// ESC 로 닫기.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !pickModal.hidden) {
|
||||
pickModal.hidden = true
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
document.querySelectorAll('#pickList .pickable').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
pickedKey = card.getAttribute('data-key')
|
||||
var name = card.getAttribute('data-name')
|
||||
document.getElementById('pickedLabel').textContent = '선택: ' + name
|
||||
var count = card.getAttribute('data-music-count') || '0'
|
||||
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
|
||||
document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', count)
|
||||
pickModal.hidden = true
|
||||
document.getElementById('dpActions').hidden = false
|
||||
// 곡 수 미리 가져오기
|
||||
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
|
||||
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
|
||||
document.getElementById('countLabel').textContent = ''
|
||||
document.getElementById('dp-status').textContent = ''
|
||||
document.getElementById('dp-status').classList.remove('error')
|
||||
document.getElementById('codeOut').hidden = true
|
||||
document.getElementById('codeOut').textContent = ''
|
||||
})
|
||||
})
|
||||
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 = '출력 완료'
|
||||
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('imagesZipBtn').addEventListener('click', function () {
|
||||
if (!pickedKey) return
|
||||
var sizeInput = document.getElementById('imagesZipSize')
|
||||
var size = parseInt(sizeInput.value, 10)
|
||||
if (!isFinite(size) || size < 1) size = 4
|
||||
if (size > 16) size = 16
|
||||
sizeInput.value = String(size)
|
||||
var s = document.getElementById('dp-status')
|
||||
s.textContent = I18N.imagesZipDownloading; s.classList.remove('error')
|
||||
// 브라우저 기본 다운로드로 위임. 인증 쿠키는 자동으로 따라간다.
|
||||
var url = '/op/datapack/' + encodeURIComponent(pickedKey) + '/images-zip?size=' + size
|
||||
window.location.href = url
|
||||
// 다운로드 시작은 비동기지만, 사용자에게 즉시 피드백.
|
||||
setTimeout(function () { s.textContent = I18N.imagesZipDone }, 500)
|
||||
})
|
||||
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,70 +40,83 @@
|
||||
</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>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformDownloadField">
|
||||
<span>플랫폼 설치파일 URL</span>
|
||||
<label class="fullSpan" id="platformDownloadField"<%= pack.platform.type === 'vanilla' ? ' hidden' : '' %>>
|
||||
<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>
|
||||
<label class="fullSpan" id="platformLoaderField"<%= pack.platform.type === 'fabric' ? '' : ' hidden' %>>
|
||||
<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')
|
||||
@@ -118,9 +131,8 @@
|
||||
function syncPlatformVisibility() {
|
||||
var type = platformSelect.value
|
||||
if (type === 'fabric') {
|
||||
downloadField.removeAttribute('hidden')
|
||||
loaderField.removeAttribute('hidden')
|
||||
downloadField.setAttribute('hidden', '')
|
||||
downloadField.querySelector('input').value = ''
|
||||
loadFabricLoaders()
|
||||
} else if (type === 'vanilla') {
|
||||
downloadField.setAttribute('hidden', '')
|
||||
@@ -136,7 +148,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 +168,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 +176,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 +193,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 +210,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,43 +87,61 @@
|
||||
<!-- 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>
|
||||
|
||||
<!-- Alias modal (music) -->
|
||||
<div class="modalOverlay" id="aliasModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header class="aliasModalHeader">
|
||||
<button type="button" class="ghostLink" id="alias-back"><%= t('listEditor.aliasBack') %></button>
|
||||
<h3 id="alias-modal-title"></h3>
|
||||
<span></span>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.aliasHint') %></p>
|
||||
<div id="alias-rows" class="aliasRowList"></div>
|
||||
<div>
|
||||
<button type="button" class="secondaryButton" id="alias-add"><%= t('listEditor.aliasAdd') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 +149,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