Compare commits
46 Commits
6cd402121b
...
v0.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 05dc9d7166 | |||
| 25977d894b | |||
| c14b0507c7 | |||
| ffb2048627 | |||
| bc3841147f | |||
| 40986bee11 | |||
| bf225f51e1 | |||
| 2371af4411 | |||
| 1f59f6a98b | |||
| 794ad9b778 | |||
| f810719d92 | |||
| 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 |
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,28 +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.
@@ -124,7 +124,7 @@ function renderStep1() {
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.selectedKey) return
|
||||
api.selectPack(state.selectedKey).then(function () {
|
||||
renderStep2()
|
||||
renderAgreement()
|
||||
}).catch(function (err) {
|
||||
alert(err.message || tt('common.selectFailed'))
|
||||
})
|
||||
@@ -140,6 +140,170 @@ function renderStep1() {
|
||||
})
|
||||
}
|
||||
|
||||
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
||||
// rp 인스톨러는 리소스팩·설치기 두 약관만 확인·동의하면 된다.
|
||||
function renderAgreement() {
|
||||
setActiveStep(1)
|
||||
clearPage()
|
||||
var KINDS = [
|
||||
{ id: 'resourcepack', tab: tt('agreement.tabResourcepack') },
|
||||
{ id: 'installer-rp', tab: tt('agreement.tabInstaller') }
|
||||
]
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + escapeHtml(tt('agreement.intro')) + '</p>' +
|
||||
'<div class="tabBar" id="agTabs">' +
|
||||
KINDS.map(function (k, i) {
|
||||
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + escapeHtml(k.tab) + '</button>'
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'<div class="agreementBody" id="agBody">' + escapeHtml(tt('agreement.loading')) + '</div>' +
|
||||
'<label class="toggleRow" style="margin-top:12px;"><input type="checkbox" id="agAccept" /> ' +
|
||||
escapeHtml(tt('agreement.agreeAll')) + '</label>' +
|
||||
'<div class="formMessage" id="agMsg"></div>' +
|
||||
'<div class="actionRow">' +
|
||||
' <button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
|
||||
' <button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
|
||||
var body = section.querySelector('#agBody')
|
||||
var tabs = section.querySelectorAll('[data-ag]')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var accept = section.querySelector('#agAccept')
|
||||
var msg = section.querySelector('#agMsg')
|
||||
|
||||
// 본문 캐시. 탭 전환 시 재요청하지 않음.
|
||||
var cache = {}
|
||||
|
||||
function showKind(kind) {
|
||||
if (cache[kind]) { body.innerHTML = cache[kind]; return }
|
||||
body.textContent = tt('agreement.loading')
|
||||
api.getTerm(kind).then(function (res) {
|
||||
if (!res.ok) {
|
||||
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: res.message || '' })) + '</p>'
|
||||
return
|
||||
}
|
||||
var html = renderTermsMarkdown(res.content || '')
|
||||
cache[kind] = html
|
||||
body.innerHTML = html
|
||||
}).catch(function (err) {
|
||||
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: err.message })) + '</p>'
|
||||
})
|
||||
}
|
||||
|
||||
tabs.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
tabs.forEach(function (x) { x.classList.remove('active') })
|
||||
b.classList.add('active')
|
||||
showKind(b.getAttribute('data-ag'))
|
||||
})
|
||||
})
|
||||
|
||||
accept.addEventListener('change', function () {
|
||||
nextBtn.disabled = !accept.checked
|
||||
if (accept.checked) msg.textContent = ''
|
||||
})
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!accept.checked) {
|
||||
msg.textContent = tt('agreement.agreeRequired')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
renderStep2()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
|
||||
showKind(KINDS[0].id)
|
||||
}
|
||||
|
||||
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 같은 규칙을 처리한다.
|
||||
function renderTermsMarkdown(src) {
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function inline(s) {
|
||||
s = escHtml(s)
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
|
||||
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
var lines = src.replace(/\r\n/g, '\n').split('\n')
|
||||
var out = []
|
||||
var i = 0
|
||||
var stack = null
|
||||
function closeList() { if (stack) { out.push('</' + stack + '>'); stack = null } }
|
||||
while (i < lines.length) {
|
||||
var line = lines[i]
|
||||
var fence = /^```(\w*)\s*$/.exec(line)
|
||||
if (fence) {
|
||||
closeList()
|
||||
var code = []; i += 1
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
|
||||
continue
|
||||
}
|
||||
var togStart = /^:::toggle\s+(.+)$/.exec(line)
|
||||
if (togStart) {
|
||||
closeList()
|
||||
var summary = togStart[1]; var body2 = []; i += 1
|
||||
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<details><summary>' + inline(summary) + '</summary>' + renderTermsMarkdown(body2.join('\n')) + '</details>')
|
||||
continue
|
||||
}
|
||||
var h = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (h) {
|
||||
closeList()
|
||||
out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>')
|
||||
i += 1; continue
|
||||
}
|
||||
if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr/>'); i += 1; continue }
|
||||
if (/^>\s?/.test(line)) {
|
||||
closeList()
|
||||
var q = []
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
|
||||
out.push('<blockquote>' + renderTermsMarkdown(q.join('\n')) + '</blockquote>')
|
||||
continue
|
||||
}
|
||||
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
|
||||
if (ol) {
|
||||
if (stack !== 'ol') { closeList(); out.push('<ol>'); stack = 'ol' }
|
||||
out.push('<li>' + inline(ol[1]) + '</li>'); i += 1; continue
|
||||
}
|
||||
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
|
||||
if (ul) {
|
||||
if (stack !== 'ul') { closeList(); out.push('<ul>'); stack = 'ul' }
|
||||
out.push('<li>' + inline(ul[1]) + '</li>'); i += 1; continue
|
||||
}
|
||||
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
|
||||
closeList()
|
||||
var para = [line]; i += 1
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i])
|
||||
&& !/^(#{1,6})\s+/.test(lines[i])
|
||||
&& !/^\s*[-*]\s+/.test(lines[i])
|
||||
&& !/^\s*\d+\.\s+/.test(lines[i])
|
||||
&& !/^>/.test(lines[i])
|
||||
&& !/^---+\s*$/.test(lines[i])
|
||||
&& !/^```/.test(lines[i])
|
||||
&& !/^:::/.test(lines[i])) {
|
||||
para.push(lines[i]); i += 1
|
||||
}
|
||||
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
|
||||
}
|
||||
closeList()
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
// ── 2단계: 설치 진행 ────────────────────────────────
|
||||
function renderStep2() {
|
||||
setActiveStep(2)
|
||||
@@ -255,9 +419,16 @@ function renderStep2() {
|
||||
}
|
||||
})
|
||||
|
||||
// 사용자가 취소를 눌렀는지 추적. 취소 흐름에서는 installFailed 알림을 띄우지 않고
|
||||
// 조용히 step1 로 돌아간다.
|
||||
var cancelInitiated = false
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
if (!state.installing) return
|
||||
if (!state.installing || cancelInitiated) return
|
||||
cancelInitiated = true
|
||||
cancelBtn.disabled = true
|
||||
cancelBtn.textContent = tt('agreement.cancelling')
|
||||
// 사용자에게 어느 단계든 즉시 "취소 중" 신호가 보이도록 패키지 섹션 상태 갱신.
|
||||
pkgSub.textContent = tt('agreement.cancelling')
|
||||
api.cancelInstall()
|
||||
})
|
||||
|
||||
@@ -273,7 +444,9 @@ function renderStep2() {
|
||||
}).catch(function (err) {
|
||||
state.installing = false
|
||||
if (stopProgress) stopProgress()
|
||||
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
||||
if (!cancelInitiated) {
|
||||
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
||||
}
|
||||
renderStep1()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ 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: '',
|
||||
@@ -129,7 +134,7 @@ function renderStep1() {
|
||||
if (!state.selectedPackKey) return
|
||||
await installerApi.setSelectedPack(state.selectedPackKey)
|
||||
state.stepDone[1] = true
|
||||
renderStep2()
|
||||
renderAgreement()
|
||||
})
|
||||
|
||||
;(async function () {
|
||||
@@ -143,6 +148,171 @@ function renderStep1() {
|
||||
})()
|
||||
}
|
||||
|
||||
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
|
||||
// 메인 설치기는 맵·모드·설치기 세 약관을 모두 확인·동의해야 다음 단계로 갈 수 있다.
|
||||
function renderAgreement() {
|
||||
setActiveStep(1)
|
||||
clearPage()
|
||||
var KINDS = [
|
||||
{ id: 'map', tab: tt('agreement.tabMap') },
|
||||
{ id: 'mod', tab: tt('agreement.tabMod') },
|
||||
{ id: 'installer', tab: tt('agreement.tabInstaller') }
|
||||
]
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + tt('agreement.heading') + '</h2>' +
|
||||
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
|
||||
'<div class="tabBar" id="agTabs">' +
|
||||
KINDS.map(function (k, i) {
|
||||
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + k.tab + '</button>'
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
|
||||
'<label class="toggleRow" style="margin-top:12px;"><input type="checkbox" id="agAccept" /> ' +
|
||||
tt('agreement.agreeAll') + '</label>' +
|
||||
'<div class="formMessage" id="agMsg"></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 body = section.querySelector('#agBody')
|
||||
var tabs = section.querySelectorAll('[data-ag]')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var accept = section.querySelector('#agAccept')
|
||||
var msg = section.querySelector('#agMsg')
|
||||
|
||||
// 약관 본문은 한 번 받으면 캐시. 탭 전환 시 재요청하지 않는다.
|
||||
var cache = {}
|
||||
|
||||
function showKind(kind) {
|
||||
if (cache[kind]) {
|
||||
body.innerHTML = cache[kind]
|
||||
return
|
||||
}
|
||||
body.textContent = tt('agreement.loading')
|
||||
installerApi.getTerm(kind).then(function (res) {
|
||||
if (!res.ok) {
|
||||
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: res.message || '' }) + '</p>'
|
||||
return
|
||||
}
|
||||
var html = renderTermsMarkdown(res.content || '')
|
||||
cache[kind] = html
|
||||
body.innerHTML = html
|
||||
}).catch(function (err) {
|
||||
body.innerHTML = '<p class="formMessage error">' + tt('agreement.loadFailed', { message: err.message }) + '</p>'
|
||||
})
|
||||
}
|
||||
|
||||
tabs.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
tabs.forEach(function (x) { x.classList.remove('active') })
|
||||
b.classList.add('active')
|
||||
showKind(b.getAttribute('data-ag'))
|
||||
})
|
||||
})
|
||||
|
||||
accept.addEventListener('change', function () {
|
||||
nextBtn.disabled = !accept.checked
|
||||
if (accept.checked) msg.textContent = ''
|
||||
})
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!accept.checked) {
|
||||
msg.textContent = tt('agreement.agreeRequired')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
renderStep2()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
|
||||
showKind(KINDS[0].id)
|
||||
}
|
||||
|
||||
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 동일한 규칙을 처리한다.
|
||||
function renderTermsMarkdown(src) {
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function inline(s) {
|
||||
s = escHtml(s)
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
|
||||
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
var lines = src.replace(/\r\n/g, '\n').split('\n')
|
||||
var out = []
|
||||
var i = 0
|
||||
var stack = null
|
||||
function closeList() { if (stack) { out.push('</' + stack + '>'); stack = null } }
|
||||
while (i < lines.length) {
|
||||
var line = lines[i]
|
||||
var fence = /^```(\w*)\s*$/.exec(line)
|
||||
if (fence) {
|
||||
closeList()
|
||||
var code = []; i += 1
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
|
||||
continue
|
||||
}
|
||||
var togStart = /^:::toggle\s+(.+)$/.exec(line)
|
||||
if (togStart) {
|
||||
closeList()
|
||||
var summary = togStart[1]; var body2 = []; i += 1
|
||||
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<details><summary>' + inline(summary) + '</summary>' + renderTermsMarkdown(body2.join('\n')) + '</details>')
|
||||
continue
|
||||
}
|
||||
var h = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (h) {
|
||||
closeList()
|
||||
out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>')
|
||||
i += 1; continue
|
||||
}
|
||||
if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr/>'); i += 1; continue }
|
||||
if (/^>\s?/.test(line)) {
|
||||
closeList()
|
||||
var q = []
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
|
||||
out.push('<blockquote>' + renderTermsMarkdown(q.join('\n')) + '</blockquote>')
|
||||
continue
|
||||
}
|
||||
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
|
||||
if (ol) {
|
||||
if (stack !== 'ol') { closeList(); out.push('<ol>'); stack = 'ol' }
|
||||
out.push('<li>' + inline(ol[1]) + '</li>'); i += 1; continue
|
||||
}
|
||||
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
|
||||
if (ul) {
|
||||
if (stack !== 'ul') { closeList(); out.push('<ul>'); stack = 'ul' }
|
||||
out.push('<li>' + inline(ul[1]) + '</li>'); i += 1; continue
|
||||
}
|
||||
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
|
||||
closeList()
|
||||
var para = [line]; i += 1
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i])
|
||||
&& !/^(#{1,6})\s+/.test(lines[i])
|
||||
&& !/^\s*[-*]\s+/.test(lines[i])
|
||||
&& !/^\s*\d+\.\s+/.test(lines[i])
|
||||
&& !/^>/.test(lines[i])
|
||||
&& !/^---+\s*$/.test(lines[i])
|
||||
&& !/^```/.test(lines[i])
|
||||
&& !/^:::/.test(lines[i])) {
|
||||
para.push(lines[i]); i += 1
|
||||
}
|
||||
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
|
||||
}
|
||||
closeList()
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
function renderStep2() {
|
||||
setActiveStep(2)
|
||||
clearPage()
|
||||
@@ -159,30 +329,80 @@ function renderStep2() {
|
||||
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)
|
||||
section.querySelector('#back').addEventListener('click', renderAgreement)
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -196,7 +416,8 @@ function renderStep3() {
|
||||
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) }
|
||||
@@ -446,20 +667,16 @@ function renderSubStep33(host, back, done) {
|
||||
}
|
||||
|
||||
// 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">' + tt('step3.eulaModal.fromFile') + '</p>' +
|
||||
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
|
||||
var fetched = await installerApi.fetchMinecraftEula()
|
||||
if (fetched.html) {
|
||||
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
|
||||
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
|
||||
} else {
|
||||
var fetched = await installerApi.fetchMinecraftEula()
|
||||
if (fetched.html) {
|
||||
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">' + tt('step3.eulaModal.loadFailed') + '</p>'
|
||||
}
|
||||
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
|
||||
}
|
||||
return new Promise(function (resolve) {
|
||||
var overlay = document.createElement('div')
|
||||
@@ -491,12 +708,6 @@ 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, '"')
|
||||
}
|
||||
@@ -535,6 +746,14 @@ function renderSubStep35(host, back, done) {
|
||||
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')
|
||||
@@ -543,16 +762,16 @@ function renderSubStep35(host, back, done) {
|
||||
try {
|
||||
var result = await installerApi.checkPortForward(port)
|
||||
state.serverInstall.portStatus = result
|
||||
var address = formatServerAddress(result.externalIp, result.port)
|
||||
if (result.status === 'preForwarded') {
|
||||
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { ip: result.externalIp, port: result.port })
|
||||
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else if (result.status === 'upnpOk') {
|
||||
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { ip: result.externalIp, port: result.port })
|
||||
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else {
|
||||
var ip = result.externalIp || tt('step3.sub35.ipUnknown')
|
||||
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
|
||||
tt('step3.sub35.manualDetail', { ip: ip, port: result.port })
|
||||
tt('step3.sub35.manualDetail', { address: address })
|
||||
resultMsg.classList.add('warn')
|
||||
}
|
||||
nextBtn.disabled = false
|
||||
@@ -581,67 +800,26 @@ function renderStep4() {
|
||||
'<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 () {
|
||||
state.stepDone[4] = true
|
||||
renderStep5()
|
||||
})
|
||||
}
|
||||
show41()
|
||||
}
|
||||
|
||||
function renderSubStep41(host, pack, back, done) {
|
||||
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
|
||||
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
if (platformType === 'vanilla') {
|
||||
state.client.installPlatform = false
|
||||
host.innerHTML =
|
||||
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.vanillaInfo') + '</p>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.vanillaNoInstall') + '</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
return
|
||||
state.client.installPlatform = platformType !== 'vanilla'
|
||||
|
||||
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
|
||||
// 멀티+참가자 는 직전 화면이 역할 선택 탭이므로 거기로, 싱글은 모드 탭으로.
|
||||
function backToPrevStep() {
|
||||
if (state.mode === 'multi' && state.role === 'host') renderStep3()
|
||||
else if (state.mode === 'multi') renderStep2Role()
|
||||
else renderStep2()
|
||||
}
|
||||
|
||||
host.innerHTML =
|
||||
'<h3>' + tt('step4.sub41.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub41.info', { platform: platformType }) + '</p>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button type="button" data-choice="install"><strong>' + tt('step4.sub41.installTitle') + '</strong><br><small>' + tt('step4.sub41.installHint', { platform: platformType }) + '</small></button>' +
|
||||
'<button type="button" data-choice="skip"><strong>' + tt('step4.sub41.skipTitle') + '</strong><br><small>' + tt('step4.sub41.skipHint') + '</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
|
||||
var nextBtn = host.querySelector('#next')
|
||||
var choiceButtons = host.querySelectorAll('[data-choice]')
|
||||
|
||||
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
|
||||
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
|
||||
function goStep5() {
|
||||
state.stepDone[4] = true
|
||||
renderStep5()
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -655,8 +833,20 @@ function renderSubStep42(host, back, done) {
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
nextBtn.addEventListener('click', done)
|
||||
|
||||
// 이미 설치됐다면 다시 돌리지 않음
|
||||
if (state.client.clientInstalled) {
|
||||
// 이번에 실제로 보내야 할 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
|
||||
@@ -666,40 +856,32 @@ function renderSubStep42(host, back, done) {
|
||||
// 페이지 진입 즉시 자동 설치
|
||||
;(async function () {
|
||||
try {
|
||||
await installerApi.installClient({
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform
|
||||
})
|
||||
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) {
|
||||
// 실패한 호출은 "마지막 성공" 기록에 남기지 않는다. 다음 진입 시 재시도.
|
||||
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>' + tt('step4.sub43.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub43.description') + '</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('step4.sub43.goStep5') + '</button></div>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
}
|
||||
|
||||
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>' + tt('step5.heading') + '</h2>' +
|
||||
'<p>' + tt('step5.summary') + '</p>' +
|
||||
(multi ? '<div class="subStep">' +
|
||||
(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>' +
|
||||
@@ -712,7 +894,7 @@ function renderStep5() {
|
||||
'<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()
|
||||
})
|
||||
@@ -722,7 +904,7 @@ function renderStep5() {
|
||||
finishBtn.disabled = true
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -155,6 +155,49 @@ main {
|
||||
|
||||
.toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
|
||||
|
||||
/* 약관 동의 페이지 — 탭 + 약관 본문 박스. */
|
||||
.tabBar { display: flex; gap: 6px; margin: 12px 0 0; flex-wrap: wrap; }
|
||||
.tabBtn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tabBtn.active {
|
||||
background: var(--bg-card);
|
||||
border-bottom-color: var(--bg-card);
|
||||
color: var(--accent, #6cf);
|
||||
font-weight: 600;
|
||||
}
|
||||
.agreementBody {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px 18px;
|
||||
border-radius: 0 10px 10px 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.agreementBody h1, .agreementBody h2, .agreementBody h3 { margin: 12px 0 6px; }
|
||||
.agreementBody h1 { font-size: 17px; }
|
||||
.agreementBody h2 { font-size: 15px; }
|
||||
.agreementBody h3 { font-size: 14px; }
|
||||
.agreementBody p { margin: 6px 0; }
|
||||
.agreementBody ul, .agreementBody ol { margin: 6px 0; padding-left: 22px; }
|
||||
.agreementBody li { margin: 2px 0; }
|
||||
.agreementBody code { background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; font-family: 'Consolas', monospace; }
|
||||
.agreementBody pre { background: rgba(0,0,0,0.3); padding: 8px 10px; border-radius: 6px; overflow-x: auto; }
|
||||
.agreementBody pre code { background: none; padding: 0; }
|
||||
.agreementBody blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid var(--border); color: #aab; }
|
||||
.agreementBody details { margin: 6px 0; }
|
||||
.agreementBody details > summary { cursor: pointer; padding: 4px 0; }
|
||||
.agreementBody hr { border: none; border-top: 1px solid var(--border); margin: 10px 0; }
|
||||
.agreementBody a { color: var(--accent, #6cf); }
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"common": {
|
||||
"next": "다음",
|
||||
"back": "이전",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"openFolder": "리소스팩 폴더 열기",
|
||||
@@ -25,13 +26,24 @@
|
||||
"mcVersionLabel": "마인크래프트 {{version}} · ",
|
||||
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
|
||||
"requestTimeout": "요청 시간 초과",
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||
"tooManyRedirects": "너무 많은 요청."
|
||||
},
|
||||
"step1": {
|
||||
"heading": "1단계. 음악퀴즈 선택"
|
||||
"heading": "음악퀴즈 선택"
|
||||
},
|
||||
"agreement": {
|
||||
"heading": "약관 동의",
|
||||
"intro": "리소스팩을 설치하기 전에 아래 약관을 모두 확인하고 동의해 주세요.",
|
||||
"tabResourcepack": "리소스팩 약관",
|
||||
"tabInstaller": "리소스팩 설치기 약관",
|
||||
"loading": "약관을 불러오는 중...",
|
||||
"loadFailed": "약관 로드 실패: {{message}}",
|
||||
"agreeAll": "위 모든 약관(리소스팩·설치기)에 동의합니다.",
|
||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
|
||||
"cancelling": "취소 중…"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "2단계. 리소스팩 설치",
|
||||
"heading": "리소스팩 설치",
|
||||
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
|
||||
"chipYtdlp": "yt-dlp 준비",
|
||||
"chipFfmpeg": "ffmpeg 준비",
|
||||
@@ -48,7 +60,7 @@
|
||||
"cardError": "실패"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "3단계. 완료",
|
||||
"heading": "완료",
|
||||
"message": "리소스팩 설치를 완료했습니다."
|
||||
},
|
||||
"log": {
|
||||
@@ -75,6 +87,7 @@
|
||||
"baseUrl": " URL: {{url}}",
|
||||
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
||||
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
|
||||
"baseRemoved": "베이스 리소스팩 삭제: {{path}}",
|
||||
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
|
||||
"installComplete": "설치 완료: {{path}}",
|
||||
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",
|
||||
@@ -86,8 +99,10 @@
|
||||
"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}}"
|
||||
},
|
||||
|
||||
@@ -30,42 +30,58 @@
|
||||
"logViewer": {
|
||||
"title": "설치 로그"
|
||||
},
|
||||
"agreement": {
|
||||
"heading": "약관 동의",
|
||||
"intro": "설치 전에 아래 약관을 모두 확인하고 동의해 주세요.",
|
||||
"tabMap": "맵 약관",
|
||||
"tabMod": "모드 약관",
|
||||
"tabInstaller": "설치기 약관",
|
||||
"loading": "약관을 불러오는 중...",
|
||||
"loadFailed": "약관 로드 실패: {{message}}",
|
||||
"agreeAll": "위 모든 약관(맵·모드·설치기)에 동의합니다.",
|
||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
|
||||
},
|
||||
"step1": {
|
||||
"heading": "1단계. 설치할 음악퀴즈 선택",
|
||||
"heading": "설치할 음악퀴즈 선택",
|
||||
"loading": "목록을 불러오는 중...",
|
||||
"empty": "등록된 음악퀴즈가 없습니다.",
|
||||
"fetchFailed": "목록을 가져오지 못했습니다: {{message}}",
|
||||
"subtitle": "마인크래프트 {{mc}} / {{platform}}"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "2단계. 싱글 / 멀티 선택",
|
||||
"heading": "싱글 / 멀티 선택",
|
||||
"singleTitle": "싱글",
|
||||
"singleHint": "혼자 즐기는 모드. 4단계만 진행합니다.",
|
||||
"singleHint": "싱글 맵으로 혼자 플레이할때",
|
||||
"multiTitle": "멀티",
|
||||
"multiHint": "친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다."
|
||||
"multiHint": "버킷 서버로 친구들과 같이 플레이할때",
|
||||
"roleHeading": "호스트 / 참가자",
|
||||
"hostTitle": "호스트",
|
||||
"hostHint": "내가 서버를 직접 열고 친구들을 초대할 때",
|
||||
"participantTitle": "참가자",
|
||||
"participantHint": "친구가 연 서버에 접속만 할 때"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "3단계. 서버 관련 설정",
|
||||
"heading": "서버 관련 설정",
|
||||
"sub31": {
|
||||
"heading": "3-1. 서버 설치 경로",
|
||||
"description": "서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.",
|
||||
"heading": "서버 설치 경로",
|
||||
"description": "서버를 생성할 폴더를 선택하세요.",
|
||||
"pickFolder": "폴더 선택",
|
||||
"invalidPath": "경로가 유효하지 않습니다.",
|
||||
"confirmed": "경로 확정: {{message}}"
|
||||
},
|
||||
"sub32": {
|
||||
"heading": "3-2. JDK 확인",
|
||||
"description": "JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 \"자동 설치\" 로 Temurin 21 을 받아 설치할 수 있습니다.",
|
||||
"heading": "JDK 확인",
|
||||
"description": "JDK 자동탐색 or 설치",
|
||||
"pickFolder": "폴더 선택",
|
||||
"auto": "자동 탐색",
|
||||
"install": "자동 설치",
|
||||
"installCancel": "설치 취소",
|
||||
"found": "JDK 발견: {{path}}",
|
||||
"autoDetected": "JDK 자동 탐색됨: {{path}}",
|
||||
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.",
|
||||
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.",
|
||||
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.",
|
||||
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.",
|
||||
"cancelRequested": "JDK 설치 취소 요청 중...",
|
||||
"downloading": "Temurin 21 다운로드 중... (네트워크 상태에 따라 1~5분)",
|
||||
"downloading": "JDK 다운로드 중...",
|
||||
"installComplete": "JDK 자동 설치 완료: {{path}}",
|
||||
"installCanceled": "JDK 설치 취소됨",
|
||||
"installFailed": "JDK 설치 실패: {{message}}",
|
||||
@@ -73,76 +89,60 @@
|
||||
"pathRequired": "JDK 경로를 입력해 주세요."
|
||||
},
|
||||
"sub33": {
|
||||
"heading": "3-3. 서버 다운로드 및 설치",
|
||||
"description": "선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.",
|
||||
"heading": "서버 다운로드 및 설치",
|
||||
"description": "서버 파일 다운로드",
|
||||
"waiting": "대기 중",
|
||||
"downloading": "다운로드 중...",
|
||||
"ramHeading": "램 검사",
|
||||
"ramChecking": "검사 중...",
|
||||
"eulaPrompt": "EULA 동의가 필요합니다. 팝업을 확인해 주세요.",
|
||||
"eulaRejected": "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로 설정."
|
||||
"ramMaxOk": "시스템 램({{system}}MB) 확인. {{applied}}MB로 설정."
|
||||
},
|
||||
"eulaModal": {
|
||||
"title": "Minecraft EULA 동의",
|
||||
"fromFile": "서버 파일에 포함된 eula.txt 내용입니다.",
|
||||
"fromMojang": "서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
|
||||
"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": "3-4. 서버 설정 편집",
|
||||
"heading": "서버 설정 편집",
|
||||
"description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.",
|
||||
"open": "편집기 열기",
|
||||
"openedAt": "편집기 주소: <a href=\"{{url}}\" target=\"_blank\">{{url}}</a>",
|
||||
"openFailed": "편집기 실행 실패: {{message}}"
|
||||
},
|
||||
"sub35": {
|
||||
"heading": "3-5. 포트포워딩 점검",
|
||||
"description": "서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.",
|
||||
"heading": "포트포워딩",
|
||||
"description": "UPNP를 개방해 외부 접속을 허용합니다.",
|
||||
"portLabel": "포트",
|
||||
"recheck": "재점검",
|
||||
"checking": "확인 중...",
|
||||
"preForwarded": "이미 외부 접속 가능: {{ip}}:{{port}}",
|
||||
"upnpOk": "UPnP로 자동 개방 완료: {{ip}}:{{port}}",
|
||||
"preForwarded": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)",
|
||||
"upnpOk": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)",
|
||||
"manualHint": "직접 포트포워딩을 해주세요.",
|
||||
"manualDetail": "<br><small>외부 IP: {{ip}}, 포트: {{port}}</small>",
|
||||
"manualDetail": "<br><small>외부 주소: {{address}}</small>",
|
||||
"checkFailed": "점검 실패: {{message}}",
|
||||
"ipUnknown": "확인 불가"
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
"heading": "4단계. 유저 클라이언트 설정",
|
||||
"sub41": {
|
||||
"heading": "4-1. 모드 플랫폼",
|
||||
"vanillaInfo": "선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong>",
|
||||
"vanillaNoInstall": "바닐라이므로 별도 설치는 필요 없습니다.",
|
||||
"info": "선택한 음악퀴즈의 플랫폼: <strong>{{platform}}</strong>",
|
||||
"installTitle": "권장 플랫폼 설치",
|
||||
"installHint": "{{platform}} 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.",
|
||||
"skipTitle": "기본 마인크래프트로 설치",
|
||||
"skipHint": "플랫폼은 설치하지 않고 바닐라로 진행합니다."
|
||||
},
|
||||
"heading": "클라이언트 설정",
|
||||
"sub42": {
|
||||
"heading": "4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신",
|
||||
"description": "%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.",
|
||||
"heading": "다운로드 및 적용",
|
||||
"description": "클라이언트 설정",
|
||||
"installing": "설치 중...",
|
||||
"done": "클라이언트 설치 완료.",
|
||||
"failed": "설치 실패: {{message}}"
|
||||
},
|
||||
"sub43": {
|
||||
"heading": "4-3. 완료 확인",
|
||||
"description": "모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.",
|
||||
"goStep5": "5단계로"
|
||||
}
|
||||
},
|
||||
"step5": {
|
||||
"heading": "5단계. 설치 완료",
|
||||
"summary": "모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.",
|
||||
"heading": "설치 완료",
|
||||
"summary": "",
|
||||
"serverHeading": "서버",
|
||||
"openServerFolder": "서버 폴더 열기",
|
||||
"shortcut": "바탕화면에 서버 실행 바로가기 만들기",
|
||||
@@ -201,7 +201,10 @@
|
||||
"labelServerFile": "서버 파일",
|
||||
"labelMap": "맵",
|
||||
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
|
||||
"skipMapZip": "맵 파일(mapPath)이 비어 있어 맵 다운로드를 건너뜁니다.",
|
||||
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
|
||||
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
|
||||
"mapInstalledAs": "맵을 saves/{{name}} 으로 설치했습니다.",
|
||||
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
|
||||
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
|
||||
"modsIndexFetch": "모드 목록 조회: {{url}}",
|
||||
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",
|
||||
@@ -260,6 +263,7 @@
|
||||
"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}}",
|
||||
@@ -267,7 +271,7 @@
|
||||
"launcherProfilesUpdated": "launcher_profiles.json 갱신: 프로필 \"{{profile}}\", gameDir={{dir}}",
|
||||
"minecraftRootMissing": ".minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.",
|
||||
"settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}",
|
||||
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 보존(이미 존재) {{skipped}}개.",
|
||||
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{skipped}}개.",
|
||||
"settingCopyError": "기존 설정 복사 중 오류: {{message}}",
|
||||
"runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.",
|
||||
"runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"ok": "확인",
|
||||
"delete": "삭제",
|
||||
"edit": "수정",
|
||||
"close": "×",
|
||||
"close": "x",
|
||||
"loading": "불러오는 중..."
|
||||
},
|
||||
"site": {
|
||||
@@ -37,6 +37,7 @@
|
||||
"browserTitle": "관리자 대시보드",
|
||||
"editList": "음악목록 수정",
|
||||
"editDatapack": "데이터팩 수정",
|
||||
"editTerms": "약관 수정",
|
||||
"addPack": "음악퀴즈 추가",
|
||||
"deletePack": "음악퀴즈 삭제",
|
||||
"emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
|
||||
@@ -69,6 +70,14 @@
|
||||
"titleFallback": "(제목 없음)",
|
||||
"artistFallback": "(가수 미상)",
|
||||
"rowEditTooltip": "더블클릭해서 수정",
|
||||
"aliasBtn": "별칭",
|
||||
"aliasBtnWithCount": "별칭 ({{count}})",
|
||||
"aliasModalTitle": "별칭 - {{title}}",
|
||||
"aliasBack": "← 돌아가기",
|
||||
"aliasAdd": "별칭 추가",
|
||||
"aliasPlaceholder": "별칭 입력",
|
||||
"aliasRemove": "삭제",
|
||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||
"metaLoading": "메타데이터 가져오는 중…",
|
||||
"metaFailedShort": "메타 조회 실패",
|
||||
"metaFailedTitle": "메타데이터 조회 실패",
|
||||
@@ -116,11 +125,64 @@
|
||||
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
|
||||
"modsFolder": "모드 폴더 이름",
|
||||
"modsFolderHint": "/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
|
||||
"resourcepackPath": "리소스팩 (.zip)",
|
||||
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
|
||||
"resourcepackPath": "베이스 리소스팩 (.zip)",
|
||||
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.",
|
||||
"outputPackName": "생성되는 리소스팩 이름",
|
||||
"outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩",
|
||||
"outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" < > |)는 자동으로 _ 로 바뀝니다.",
|
||||
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
|
||||
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
|
||||
},
|
||||
"terms": {
|
||||
"browserTitle": "약관 수정",
|
||||
"title": "약관 수정",
|
||||
"pickPackHint": "약관을 수정할 음악퀴즈를 선택하세요. 각 음악퀴즈마다 약관을 따로 보관합니다.",
|
||||
"packBrowserTitle": "{{name}} — 약관 수정",
|
||||
"packTitle": "{{name}} 약관 수정",
|
||||
"hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.",
|
||||
"editorBrowserTitle": "{{label}} 편집",
|
||||
"editorTitle": "{{label}}",
|
||||
"save": "약관 저장",
|
||||
"saving": "저장 중…",
|
||||
"saved": "저장 완료",
|
||||
"saveFailed": "저장 실패: {{message}}",
|
||||
"preview": "미리보기",
|
||||
"edit": "편집",
|
||||
"slashHint": "/ 를 입력해 블록 종류를 선택하거나 #, - 를 직접 입력할 수 있습니다.",
|
||||
"slashHeading1": "큰 제목",
|
||||
"slashHeading2": "중간 제목",
|
||||
"slashHeading3": "작은 제목",
|
||||
"slashText": "내용",
|
||||
"slashBullet": "글머리 기호",
|
||||
"slashNumbered": "번호 매기기",
|
||||
"slashToggle": "토글",
|
||||
"slashDivider": "구분선",
|
||||
"slashQuote": "인용",
|
||||
"slashCode": "코드",
|
||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
|
||||
"builtinBadge": "기본",
|
||||
"addHeading": "약관 추가",
|
||||
"kindLabel": "식별자",
|
||||
"kindPlaceholder": "예: privacy",
|
||||
"kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.",
|
||||
"labelLabel": "표시 이름",
|
||||
"labelPlaceholder": "예: 개인정보 처리방침",
|
||||
"addButton": "추가",
|
||||
"deleteButton": "삭제",
|
||||
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
|
||||
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
|
||||
"createFailed": "약관 추가 실패",
|
||||
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.",
|
||||
"importHeading": "다른 음악퀴즈에서 불러오기",
|
||||
"importSourceLabel": "가져올 음악퀴즈",
|
||||
"importSourcePlaceholder": "음악퀴즈를 선택하세요",
|
||||
"importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.",
|
||||
"importButton": "불러오기",
|
||||
"importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.",
|
||||
"importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?",
|
||||
"importFailed": "약관 불러오기 실패",
|
||||
"invalidImportSource": "올바르지 않은 음악퀴즈입니다."
|
||||
},
|
||||
"datapack": {
|
||||
"browserTitle": "데이터팩 수정",
|
||||
"title": "데이터팩 수정",
|
||||
@@ -128,13 +190,18 @@
|
||||
"pickedNone": "선택된 음악퀴즈 없음",
|
||||
"pickedLabel": "선택: {{name}}",
|
||||
"totalCount": "총 {{count}}개의 음악을 찾았습니다.",
|
||||
"export": "데이터팩 출력",
|
||||
"hint": "music_quiz 데이터팩의 data/mq/function/init/songs.mcfunction 파일에 아래 코드를 그대로 덮어쓰세요.",
|
||||
"export": "코드 출력",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"exporting": "출력 중…",
|
||||
"exported": "출력 완료",
|
||||
"failed": "실패: {{message}}",
|
||||
"modalPickTitle": "음악퀴즈 선택"
|
||||
"modalPickTitle": "음악퀴즈 선택",
|
||||
"imagesZip": "이미지.zip 출력",
|
||||
"imagesZipSizeLabel": "크기",
|
||||
"imagesZipDownloading": "이미지.zip 생성 중…",
|
||||
"imagesZipDone": "이미지.zip 다운로드 완료"
|
||||
},
|
||||
"errors": {
|
||||
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
|
||||
@@ -153,14 +220,5 @@
|
||||
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
|
||||
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||
},
|
||||
"datapackOutput": {
|
||||
"header": "# === musicquiz: {{name}} ===",
|
||||
"summary": "# 총 {{musicCount}}곡 / 사진 {{imageCount}}장",
|
||||
"initLine": "say [musicquiz] 데이터팩 초기화",
|
||||
"placeholder": "# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.",
|
||||
"trackLine": "# {{index}}. {{title}} - {{artist}} ({{duration}}s)",
|
||||
"titleFallback": "(제목 없음)",
|
||||
"artistFallback": "(가수 미상)"
|
||||
}
|
||||
}
|
||||
|
||||
27
manifest/terms/installer-rp.md
Normal file
27
manifest/terms/installer-rp.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 리소스팩 설치기(exe) 안내 및 약관
|
||||
|
||||
**1.** 이 설치기는 리소스팩(음악·사진)의 간편한 빌드 및 설치를 위한 프로그램입니다.
|
||||
- 설치기를 통해 설치되는 리소스팩은 리소스팩 약관을 따릅니다.
|
||||
- 설치기 사용 전 리소스팩 약관을 반드시 확인하세요.
|
||||
|
||||
**2.** 이 설치기는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
|
||||
|
||||
**3.** 설치기에 대한 2차 창작 및 2차 배포는 금지됩니다.
|
||||
- 소스코드의 무단 복제, 수정, 재배포는 엄격히 금지됩니다.
|
||||
- 설치기를 타 플랫폼 또는 제3자에게 재배포하는 행위는 금지됩니다.
|
||||
|
||||
**4.** 설치기의 소스코드는 GitHub를 통해 공개되어 있습니다.
|
||||
- 소스코드 열람은 허용되나, 이를 기반으로 한 파생 프로그램 제작 및 배포는 금지됩니다.
|
||||
- 버그 제보 및 기여는 허용됩니다.
|
||||
|
||||
**5.** 설치기는 음악·이미지 다운로드를 위해 외부 도구(yt-dlp, ffmpeg)를 자동으로 받아 사용합니다. 각 도구는 해당 프로젝트의 라이선스를 따릅니다.
|
||||
- yt-dlp: https://github.com/yt-dlp/yt-dlp
|
||||
- ffmpeg: https://ffmpeg.org/
|
||||
|
||||
**6.** 이 설치기의 저작권은 제작자에게 있으며, 무단 사용 시 저작권법에 의해 처벌받을 수 있습니다.
|
||||
|
||||
Copyright (c) 2026. All rights reserved.
|
||||
|
||||
This software is protected under a Custom License.
|
||||
Unauthorized reproduction, modification, or distribution of this software is strictly prohibited and may result in legal action under applicable copyright laws.
|
||||
29
manifest/terms/installer.md
Normal file
29
manifest/terms/installer.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 설치기(exe) 안내 및 약관
|
||||
|
||||
**1.** 이 설치기는 맵, 모드, 리소스팩의 간편한 설치를 위한 프로그램입니다.
|
||||
- 설치기를 통해 설치되는 각 콘텐츠(맵, 모드, 리소스팩)는 각각의 약관을 따릅니다.
|
||||
- 설치기 사용 전 각 콘텐츠의 약관을 반드시 확인하세요.
|
||||
|
||||
**2.** 이 설치기는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
|
||||
|
||||
**3.** 설치기에 대한 2차 창작 및 2차 배포는 금지됩니다.
|
||||
- 소스코드의 무단 복제, 수정, 재배포는 엄격히 금지됩니다.
|
||||
- 설치기를 타 플랫폼 또는 제3자에게 재배포하는 행위는 금지됩니다.
|
||||
|
||||
**4.** 설치기의 소스코드는 GitHub를 통해 공개되어 있습니다.
|
||||
- 소스코드 열람은 허용되나, 이를 기반으로 한 파생 프로그램 제작 및 배포는 금지됩니다.
|
||||
- 버그 제보 및 기여는 허용됩니다.
|
||||
|
||||
**5.** 설치기에 포함된 외부 모드(Fabric API, Modmenu)는 각 모드의 라이선스를 따르며, 설치기는 해당 모드들을 공식 배포처에서 다운로드합니다.
|
||||
- Fabric API: https://www.curseforge.com/minecraft/mc-mods/fabric-api
|
||||
- Modmenu: https://www.curseforge.com/minecraft/mc-mods/modmenu
|
||||
|
||||
**6.** 이 설치기의 저작권은 제작자에게 있으며, 무단 사용 시 저작권법에 의해 처벌받을 수 있습니다.
|
||||
|
||||
Copyright (c) 2026. All rights reserved.
|
||||
|
||||
This software is protected under a Custom License.
|
||||
Unauthorized reproduction, modification, or distribution of this software is strictly prohibited and may result in legal action under applicable copyright laws.
|
||||
|
||||
All rights reserved (ARR). No part of this software may be reproduced, distributed, or transmitted in any form or by any means without the prior written permission of the copyright holder.
|
||||
22
manifest/terms/map.md
Normal file
22
manifest/terms/map.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 맵(Map) 안내 및 약관
|
||||
|
||||
**1.** 이 맵은 마인크래프트 인게임에서 시스템에 따라 재생되는 노래를 듣고 제목을 맞추는 Windows PC 기반 JE 26.1.2 전용 맵입니다.
|
||||
- 이번 노래퀴즈 주제는 "게임"이며, 임의의 게임 OST/BGM을 듣고 게임의 이름을 맞추어야 합니다.
|
||||
- JE 버전이 다를 경우 플레이가 불가능할 수도 있습니다. 버전을 반드시 확인하세요.
|
||||
|
||||
**2.** 이 맵은 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
|
||||
|
||||
**3.** 맵에 대한 2차 창작은 금지합니다. 2차 배포는 이 글을 통하여 배포하되 허가가 필요합니다.
|
||||
- 맵에는 배경용 타 맵 제작자의 라이선스도 포함되어 있습니다. 무단 배포는 엄격히 금지합니다.
|
||||
|
||||
**4.** 맵 플레이에는 50Mbps 이상의 기본적인 인터넷 속도를 요구합니다.
|
||||
- 또한 8코어 이상의 CPU와 16GB 이상의 램 용량을 권장합니다.
|
||||
- 위 사양을 충족하지 못할 경우 원활한 플레이가 어려울 수 있습니다.
|
||||
|
||||
**5.** 맵에는 배경용 제3자의 맵이 사용되었습니다.
|
||||
- 출처: https://www.planetminecraft.com/project/liyue-harbour-from-genshin-impact-in-minecraft-1-1-scale/
|
||||
- 저작자: SkyBlock Squad
|
||||
- 해당 맵은 저작자의 허가를 받아 사용하였습니다.
|
||||
|
||||
This work is licensed under CC BY-NC-ND 4.0
|
||||
19
manifest/terms/mod.md
Normal file
19
manifest/terms/mod.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 모드(Mod) 안내 및 약관
|
||||
|
||||
**1.** 더 향상된 플레이를 위하여 모드가 포함되어 있습니다.
|
||||
- 모드는 설치기를 통하여 자동 설치됩니다.
|
||||
- 자동 설치가 제대로 되지 않을 경우 수동 설치를 권장합니다.
|
||||
|
||||
**2.** Fabric 기반 26.1.2 모드를 사용하였습니다.
|
||||
- 저희가 제작한 chat_answer, video_player 모드는 제작자의 소유입니다.
|
||||
- 두 모드에 대한 2차 창작 및 2차 배포는 금지됩니다.
|
||||
|
||||
**3.** 모드는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없습니다.
|
||||
|
||||
**4.** 원활한 플레이를 위해 Sodium, Iris Shaders 모드를 함께 사용하는 것을 권장합니다.
|
||||
- 최적화 및 쉐이더 적용을 하기 위한 의도이며, 필수 사항은 아닙니다.
|
||||
- Sodium: https://www.curseforge.com/minecraft/mc-mods/sodium
|
||||
- Iris Shaders: https://www.curseforge.com/minecraft/mc-mods/irisshaders
|
||||
|
||||
This work is licensed under CC BY-NC-ND 4.0
|
||||
13
manifest/terms/resourcepack.md
Normal file
13
manifest/terms/resourcepack.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 리소스팩(ResourcePack) 안내 및 약관
|
||||
|
||||
**1.** 리소스팩은 맵 플레이를 위한 필수 요소입니다.
|
||||
- 노래가 나오지 않는 경우 리소스팩의 적용 여부를 반드시 확인하세요.
|
||||
|
||||
**2.** 리소스팩은 절대 2차 창작하거나 2차 배포해서는 안 되며, 어느 누구에게도 전달해서는 안 됩니다.
|
||||
- 영리적인 목적으로 사용할 수 없습니다.
|
||||
- 리소스팩에 포함된 음악의 저작권은 각 원저작자에게 있습니다.
|
||||
- 리소스팩은 이 맵 플레이 전용으로만 사용하여야 합니다.
|
||||
|
||||
Copyright (c) 2026. All rights reserved.
|
||||
All music and audio files included in this resource pack are excluded from this license.
|
||||
The copyright of all such content belongs to their respective original copyright holders.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.3",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
@@ -9,7 +9,10 @@
|
||||
"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",
|
||||
"build:launcher-icon": "node scripts/build-launcher-icon.cjs",
|
||||
"dist:win": "npm run preinstall:sharp-win32 && npm run build:launcher-icon && 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",
|
||||
|
||||
@@ -99,6 +99,10 @@
|
||||
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"/>' +
|
||||
@@ -110,9 +114,13 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -340,6 +348,19 @@
|
||||
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()
|
||||
@@ -402,6 +423,110 @@
|
||||
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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
106
public/termsEditor.css
Normal file
106
public/termsEditor.css
Normal file
@@ -0,0 +1,106 @@
|
||||
/* Notion 스타일 약관 편집기 전용 스타일.
|
||||
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
|
||||
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
|
||||
* 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */
|
||||
|
||||
.termsEditorWrap {
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.termsEditor {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, #1f242c);
|
||||
color: var(--text, #e6edf3);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
caret-color: var(--accent, #58a6ff);
|
||||
}
|
||||
|
||||
.termsEditor:focus {
|
||||
border-color: var(--accent, #58a6ff);
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
.termsPreview {
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.termsPreview h1 { font-size: 22px; margin: 12px 0 8px; }
|
||||
.termsPreview h2 { font-size: 18px; margin: 10px 0 6px; }
|
||||
.termsPreview h3 { font-size: 15px; margin: 8px 0 4px; }
|
||||
.termsPreview p { margin: 6px 0; }
|
||||
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
|
||||
.termsPreview li { margin: 2px 0; }
|
||||
.termsPreview hr { border: none; border-top: 1px solid var(--border, #30363d); margin: 12px 0; }
|
||||
.termsPreview blockquote {
|
||||
margin: 8px 0; padding: 4px 12px;
|
||||
border-left: 3px solid var(--border, #30363d);
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
.termsPreview code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 5px; border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.termsPreview pre {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 10px 12px; border-radius: 6px; overflow: auto;
|
||||
}
|
||||
.termsPreview pre code { background: transparent; padding: 0; }
|
||||
.termsPreview a { color: var(--accent, #58a6ff); text-decoration: underline; word-break: break-all; }
|
||||
.termsPreview details {
|
||||
margin: 6px 0;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card, #1f242c);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
|
||||
|
||||
/* 슬래시 자동완성 메뉴 — 노션 느낌으로 caret 좌표 위에 띄움. */
|
||||
.slashMenu {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
min-width: 220px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.slashMenu .slashItem {
|
||||
display: flex; flex-direction: column;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slashMenu .slashItem:hover,
|
||||
.slashMenu .slashItem.active {
|
||||
background: var(--bg-card, #1f242c);
|
||||
}
|
||||
.slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); }
|
||||
.slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; }
|
||||
385
public/termsEditor.js
Normal file
385
public/termsEditor.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/* 약관(Markdown) 편집기.
|
||||
* - 기본은 textarea: 사용자가 직접 #, - 등을 입력할 수 있다.
|
||||
* - "/" 를 줄 맨 앞 또는 빈 공간 다음에 입력하면 슬래시 메뉴를 띄워
|
||||
* 제목/내용/글머리/번호/토글/구분선/인용/코드 블록을 선택해 자동 삽입한다.
|
||||
* (사용자가 #, - 같은 기호를 외울 필요 없이 명령어로 입력 가능)
|
||||
* - 미리보기 탭에서 작은 markdown → HTML 렌더러로 결과를 보여 준다.
|
||||
*/
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
var editor = document.getElementById('editor')
|
||||
var preview = document.getElementById('preview')
|
||||
var slashMenu = document.getElementById('slashMenu')
|
||||
var status = document.getElementById('status')
|
||||
var dirtyMark = document.getElementById('dirty-mark')
|
||||
var saveBtn = document.getElementById('saveBtn')
|
||||
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
||||
|
||||
editor.value = INITIAL || ''
|
||||
var dirty = false
|
||||
function setDirty(v) {
|
||||
dirty = v
|
||||
dirtyMark.hidden = !v
|
||||
}
|
||||
|
||||
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
||||
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
||||
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function inline(s) {
|
||||
s = escHtml(s)
|
||||
// code `x`
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// bold **x**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
// italic *x*
|
||||
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
||||
// links [text](url) — also auto-link bare http(s)
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
|
||||
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
function renderMd(src) {
|
||||
var lines = src.replace(/\r\n/g, '\n').split('\n')
|
||||
var out = []
|
||||
var i = 0
|
||||
var stackList = null // 'ul' | 'ol' | null
|
||||
function closeList() { if (stackList) { out.push('</' + stackList + '>'); stackList = null } }
|
||||
while (i < lines.length) {
|
||||
var line = lines[i]
|
||||
// 코드 블록 ```lang
|
||||
var fence = /^```(\w*)\s*$/.exec(line)
|
||||
if (fence) {
|
||||
closeList()
|
||||
var code = []
|
||||
i += 1
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
||||
code.push(lines[i]); i += 1
|
||||
}
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
|
||||
continue
|
||||
}
|
||||
// 토글 (자체 구문) :::toggle 제목 ... :::
|
||||
var togStart = /^:::toggle\s+(.+)$/.exec(line)
|
||||
if (togStart) {
|
||||
closeList()
|
||||
var summary = togStart[1]
|
||||
var body = []
|
||||
i += 1
|
||||
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
|
||||
body.push(lines[i]); i += 1
|
||||
}
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<details><summary>' + inline(summary) + '</summary>' + renderMd(body.join('\n')) + '</details>')
|
||||
continue
|
||||
}
|
||||
// 헤딩
|
||||
var h = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (h) {
|
||||
closeList()
|
||||
var level = h[1].length
|
||||
out.push('<h' + level + '>' + inline(h[2]) + '</h' + level + '>')
|
||||
i += 1; continue
|
||||
}
|
||||
// hr
|
||||
if (/^---+\s*$/.test(line)) {
|
||||
closeList()
|
||||
out.push('<hr />'); i += 1; continue
|
||||
}
|
||||
// 인용 >
|
||||
if (/^>\s?/.test(line)) {
|
||||
closeList()
|
||||
var q = []
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
||||
q.push(lines[i].replace(/^>\s?/, '')); i += 1
|
||||
}
|
||||
out.push('<blockquote>' + renderMd(q.join('\n')) + '</blockquote>')
|
||||
continue
|
||||
}
|
||||
// 번호 목록
|
||||
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
|
||||
if (ol) {
|
||||
if (stackList !== 'ol') { closeList(); out.push('<ol>'); stackList = 'ol' }
|
||||
out.push('<li>' + inline(ol[1]) + '</li>')
|
||||
i += 1; continue
|
||||
}
|
||||
// 불릿
|
||||
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
|
||||
if (ul) {
|
||||
if (stackList !== 'ul') { closeList(); out.push('<ul>'); stackList = 'ul' }
|
||||
out.push('<li>' + inline(ul[1]) + '</li>')
|
||||
i += 1; continue
|
||||
}
|
||||
// 빈 줄
|
||||
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
|
||||
// 일반 문단
|
||||
closeList()
|
||||
var para = [line]; i += 1
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i])
|
||||
&& !/^(#{1,6})\s+/.test(lines[i])
|
||||
&& !/^\s*[-*]\s+/.test(lines[i])
|
||||
&& !/^\s*\d+\.\s+/.test(lines[i])
|
||||
&& !/^>/.test(lines[i])
|
||||
&& !/^---+\s*$/.test(lines[i])
|
||||
&& !/^```/.test(lines[i])
|
||||
&& !/^:::/.test(lines[i])) {
|
||||
para.push(lines[i]); i += 1
|
||||
}
|
||||
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
|
||||
}
|
||||
closeList()
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
function refreshPreview() {
|
||||
preview.innerHTML = renderMd(editor.value)
|
||||
}
|
||||
|
||||
// ─── 탭 전환 (edit / preview) ────────────────────────────────────────
|
||||
tabBtns.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
tabBtns.forEach(function (x) { x.classList.remove('active') })
|
||||
b.classList.add('active')
|
||||
var mode = b.getAttribute('data-mode')
|
||||
if (mode === 'preview') {
|
||||
refreshPreview()
|
||||
editor.hidden = true
|
||||
preview.hidden = false
|
||||
} else {
|
||||
editor.hidden = false
|
||||
preview.hidden = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 저장 ───────────────────────────────────────────────────────────
|
||||
function save() {
|
||||
status.classList.remove('error')
|
||||
status.textContent = I18N.saving
|
||||
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editor.value })
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error((res.body && res.body.message) || 'failed')
|
||||
setDirty(false)
|
||||
status.textContent = I18N.saved
|
||||
}).catch(function (err) {
|
||||
status.classList.add('error')
|
||||
status.textContent = I18N.saveFailed.replace('{{message}}', err.message)
|
||||
})
|
||||
}
|
||||
saveBtn.addEventListener('click', save)
|
||||
|
||||
// Ctrl+S 저장
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault(); save()
|
||||
}
|
||||
})
|
||||
|
||||
// 페이지 떠나기 가드
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (!dirty) return
|
||||
e.preventDefault()
|
||||
e.returnValue = I18N.leaveConfirm
|
||||
return I18N.leaveConfirm
|
||||
})
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
setDirty(true)
|
||||
})
|
||||
|
||||
// ─── 슬래시 자동완성 ─────────────────────────────────────────────────
|
||||
// 정의: { label, hint, insert: 줄 시작에 들어갈 텍스트 (커서 위치는 |로 표시) }
|
||||
var SLASH_ITEMS = [
|
||||
{ label: I18N.slashHeading1, hint: '# ', insert: '# |' },
|
||||
{ label: I18N.slashHeading2, hint: '## ', insert: '## |' },
|
||||
{ label: I18N.slashHeading3, hint: '### ', insert: '### |' },
|
||||
{ label: I18N.slashText, hint: '', insert: '|' },
|
||||
{ label: I18N.slashBullet, hint: '- ', insert: '- |' },
|
||||
{ label: I18N.slashNumbered, hint: '1. ', insert: '1. |' },
|
||||
{ label: I18N.slashToggle, hint: ':::toggle 제목 ... :::', insert: ':::toggle 제목\n|\n:::' },
|
||||
{ label: I18N.slashDivider, hint: '---', insert: '---\n|' },
|
||||
{ label: I18N.slashQuote, hint: '> ', insert: '> |' },
|
||||
{ label: I18N.slashCode, hint: '```', insert: '```\n|\n```' }
|
||||
]
|
||||
|
||||
var slashState = null // { startPos: number, query: string, activeIndex: number, filtered: [] }
|
||||
|
||||
function renderSlashItems(filtered) {
|
||||
slashMenu.innerHTML = ''
|
||||
filtered.forEach(function (item, idx) {
|
||||
var el = document.createElement('div')
|
||||
el.className = 'slashItem' + (idx === slashState.activeIndex ? ' active' : '')
|
||||
var strong = document.createElement('strong')
|
||||
strong.textContent = item.label
|
||||
var span = document.createElement('span')
|
||||
span.textContent = item.hint || ''
|
||||
el.appendChild(strong); el.appendChild(span)
|
||||
el.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault()
|
||||
applySlash(item)
|
||||
})
|
||||
slashMenu.appendChild(el)
|
||||
})
|
||||
}
|
||||
|
||||
function positionSlash() {
|
||||
// textarea caret 좌표 근사: 보이지 않는 mirror div 를 만들어 caret 위치를 추정한다.
|
||||
var rect = editor.getBoundingClientRect()
|
||||
var wrapRect = editor.parentElement.getBoundingClientRect()
|
||||
var caret = getCaretCoords(editor)
|
||||
var top = caret.top + 22 + (rect.top - wrapRect.top) - editor.scrollTop
|
||||
var left = caret.left + (rect.left - wrapRect.left)
|
||||
slashMenu.style.top = top + 'px'
|
||||
slashMenu.style.left = left + 'px'
|
||||
}
|
||||
|
||||
function openSlash() {
|
||||
slashState = {
|
||||
startPos: editor.selectionStart - 1, // '/' 위치
|
||||
query: '',
|
||||
activeIndex: 0,
|
||||
filtered: SLASH_ITEMS.slice()
|
||||
}
|
||||
renderSlashItems(slashState.filtered)
|
||||
slashMenu.hidden = false
|
||||
positionSlash()
|
||||
}
|
||||
function closeSlash() {
|
||||
slashState = null
|
||||
slashMenu.hidden = true
|
||||
}
|
||||
|
||||
function applySlash(item) {
|
||||
if (!slashState) return
|
||||
var value = editor.value
|
||||
var start = slashState.startPos
|
||||
var end = editor.selectionStart
|
||||
// 줄의 시작 위치 계산 (이미 '#', '- ' 같은 prefix 가 있어도 무시하고 새 prefix 로 교체)
|
||||
var lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
var lineEnd = value.indexOf('\n', end)
|
||||
if (lineEnd === -1) lineEnd = value.length
|
||||
var beforeLine = value.slice(0, lineStart)
|
||||
var afterLine = value.slice(lineEnd)
|
||||
var currentLine = value.slice(lineStart, lineEnd)
|
||||
// 줄 안에서 '/검색어' 부분을 제거하고, 나머지 텍스트를 prefix 뒤에 이어 붙인다.
|
||||
var rest = currentLine.slice(0, start - lineStart) + currentLine.slice(end - lineStart)
|
||||
var insert = item.insert
|
||||
var caretMarker = insert.indexOf('|')
|
||||
var inserted = insert.replace('|', rest)
|
||||
editor.value = beforeLine + inserted + afterLine
|
||||
var caretPos = (beforeLine + insert.slice(0, caretMarker)).length
|
||||
editor.selectionStart = editor.selectionEnd = caretPos
|
||||
closeSlash()
|
||||
setDirty(true)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
editor.addEventListener('keydown', function (e) {
|
||||
if (slashState) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
slashState.activeIndex = (slashState.activeIndex + 1) % slashState.filtered.length
|
||||
renderSlashItems(slashState.filtered)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
slashState.activeIndex = (slashState.activeIndex - 1 + slashState.filtered.length) % slashState.filtered.length
|
||||
renderSlashItems(slashState.filtered)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (slashState.filtered.length > 0) {
|
||||
e.preventDefault()
|
||||
applySlash(slashState.filtered[slashState.activeIndex])
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeSlash()
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
editor.addEventListener('input', function (e) {
|
||||
var pos = editor.selectionStart
|
||||
var ch = editor.value.slice(pos - 1, pos)
|
||||
if (!slashState && ch === '/') {
|
||||
// 줄 시작 또는 공백 다음에서만 슬래시 메뉴 활성화
|
||||
var prev = pos >= 2 ? editor.value.slice(pos - 2, pos - 1) : '\n'
|
||||
if (prev === '\n' || prev === ' ' || pos === 1) {
|
||||
openSlash()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (slashState) {
|
||||
var startPos = slashState.startPos
|
||||
if (pos < startPos || editor.value[startPos] !== '/') {
|
||||
closeSlash()
|
||||
return
|
||||
}
|
||||
var q = editor.value.slice(startPos + 1, pos).toLowerCase()
|
||||
slashState.query = q
|
||||
slashState.filtered = SLASH_ITEMS.filter(function (it) {
|
||||
if (!q) return true
|
||||
return it.label.toLowerCase().indexOf(q) !== -1
|
||||
|| (it.hint && it.hint.toLowerCase().indexOf(q) !== -1)
|
||||
})
|
||||
slashState.activeIndex = 0
|
||||
renderSlashItems(slashState.filtered)
|
||||
positionSlash()
|
||||
}
|
||||
})
|
||||
|
||||
editor.addEventListener('blur', function () {
|
||||
// mousedown on menu uses e.preventDefault → blur 시에도 안전하게 닫는다.
|
||||
setTimeout(closeSlash, 100)
|
||||
})
|
||||
|
||||
// ─── caret 좌표 계산 (mirror div 기법) ───────────────────────────────
|
||||
function getCaretCoords(el) {
|
||||
var div = document.createElement('div')
|
||||
var s = getComputedStyle(el)
|
||||
var props = [
|
||||
'boxSizing','width','height','overflowX','overflowY',
|
||||
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
|
||||
'paddingTop','paddingRight','paddingBottom','paddingLeft',
|
||||
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust',
|
||||
'lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration',
|
||||
'letterSpacing','wordSpacing','tabSize','MozTabSize','whiteSpace'
|
||||
]
|
||||
div.style.position = 'absolute'
|
||||
div.style.visibility = 'hidden'
|
||||
div.style.whiteSpace = 'pre-wrap'
|
||||
div.style.wordWrap = 'break-word'
|
||||
props.forEach(function (p) { div.style[p] = s[p] })
|
||||
div.style.position = 'absolute'
|
||||
div.style.top = '0'
|
||||
div.style.left = '0'
|
||||
var rect = el.getBoundingClientRect()
|
||||
document.body.appendChild(div)
|
||||
var pos = el.selectionStart
|
||||
var before = el.value.substring(0, pos)
|
||||
div.textContent = before
|
||||
var span = document.createElement('span')
|
||||
span.textContent = el.value.substring(pos) || '.'
|
||||
div.appendChild(span)
|
||||
var top = span.offsetTop + parseInt(s.borderTopWidth, 10)
|
||||
var left = span.offsetLeft + parseInt(s.borderLeftWidth, 10)
|
||||
document.body.removeChild(div)
|
||||
return { top: top, left: left }
|
||||
}
|
||||
})()
|
||||
33
scripts/build-launcher-icon.cjs
Normal file
33
scripts/build-launcher-icon.cjs
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// build/icon.png 을 읽어 base64 data URL 로 변환해
|
||||
// src/installer/launcherIcon.ts 에 상수로 박는다.
|
||||
//
|
||||
// 마인크래프트 런처의 "설치 설정" 화면 프로필 아이콘은
|
||||
// launcher_profiles.json 의 profile.icon 필드에서 오는데,
|
||||
// `data:image/png;base64,...` 형태의 data URL 을 받는다.
|
||||
// build/ 폴더는 electron-builder 가 exe 아이콘으로만 쓰고 asar 에
|
||||
// 포함되지 않아서, 런타임에 그 파일을 읽을 수 없다. 대신 빌드(개발) 시점에
|
||||
// 이 스크립트를 돌려 PNG 를 소스 코드에 인라인한다.
|
||||
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const pngPath = path.join(repoRoot, 'build', 'icon.png')
|
||||
const tsPath = path.join(repoRoot, 'src', 'installer', 'launcherIcon.ts')
|
||||
|
||||
const buf = fs.readFileSync(pngPath)
|
||||
const b64 = buf.toString('base64')
|
||||
|
||||
const ts = `// AUTO-GENERATED by scripts/build-launcher-icon.cjs from build/icon.png.
|
||||
// 마인크래프트 런처의 "설치 설정" 화면에서 보이는 프로필 아이콘. exe 와 같은
|
||||
// 이미지를 쓰기 위해 빌드 시점에 PNG 를 data URL 로 인라인한다. 변경하려면
|
||||
// build/icon.png 교체 후 \`node scripts/build-launcher-icon.cjs\` 재실행.
|
||||
export const LAUNCHER_PROFILE_ICON =
|
||||
'data:image/png;base64,${b64}'
|
||||
`
|
||||
|
||||
fs.writeFileSync(tsPath, ts, 'utf8')
|
||||
console.log(`wrote ${tsPath} (${buf.length} bytes PNG → ${b64.length} chars base64)`)
|
||||
@@ -3,7 +3,7 @@ import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs
|
||||
import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { getMcCustomDir, getMcCustomInstallerDir } from '../shared/paths.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
@@ -13,10 +13,30 @@ const extractZip: (source: string, options: { dir: string }) => Promise<void> =
|
||||
|
||||
/**
|
||||
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용.
|
||||
* 경로: %appdata%/.mc_custom/ffmpeg.exe
|
||||
* 경로: %appdata%/.mc_custom/installer/ffmpeg.exe
|
||||
*/
|
||||
export function getFfmpegExePath(): string {
|
||||
return path.join(getMcCustomDir(), 'ffmpeg.exe')
|
||||
return path.join(getMcCustomInstallerDir(), 'ffmpeg.exe')
|
||||
}
|
||||
|
||||
/**
|
||||
* 0.2.1 이전 버전이 `.mc_custom/ffmpeg.exe` 에 받아둔 파일이 있으면 새 위치로
|
||||
* 옮긴다.
|
||||
*/
|
||||
async function migrateLegacyExe(target: string): Promise<void> {
|
||||
const legacy = path.join(getMcCustomDir(), 'ffmpeg.exe')
|
||||
if (legacy === target) return
|
||||
try {
|
||||
await fs.access(legacy, fsConst.F_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.rename(legacy, target)
|
||||
} catch {
|
||||
try { await fs.unlink(legacy) } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
|
||||
@@ -33,6 +53,7 @@ export async function ensureFfmpegExe(
|
||||
log?: (line: string) => void
|
||||
): Promise<string> {
|
||||
const target = getFfmpegExePath()
|
||||
await migrateLegacyExe(target)
|
||||
if (await canExecute(target)) {
|
||||
log?.(t('log.ffmpegExists', { path: target }))
|
||||
return target
|
||||
@@ -40,7 +61,7 @@ export async function ensureFfmpegExe(
|
||||
if (installPromise) return installPromise
|
||||
|
||||
installPromise = (async () => {
|
||||
const dir = getMcCustomDir()
|
||||
const dir = getMcCustomInstallerDir()
|
||||
const zipPath = path.join(dir, '.tmp_ffmpeg.zip')
|
||||
const extractDir = path.join(dir, '.tmp_ffmpeg')
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,20 @@ interface RpInstallerState {
|
||||
activeChildren: Set<ChildProcess>
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수
|
||||
* 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는
|
||||
* 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 →
|
||||
* 호출 측에서 폴백을 결정한다.
|
||||
*/
|
||||
function sanitizeOutputPackName(name: string): string {
|
||||
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
cleaned = cleaned.replace(/[ .]+$/, '')
|
||||
if (!cleaned) return ''
|
||||
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정.
|
||||
* - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시.
|
||||
@@ -58,9 +72,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()
|
||||
@@ -98,9 +112,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,
|
||||
@@ -198,11 +215,13 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
|
||||
const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null
|
||||
const mcVersion = normalized?.mcVersion ?? ''
|
||||
const resourcepackPath = normalized?.resourcepackPath ?? ''
|
||||
const outputPackName = normalized?.outputPackName ?? ''
|
||||
results.push({
|
||||
key: entry.file,
|
||||
name: entry.name || entry.file,
|
||||
mcVersion,
|
||||
resourcepackPath,
|
||||
outputPackName,
|
||||
list
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -232,6 +251,23 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
||||
|
||||
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||
|
||||
// ── IPC: 약관 다운로드 ──────────────────────────────
|
||||
// 사이트가 /manifest/terms/<packKey>/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
|
||||
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
|
||||
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
|
||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
||||
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
||||
if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' }
|
||||
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||
try {
|
||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
|
||||
const buf = await fetchBuffer(url)
|
||||
return { ok: true, content: buf.toString('utf8') }
|
||||
} catch (error) {
|
||||
return { ok: false, message: (error as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// ── IPC: 2단계 설치 ──────────────────────────────────
|
||||
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
||||
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
||||
@@ -380,7 +416,11 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
|
||||
throwIfCancelled()
|
||||
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
||||
// 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize
|
||||
// 결과가 빈 문자열이면 `<packKey>_resourcepack` 로 폴백.
|
||||
const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName)
|
||||
const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack`
|
||||
const resourcepackName = `${resourcepackBaseName}.zip`
|
||||
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
|
||||
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||
sendLog(t('log.buildingZip', { name: resourcepackName }))
|
||||
@@ -393,11 +433,30 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
workDir: tempRoot,
|
||||
outZipPath: resourcepackPath,
|
||||
baseZipPath,
|
||||
log: sendLog
|
||||
log: sendLog,
|
||||
// build 내부에서도 단계 사이/zip 도중에 폴링해서 취소를 빠르게 반영한다.
|
||||
cancelChecker: () => state.cancelRequested
|
||||
})
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||
sendLog(t('log.installComplete', { path: resourcepackPath }))
|
||||
|
||||
// 2-7. 베이스 리소스팩은 우리가 임시폴더에 받아서 빌드에 이미 얹었으므로,
|
||||
// 메인 설치기가 `.mc_custom/resourcepacks/<resourcepackPath>` 에 받아둔
|
||||
// 원본 zip 은 MC 리소스팩 목록에 굳이 남길 필요 없다. 삭제하되, 사용자가
|
||||
// outputPackName 을 base 파일명과 똑같이 둬서 우리가 방금 쓴 최종 zip 과
|
||||
// 같은 경로면 그대로 둔다(우리 산출물을 지우면 안 되므로).
|
||||
if (pack.resourcepackPath) {
|
||||
const basePackPath = path.join(resourcepackDir, pack.resourcepackPath)
|
||||
if (path.resolve(basePackPath) !== path.resolve(resourcepackPath)) {
|
||||
try {
|
||||
await fsp.rm(basePackPath, { force: true })
|
||||
sendLog(t('log.baseRemoved', { path: basePackPath }))
|
||||
} catch { /* 없으면 무시 */ }
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
|
||||
return { resourcepackPath }
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,7 @@ 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')
|
||||
@@ -29,6 +29,26 @@ export interface BuildResourcepackOptions {
|
||||
baseZipPath?: string
|
||||
/** 진단용 로그 콜백 (선택). */
|
||||
log?: (line: string) => void
|
||||
/**
|
||||
* 사용자 취소 신호. true 가 되면 가능한 시점에 build 를 중단한다.
|
||||
* - 단계 사이 (extract → meta → 음악 복사 → painting 복사 → zip) 폴링.
|
||||
* - zip 생성 중에도 폴링해서 archive.abort() 로 끊는다.
|
||||
* 호출자는 후속 처리에서 임시 폴더와 부분 zip 파일을 정리해야 한다.
|
||||
*/
|
||||
cancelChecker?: () => boolean
|
||||
}
|
||||
|
||||
/** cancelChecker 가 true 를 반환하면 던지는 에러. main 쪽 에러 처리와 동일한 메시지를 쓰지 않고,
|
||||
* 명시적인 클래스 마커로 식별하기 쉽게 한다. 메시지는 i18n 의 errors.cancelledByUser 와 1:1. */
|
||||
class CancelledError extends Error {
|
||||
constructor() {
|
||||
super(t('errors.cancelledByUser'))
|
||||
this.name = 'CancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfCancelled(checker?: () => boolean): void {
|
||||
if (checker && checker()) throw new CancelledError()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +61,8 @@ export interface BuildResourcepackOptions {
|
||||
* assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김
|
||||
*/
|
||||
export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> {
|
||||
const cancel = opts.cancelChecker
|
||||
throwIfCancelled(cancel)
|
||||
const root = path.join(opts.workDir, 'resourcepack')
|
||||
// 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다.
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
@@ -50,6 +72,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
if (opts.baseZipPath) {
|
||||
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
|
||||
await extract(opts.baseZipPath, { dir: root })
|
||||
throwIfCancelled(cancel)
|
||||
}
|
||||
|
||||
const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds')
|
||||
@@ -64,14 +87,48 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
} else {
|
||||
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
|
||||
}
|
||||
const mcmeta = {
|
||||
pack: {
|
||||
description: t('pack.description', { name: opts.packName }),
|
||||
pack_format: resolved.format,
|
||||
supported_formats: { min_inclusive: resolved.format, max_inclusive: resolved.format }
|
||||
|
||||
// 호환 범위는 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: 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))
|
||||
@@ -91,6 +148,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
// 없으면 새로 생성.
|
||||
}
|
||||
for (const fname of musicFiles) {
|
||||
throwIfCancelled(cancel)
|
||||
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
||||
const stem = path.basename(fname, path.extname(fname)) // "01"
|
||||
const trackId = `track_${stem}`
|
||||
@@ -102,36 +160,64 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
|
||||
}
|
||||
}
|
||||
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
|
||||
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.png'))
|
||||
.sort()
|
||||
for (const fname of paintingFiles) {
|
||||
throwIfCancelled(cancel)
|
||||
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
|
||||
}
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 4) zip 으로 묶기
|
||||
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
|
||||
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
|
||||
await zipDirectory(root, opts.outZipPath)
|
||||
await zipDirectory(root, opts.outZipPath, cancel)
|
||||
// zip 빌드가 끝난 직후에도 한 번 더 확인: 마지막 순간 취소가 들어왔을 수 있다.
|
||||
if (cancel && cancel()) {
|
||||
// 부분 zip 파일이 디스크에 남아있을 수 있으니 삭제.
|
||||
await fs.rm(opts.outZipPath, { force: true })
|
||||
throw new CancelledError()
|
||||
}
|
||||
|
||||
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
|
||||
}
|
||||
|
||||
function zipDirectory(srcDir: string, outZipPath: string): Promise<void> {
|
||||
function zipDirectory(srcDir: string, outZipPath: string, cancelChecker?: () => boolean): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(outZipPath)
|
||||
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||
output.on('close', () => resolve())
|
||||
output.on('error', reject)
|
||||
// 취소 폴링: archiver 자체는 abort() 후 'error' 이벤트로 ABORT 코드를 던진다.
|
||||
// 200ms 간격이면 사용자 체감으로는 즉각적이면서 CPU 부담은 없다.
|
||||
let interval: NodeJS.Timeout | null = null
|
||||
let aborted = false
|
||||
if (cancelChecker) {
|
||||
interval = setInterval(() => {
|
||||
if (cancelChecker() && !aborted) {
|
||||
aborted = true
|
||||
try { archive.abort() } catch { /* 이미 끝났거나 abort 불가 상태 */ }
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
function cleanup() {
|
||||
if (interval) { clearInterval(interval); interval = null }
|
||||
}
|
||||
output.on('close', () => { cleanup(); if (aborted) reject(new CancelledError()); else resolve() })
|
||||
output.on('error', (err) => { cleanup(); reject(err) })
|
||||
archive.on('warning', (err: Error & { code?: string }) => {
|
||||
// ENOENT 정도면 무시, 그 외는 reject.
|
||||
if (err.code === 'ENOENT') return
|
||||
reject(err)
|
||||
cleanup(); reject(err)
|
||||
})
|
||||
archive.on('error', (err: Error & { code?: string }) => {
|
||||
cleanup()
|
||||
if (err.code === 'ABORT' || aborted) reject(new CancelledError())
|
||||
else reject(err)
|
||||
})
|
||||
archive.on('error', reject)
|
||||
archive.pipe(output)
|
||||
archive.directory(srcDir, false)
|
||||
archive.finalize().catch(reject)
|
||||
archive.finalize().catch((err) => { cleanup(); reject(err) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,10 @@ const api = {
|
||||
selectPack: (packKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke('rp:packs:select', packKey),
|
||||
|
||||
/** 약관(Markdown) 다운로드. kind: 'resourcepack' | 'installer-rp'. */
|
||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||
ipcRenderer.invoke('rp:terms:get', kind),
|
||||
|
||||
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
|
||||
startInstall: (): Promise<{ resourcepackPath: string }> =>
|
||||
ipcRenderer.invoke('rp:install:start'),
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface RpFetchedPack {
|
||||
* 빈 문자열이면 새 리소스팩을 처음부터 생성.
|
||||
*/
|
||||
resourcepackPath: string
|
||||
/**
|
||||
* /manifest/<key>.json 의 outputPackName. 관리 사이트에서 설정한 "생성되는
|
||||
* 리소스팩 이름". 비어 있으면 설치기가 `<key>_resourcepack` 형식으로 폴백.
|
||||
* 파일명으로 쓰기 전에 Windows 금지 문자(\<\>:"/\\|?*) 는 `_` 로 치환.
|
||||
*/
|
||||
outputPackName: string
|
||||
/** /file/list/<key>.json 의 음악·사진 목록. */
|
||||
list: PackList
|
||||
}
|
||||
|
||||
@@ -3,17 +3,38 @@ import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs
|
||||
import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { getMcCustomDir, getMcCustomInstallerDir } from '../shared/paths.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
/**
|
||||
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
|
||||
* 경로: %appdata%/.mc_custom/yt-dlp.exe
|
||||
* 경로: %appdata%/.mc_custom/installer/yt-dlp.exe
|
||||
*/
|
||||
export function getYtDlpExePath(): string {
|
||||
return path.join(getMcCustomDir(), 'yt-dlp.exe')
|
||||
return path.join(getMcCustomInstallerDir(), 'yt-dlp.exe')
|
||||
}
|
||||
|
||||
/**
|
||||
* 0.2.1 이전 버전이 `.mc_custom/yt-dlp.exe` 에 받아둔 파일이 있으면 새 위치로
|
||||
* 옮긴다. 마인크래프트 게임 폴더 루트가 외부 도구 파일로 더럽혀지지 않도록.
|
||||
*/
|
||||
async function migrateLegacyExe(target: string): Promise<void> {
|
||||
const legacy = path.join(getMcCustomDir(), 'yt-dlp.exe')
|
||||
if (legacy === target) return
|
||||
try {
|
||||
await fs.access(legacy, fsConst.F_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.rename(legacy, target)
|
||||
} catch {
|
||||
// 권한·드라이브 문제 등으로 실패하면 그냥 새로 받으면 되므로 무시.
|
||||
try { await fs.unlink(legacy) } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
const YT_DLP_DOWNLOAD_URL =
|
||||
@@ -29,6 +50,7 @@ export async function ensureYtDlpExe(
|
||||
log?: (line: string) => void
|
||||
): Promise<string> {
|
||||
const target = getYtDlpExePath()
|
||||
await migrateLegacyExe(target)
|
||||
if (await canExecute(target)) {
|
||||
log?.(t('log.ytdlpExists', { path: target }))
|
||||
return target
|
||||
|
||||
6
src/installer/launcherIcon.ts
Normal file
6
src/installer/launcherIcon.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@ import type { Manifest, PackDefinition } from '../shared/types.js'
|
||||
import { normalizePackDefinition } from '../shared/store.js'
|
||||
import { loadEnv, getManifestUrl } from '../shared/env.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
import { LAUNCHER_PROFILE_ICON } from './launcherIcon.js'
|
||||
|
||||
loadEnv()
|
||||
|
||||
@@ -63,9 +64,13 @@ function deriveBaseUrl(manifestUrl: string): string {
|
||||
}
|
||||
|
||||
function createMainWindow(): void {
|
||||
// 패키징 시 build/icon.ico, dev 실행 시 build/icon.png 모두 동일 경로에서 발견되도록
|
||||
// 프로젝트 루트의 build/ 를 가리킨다. 파일이 없으면 Electron 이 기본 아이콘으로 fallback.
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 980,
|
||||
height: 720,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
@@ -149,6 +154,25 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
||||
return results
|
||||
})
|
||||
|
||||
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
|
||||
// 화이트리스트로 5종 제한. pack 미선택 상태에서는 에러를 돌려준다. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
|
||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
||||
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
|
||||
if (!TERM_KIND_WHITELIST.has(kind)) {
|
||||
return { ok: false, message: 'unknown term kind' }
|
||||
}
|
||||
if (!state.selectedKey) {
|
||||
return { ok: false, message: 'pack not selected' }
|
||||
}
|
||||
try {
|
||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${kind}.md`
|
||||
const buf = await fetchBuffer(url)
|
||||
return { ok: true, content: buf.toString('utf8') }
|
||||
} catch (error) {
|
||||
return { ok: false, message: (error as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
||||
if (!state.packs.has(packKey)) {
|
||||
throw new Error(t('errors.packNotFound'))
|
||||
@@ -395,21 +419,121 @@ async function downloadServerZip(pack: PackDefinition, targetDir: string): Promi
|
||||
await downloadAndExtractZip(url, t('log.labelServerFile'), targetDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치러가 saves/ 에 풀어놓은 최상위 폴더(또는 파일) 목록을 기록하는 마커 파일.
|
||||
* 재설치 시 잔여물을 안전하게 정리하고, 싱글→참가자 전환 시에도
|
||||
* 사용자가 직접 만든 월드는 보존한 채 설치러가 만든 맵만 제거하기 위함이다.
|
||||
*/
|
||||
const INSTALLER_MAP_MARKER = '.musicquiz-installer-map.json'
|
||||
|
||||
async function readInstallerMapMarker(customRoot: string): Promise<string[]> {
|
||||
const markerPath = path.join(customRoot, 'saves', INSTALLER_MAP_MARKER)
|
||||
try {
|
||||
const raw = await fsp.readFile(markerPath, 'utf8')
|
||||
const data = JSON.parse(raw) as { entries?: unknown }
|
||||
if (Array.isArray(data.entries)) {
|
||||
return data.entries.filter((s): s is string => typeof s === 'string')
|
||||
}
|
||||
} catch {
|
||||
// 마커가 없거나 파싱 실패 — 빈 목록 반환
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async function writeInstallerMapMarker(customRoot: string, entries: string[]): Promise<void> {
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
await fsp.mkdir(savesDir, { recursive: true })
|
||||
const markerPath = path.join(savesDir, INSTALLER_MAP_MARKER)
|
||||
await fsp.writeFile(markerPath, JSON.stringify({ entries }, null, 2), 'utf8')
|
||||
}
|
||||
|
||||
async function cleanupInstallerMap(customRoot: string): Promise<void> {
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
const entries = await readInstallerMapMarker(customRoot)
|
||||
if (entries.length === 0) return
|
||||
sendLog(t('log.cleanupInstallerMap', { count: entries.length }))
|
||||
for (const name of entries) {
|
||||
// 안전장치: 경로 구분자/상대경로 토큰이 섞인 항목은 무시
|
||||
if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') continue
|
||||
const target = path.join(savesDir, name)
|
||||
await fsp.rm(target, { recursive: true, force: true })
|
||||
}
|
||||
await fsp.rm(path.join(savesDir, INSTALLER_MAP_MARKER), { force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 폴더 이름으로 쓸 수 없는 문자를 모두 `_` 로 치환.
|
||||
* 금지 문자: `<>:"/\|?*` 와 제어 문자(0x00~0x1f)
|
||||
* 추가 제한: 끝의 공백/마침표 제거, 빈 문자열 fallback, 예약 이름(CON, NUL 등) 회피.
|
||||
* 참고: https://learn.microsoft.com/windows/win32/fileio/naming-a-file
|
||||
*/
|
||||
function sanitizeMapFolderName(name: string): string {
|
||||
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
cleaned = cleaned.replace(/[ .]+$/, '')
|
||||
if (!cleaned) cleaned = 'map'
|
||||
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
|
||||
return cleaned
|
||||
}
|
||||
|
||||
async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
if (!pack.mapPath) {
|
||||
sendLog(t('log.skipMapZip'))
|
||||
return
|
||||
}
|
||||
// 이전 설치러가 풀어놓은 맵이 남아 있으면 먼저 제거 (다른 팩/재설치 시 잔여물 방지).
|
||||
await cleanupInstallerMap(customRoot)
|
||||
const url = resolveManifestRelative(pack.mapPath, 'maps')
|
||||
const savesDir = path.join(customRoot, 'saves')
|
||||
await downloadAndExtractZip(url, t('log.labelMap'), savesDir)
|
||||
await fsp.mkdir(savesDir, { recursive: true })
|
||||
|
||||
// zip 의 최상위 구조(단일 폴더 / 루트에 level.dat) 와 관계없이 최종 폴더 이름이
|
||||
// 항상 퀴즈 이름이 되도록, 우선 saves/ 안의 임시 폴더에 풀고 적절히 옮긴다.
|
||||
// saves 와 같은 디렉터리에서 만들기 때문에 rename 이 cross-device 실패 없이 동작.
|
||||
const tempExtractDir = await fsp.mkdtemp(path.join(savesDir, '.mq-map-extract-'))
|
||||
try {
|
||||
await downloadAndExtractZip(url, t('log.labelMap'), tempExtractDir)
|
||||
|
||||
// zip 이 단일 최상위 폴더면 그 안을 월드 콘텐츠로, 아니면 임시 디렉터리 자체가
|
||||
// 월드 콘텐츠(level.dat 등이 루트). 어느 쪽이든 결과적으로 saves/<퀴즈이름>/ 로.
|
||||
const entries = await fsp.readdir(tempExtractDir)
|
||||
let sourceDir = tempExtractDir
|
||||
if (entries.length === 1) {
|
||||
const candidate = path.join(tempExtractDir, entries[0])
|
||||
const stat = await fsp.stat(candidate).catch(() => null)
|
||||
if (stat?.isDirectory()) sourceDir = candidate
|
||||
}
|
||||
|
||||
const desired = sanitizeMapFolderName(pack.name)
|
||||
// 사용자가 직접 만든 동명 월드와 겹치면 덮어쓰지 않고 _2, _3 … 으로 회피.
|
||||
let target = desired
|
||||
let suffix = 2
|
||||
while (fs.existsSync(path.join(savesDir, target))) {
|
||||
target = `${desired}_${suffix}`
|
||||
suffix++
|
||||
}
|
||||
const targetDir = path.join(savesDir, target)
|
||||
await fsp.rename(sourceDir, targetDir)
|
||||
sendLog(t('log.mapInstalledAs', { name: target }))
|
||||
await writeInstallerMapMarker(customRoot, [target])
|
||||
} finally {
|
||||
// sourceDir 가 tempExtractDir 자체였다면 rename 으로 사라졌고, 단일 하위 폴더였다면
|
||||
// 비어 있는 껍데기만 남아 있다. 어느 경우든 안전하게 정리.
|
||||
await fsp.rm(tempExtractDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||
// 바닐라 팩(modsFolder 비어 있음)은 모드 자체와 무관하므로 기존 mods/ 를 건드리지
|
||||
// 않는다 — 사용자가 다른 곳에서 받아 둔 모드까지 지워버리는 부작용 방지.
|
||||
if (!pack.modsFolder) {
|
||||
sendLog(t('log.skipModsFolder'))
|
||||
return
|
||||
}
|
||||
const modsDir = path.join(customRoot, 'mods')
|
||||
// 모드팩인 경우엔 이전 버전/이전 팩 모드가 섞이면 로딩이 실패하므로 매번 비우고 받는다.
|
||||
sendLog(t('log.clearMods', { dir: modsDir }))
|
||||
await fsp.rm(modsDir, { recursive: true, force: true })
|
||||
await fsp.mkdir(modsDir, { recursive: true })
|
||||
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
|
||||
sendLog(t('log.modsIndexFetch', { url: indexUrl }))
|
||||
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
|
||||
@@ -420,8 +544,6 @@ async function downloadModsFolder(pack: PackDefinition, customRoot: string): Pro
|
||||
sendLog(t('log.modsFolderEmpty', { folder: pack.modsFolder }))
|
||||
return
|
||||
}
|
||||
const modsDir = path.join(customRoot, 'mods')
|
||||
await fsp.mkdir(modsDir, { recursive: true })
|
||||
for (const fileName of files) {
|
||||
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
|
||||
const target = path.join(modsDir, fileName)
|
||||
@@ -1026,34 +1148,49 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
|
||||
await fsp.mkdir(path.join(customRoot, 'mods'), { recursive: true })
|
||||
await fsp.mkdir(path.join(customRoot, 'resourcepacks'), { recursive: true })
|
||||
|
||||
// 사용자가 기존 .minecraft 에 저장해둔 설정(options.txt, servers.dat 등)을
|
||||
// .mc_custom 으로 가져온다. 이미 있는 파일은 보존.
|
||||
await copyMinecraftUserSettings(customRoot)
|
||||
try {
|
||||
// 사용자가 기존 .minecraft 에 저장해둔 설정(options.txt, servers.dat 등)을
|
||||
// .mc_custom 으로 가져온다. 이미 있는 파일은 보존.
|
||||
await copyMinecraftUserSettings(customRoot)
|
||||
|
||||
if (payload.installPlatform && pack.pack.platform.type === 'fabric') {
|
||||
await installFabricLoader(pack.pack, customRoot)
|
||||
} else if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
|
||||
const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms')
|
||||
const cacheDir = path.join(customRoot, 'platform-cache')
|
||||
await fsp.mkdir(cacheDir, { recursive: true })
|
||||
const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
|
||||
sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl }))
|
||||
await downloadFile(platformUrl, installerPath)
|
||||
sendLog(t('log.platformSaved', { path: installerPath }))
|
||||
} else if (!payload.installPlatform) {
|
||||
sendLog(t('log.platformSkipped'))
|
||||
if (payload.installPlatform && pack.pack.platform.type === 'fabric') {
|
||||
await installFabricLoader(pack.pack, customRoot)
|
||||
} else if (payload.installPlatform && pack.pack.platform.type !== 'vanilla' && pack.pack.platform.downloadUrl) {
|
||||
const platformUrl = resolveManifestRelative(pack.pack.platform.downloadUrl, 'platforms')
|
||||
const cacheDir = path.join(customRoot, 'platform-cache')
|
||||
await fsp.mkdir(cacheDir, { recursive: true })
|
||||
const installerPath = path.join(cacheDir, deriveFileName(platformUrl) || 'platform-installer.jar')
|
||||
sendLog(t('log.platformDownload', { type: pack.pack.platform.type, url: platformUrl }))
|
||||
await downloadFile(platformUrl, installerPath)
|
||||
sendLog(t('log.platformSaved', { path: installerPath }))
|
||||
} else if (!payload.installPlatform) {
|
||||
sendLog(t('log.platformSkipped'))
|
||||
}
|
||||
|
||||
await downloadModsFolder(pack.pack, customRoot)
|
||||
await downloadResourcepackZip(pack.pack, customRoot)
|
||||
|
||||
if (payload.skipMap) {
|
||||
// 참가자 모드: 이전 설치 흐름에서 설치러가 풀어둔 맵이 있다면 제거한다.
|
||||
// 사용자가 직접 만든 월드는 마커에 포함되지 않으므로 그대로 보존된다.
|
||||
await cleanupInstallerMap(customRoot)
|
||||
sendLog(t('log.skipMapZip'))
|
||||
} else {
|
||||
await downloadMapZip(pack.pack, customRoot)
|
||||
}
|
||||
|
||||
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
|
||||
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.
|
||||
await linkMinecraftRuntimeDirs(customRoot)
|
||||
|
||||
await updateLauncherProfile(pack.pack, customRoot)
|
||||
} finally {
|
||||
// 설치가 끝나면(또는 실패해도) 더 이상 필요 없는 platform-cache(다운받은
|
||||
// fabric/forge/neoforge installer jar 캐시)를 삭제한다. 다음 실행에서 다시
|
||||
// 받으면 되고, 남겨두면 사용자 .mc_custom 폴더만 차지한다. 실패 경로에서도
|
||||
// 정리되도록 finally 에 둔다.
|
||||
await fsp.rm(path.join(customRoot, 'platform-cache'), { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
await downloadModsFolder(pack.pack, customRoot)
|
||||
await downloadResourcepackZip(pack.pack, customRoot)
|
||||
|
||||
await downloadMapZip(pack.pack, customRoot)
|
||||
|
||||
// 런처가 .mc_custom 을 gameDir 로 잡아도 assets/libraries/versions 를
|
||||
// 찾을 수 있도록 .minecraft 의 해당 폴더로 junction 링크.
|
||||
await linkMinecraftRuntimeDirs(customRoot)
|
||||
|
||||
await updateLauncherProfile(pack.pack, customRoot)
|
||||
})
|
||||
|
||||
interface FabricInstallerMeta {
|
||||
@@ -1068,6 +1205,19 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
|
||||
throw new Error(t('errors.fabricLoaderRequired'))
|
||||
}
|
||||
|
||||
// 0) 이미 설치돼 있으면 건너뛴다. fabric-installer 는 매번 jar 를 지우고
|
||||
// 다시 쓰려고 시도해서, 마인크래프트나 다른 프로세스가 그 파일을 잡고
|
||||
// 있으면 FileSystemException 으로 실패한다. 결과 파일이 그대로 있으면
|
||||
// 재실행할 필요가 없으므로 그냥 통과.
|
||||
const versionId = `fabric-loader-${loaderVersion}-${pack.mcVersion}`
|
||||
const versionDir = path.join(customRoot, 'versions', versionId)
|
||||
const versionJar = path.join(versionDir, `${versionId}.jar`)
|
||||
const versionJson = path.join(versionDir, `${versionId}.json`)
|
||||
if (fs.existsSync(versionJar) && fs.existsSync(versionJson)) {
|
||||
sendLog(t('log.fabricAlreadyInstalled', { id: versionId, dir: versionDir }))
|
||||
return
|
||||
}
|
||||
|
||||
// 1) 최신 fabric-installer 메타데이터 조회.
|
||||
sendLog(t('log.fabricFetchInstallerList'))
|
||||
const installerList = await fetchJson<FabricInstallerMeta[]>('https://meta.fabricmc.net/v2/versions/installer')
|
||||
@@ -1089,7 +1239,16 @@ async function installFabricLoader(pack: PackDefinition, customRoot: string): Pr
|
||||
|
||||
// 4) fabric-installer CLI 자동 실행.
|
||||
// client 모드 + -noprofile: launcher_profiles.json 은 우리 코드가 직접 갱신하므로 fabric-installer 가 덮어쓰지 않게 한다.
|
||||
// JVM stdout 인코딩 강제 UTF-8:
|
||||
// 한국 윈도우의 시스템 codepage 는 cp949(MS949) 라서 fabric-installer 가
|
||||
// 한글을 cp949 로 stdout 에 쓰면 우리가 utf-8 로 디코드해서 깨져 보인다.
|
||||
// `file.encoding` 은 default Charset, `stdout/stderr.encoding` 은
|
||||
// System.out/err 의 PrintStream 인코딩(Java 18+). 둘 다 지정하면
|
||||
// 구버전·신버전 JDK 모두에서 안전.
|
||||
const args = [
|
||||
'-Dfile.encoding=UTF-8',
|
||||
'-Dstdout.encoding=UTF-8',
|
||||
'-Dstderr.encoding=UTF-8',
|
||||
'-jar', installerJar,
|
||||
'client',
|
||||
'-mcversion', pack.mcVersion,
|
||||
@@ -1330,6 +1489,7 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
...existingProfile,
|
||||
name: profileKey,
|
||||
type: 'custom',
|
||||
icon: LAUNCHER_PROFILE_ICON,
|
||||
gameDir,
|
||||
lastVersionId,
|
||||
javaArgs
|
||||
@@ -1341,9 +1501,18 @@ async function updateLauncherProfile(pack: PackDefinition, gameDir: string): Pro
|
||||
/**
|
||||
* 사용자가 기존에 .minecraft 에 만들어둔 설정 파일들(options.txt, optionsof.txt,
|
||||
* servers.dat, usercache.json 등 최상위 파일 전부)을 .mc_custom 으로 복사한다.
|
||||
* 이미 .mc_custom 에 같은 이름의 파일이 있으면 보존(덮어쓰지 않음).
|
||||
* 기본 규칙은 "이미 .mc_custom 에 같은 이름의 파일이 있으면 보존" 이지만,
|
||||
* ALWAYS_SYNC_FILES 목록에 든 파일(=사용자가 원래 .minecraft 에서 쓰던
|
||||
* 설정을 그대로 이어 쓰고 싶은 옵션 파일들)은 매번 .minecraft 쪽으로
|
||||
* 덮어써서 동기화한다.
|
||||
* 디렉터리(mods/saves/versions/assets 등)는 각자 별도 처리하므로 여기서는 건드리지 않는다.
|
||||
*/
|
||||
const ALWAYS_SYNC_FILES = new Set([
|
||||
'options.txt',
|
||||
'optionsof.txt',
|
||||
'optionsshaders.txt'
|
||||
])
|
||||
|
||||
async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
const mcRoot = path.join(getAppDataDir(), '.minecraft')
|
||||
if (!fs.existsSync(mcRoot)) {
|
||||
@@ -1352,24 +1521,28 @@ async function copyMinecraftUserSettings(customRoot: string): Promise<void> {
|
||||
}
|
||||
let copied = 0
|
||||
let skipped = 0
|
||||
let synced = 0
|
||||
try {
|
||||
const entries = await fsp.readdir(mcRoot, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
const src = path.join(mcRoot, entry.name)
|
||||
const dst = path.join(customRoot, entry.name)
|
||||
if (fs.existsSync(dst)) {
|
||||
const dstExists = fs.existsSync(dst)
|
||||
const alwaysSync = ALWAYS_SYNC_FILES.has(entry.name)
|
||||
if (dstExists && !alwaysSync) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await fsp.copyFile(src, dst)
|
||||
copied += 1
|
||||
if (dstExists) synced += 1
|
||||
else copied += 1
|
||||
} catch (err) {
|
||||
sendLog(t('log.settingCopyFail', { name: entry.name, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
sendLog(t('log.settingCopySummary', { copied, skipped }))
|
||||
sendLog(t('log.settingCopySummary', { copied, skipped, synced }))
|
||||
} catch (err) {
|
||||
sendLog(t('log.settingCopyError', { message: (err as Error).message }))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ const api = {
|
||||
setSelectedPack: (packKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke('packs:select', packKey),
|
||||
|
||||
// 약관(Markdown) 다운로드
|
||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||
ipcRenderer.invoke('terms:get', kind),
|
||||
|
||||
// 3-1
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
||||
validateInstallPath: (target: string): Promise<{ ok: boolean; message?: string }> =>
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ServerInstallPayload {
|
||||
export interface ClientInstallPayload {
|
||||
packKey: string
|
||||
installPlatform: boolean
|
||||
/** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */
|
||||
skipMap?: boolean
|
||||
}
|
||||
|
||||
export interface RamCheckResult {
|
||||
|
||||
@@ -2,7 +2,11 @@ import express from 'express'
|
||||
import session from 'express-session'
|
||||
import path from 'node:path'
|
||||
import fsp from 'node:fs/promises'
|
||||
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
|
||||
import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
fileDirPath, viewsDirPath, publicDirPath
|
||||
} from '../shared/paths.js'
|
||||
import { ensurePackTermsDir, isPublicTermsFile, loadPackDefinition } from '../shared/store.js'
|
||||
import { loadEnv } from '../shared/env.js'
|
||||
import { t, localeDict } from './i18n.js'
|
||||
import { indexRouter } from './routes/index.js'
|
||||
@@ -59,6 +63,37 @@ app.get('/manifest.json', (_req, res) => {
|
||||
res.sendFile(manifestRootPath)
|
||||
})
|
||||
|
||||
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
||||
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||
//
|
||||
// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
|
||||
// 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1
|
||||
// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가
|
||||
// 생성되는 것은 loadPackDefinition 으로 차단.
|
||||
app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => {
|
||||
try {
|
||||
const { packKey, fileName } = req.params
|
||||
if (!isPublicTermsFile(packKey, fileName)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
const pack = await loadPackDefinition(packKey)
|
||||
if (!pack) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
await ensurePackTermsDir(packKey)
|
||||
res.type('text/markdown; charset=utf-8')
|
||||
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
|
||||
if (!err || res.headersSent) return
|
||||
res.status(404).send('Not Found')
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
||||
// 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단.
|
||||
app.get('/manifest/:fileName', (req, res) => {
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
import { Router } from 'express'
|
||||
import archiver from 'archiver'
|
||||
import {
|
||||
createPack,
|
||||
createTerm,
|
||||
deletePackKeys,
|
||||
deleteTerm,
|
||||
getTermLabel,
|
||||
importTerms,
|
||||
isBuiltinTermKind,
|
||||
isTermKind,
|
||||
listPackKeys,
|
||||
listTermsWithLabels,
|
||||
loadPackDefinition,
|
||||
loadPackList,
|
||||
loadTerm,
|
||||
normalizePackDefinition,
|
||||
normalizePackList,
|
||||
readAccounts,
|
||||
renamePack,
|
||||
sanitizePackKey,
|
||||
saveTerm,
|
||||
savePackList
|
||||
} from '../../shared/store.js'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||
@@ -17,6 +27,7 @@ import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../
|
||||
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()
|
||||
|
||||
@@ -223,17 +234,19 @@ 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))
|
||||
@@ -243,25 +256,216 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
|
||||
return
|
||||
}
|
||||
const list = await loadPackList(packKey)
|
||||
const lines: string[] = []
|
||||
lines.push(t('datapackOutput.header', { name: definition.name }))
|
||||
lines.push(t('datapackOutput.summary', {
|
||||
musicCount: list.music.length,
|
||||
imageCount: list.images.length
|
||||
}))
|
||||
lines.push(t('datapackOutput.initLine'))
|
||||
lines.push(t('datapackOutput.placeholder'))
|
||||
list.music.forEach((entry, index) => {
|
||||
const title = entry.title || t('datapackOutput.titleFallback')
|
||||
const artist = entry.artist || t('datapackOutput.artistFallback')
|
||||
lines.push(t('datapackOutput.trackLine', {
|
||||
index: index + 1,
|
||||
title,
|
||||
artist,
|
||||
duration: entry.durationSec
|
||||
}))
|
||||
res.type('text/plain; charset=utf-8').send(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)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
||||
// builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는
|
||||
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<packKey>/<kind>.md 로 받아 표시한다.
|
||||
|
||||
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
||||
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const keys = await listPackKeys()
|
||||
const items = await Promise.all(keys.map(async (key) => ({
|
||||
key,
|
||||
definition: await loadPackDefinition(key)
|
||||
})))
|
||||
res.render('op/terms', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가/불러오기/삭제.
|
||||
opRouter.get('/op/agreement/:packName', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const items = await listTermsWithLabels(packKey)
|
||||
// 불러오기 source 후보: 현재 pack 을 제외한 나머지.
|
||||
const allKeys = await listPackKeys()
|
||||
const sourceCandidates = await Promise.all(
|
||||
allKeys
|
||||
.filter((k) => k !== packKey)
|
||||
.map(async (k) => ({ key: k, definition: await loadPackDefinition(k) }))
|
||||
)
|
||||
res.render('op/terms-pack', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
items,
|
||||
sourceCandidates
|
||||
})
|
||||
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/create', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const kindInput = pickFirstValue(req.body.kind).trim().toLowerCase()
|
||||
const label = pickFirstValue(req.body.label)
|
||||
if (!isTermKind(kindInput)) {
|
||||
res.status(400).send(t('terms.invalidKind'))
|
||||
return
|
||||
}
|
||||
await createTerm(packKey, kindInput, label)
|
||||
res.redirect(`/op/agreement/${packKey}/${kindInput}`)
|
||||
} catch (error) {
|
||||
res.status(400).send((error as Error).message || t('terms.createFailed'))
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/import', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const sourceKey = sanitizePackKey(pickFirstValue(req.body.source))
|
||||
if (!sourceKey || sourceKey === packKey) {
|
||||
res.status(400).send(t('terms.invalidImportSource'))
|
||||
return
|
||||
}
|
||||
const sourceDefinition = await loadPackDefinition(sourceKey)
|
||||
if (!sourceDefinition) {
|
||||
res.status(404).send(t('terms.invalidImportSource'))
|
||||
return
|
||||
}
|
||||
await importTerms(packKey, sourceKey)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (error) {
|
||||
res.status(400).send((error as Error).message || t('terms.importFailed'))
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(400).send(t('terms.invalidKind'))
|
||||
return
|
||||
}
|
||||
if (isBuiltinTermKind(kind)) {
|
||||
res.status(400).send(t('terms.cannotDeleteBuiltin'))
|
||||
return
|
||||
}
|
||||
await deleteTerm(packKey, kind)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(404).send(t('errors.unknown'))
|
||||
return
|
||||
}
|
||||
const content = await loadTerm(packKey, kind)
|
||||
const label = await getTermLabel(packKey, kind)
|
||||
res.render('op/termsEditor', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
kind,
|
||||
label,
|
||||
content
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
|
||||
return
|
||||
}
|
||||
const kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(404).json({ ok: false, message: t('errors.unknown') })
|
||||
return
|
||||
}
|
||||
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
||||
await saveTerm(packKey, kind, content)
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
@@ -286,6 +490,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
} as PackDefinition['platform'] & { loaderVersion?: string },
|
||||
modsFolder: pickFirstValue(req.body.modsFolder),
|
||||
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
|
||||
outputPackName: pickFirstValue(req.body.outputPackName),
|
||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import os from 'node:os'
|
||||
export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const manifestTermsDirPath = path.join(manifestDirPath, 'terms')
|
||||
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')
|
||||
|
||||
@@ -31,3 +33,13 @@ export function getAppDataDir(): string {
|
||||
export function getMcCustomDir(): string {
|
||||
return path.join(getAppDataDir(), '.mc_custom')
|
||||
}
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/installer — 설치기가 자체적으로 다운로드해 사용하는
|
||||
* 외부 바이너리(yt-dlp.exe, ffmpeg.exe 등) 보관 위치. .mc_custom 루트가
|
||||
* 마인크래프트 게임 폴더(`mods/`, `resourcepacks/`, `saves/` 등)와 섞이지
|
||||
* 않도록 별도 하위 폴더에 둔다.
|
||||
*/
|
||||
export function getMcCustomInstallerDir(): string {
|
||||
return path.join(getMcCustomDir(), 'installer')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths.js'
|
||||
import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
accountFilePath, fileListDirPath
|
||||
} from './paths.js'
|
||||
import type {
|
||||
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
|
||||
PackList, MusicListEntry, ImageListEntry
|
||||
@@ -37,6 +40,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
|
||||
platform: { type: 'vanilla' },
|
||||
modsFolder: '',
|
||||
resourcepackPath: '',
|
||||
outputPackName: '',
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 2048,
|
||||
@@ -81,8 +85,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()
|
||||
@@ -95,6 +99,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
||||
},
|
||||
modsFolder: sanitizeFolderName(input.modsFolder),
|
||||
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
|
||||
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
|
||||
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
|
||||
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
||||
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
||||
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
||||
@@ -172,6 +178,14 @@ export async function deletePackKeys(keys: string[]): Promise<void> {
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
// pack 이 삭제되면 약관 폴더도 함께 정리한다. 동일 packKey 로 재생성될 때
|
||||
// 옛 약관이 부활하는 것을 막기 위함.
|
||||
const termsDir = path.join(manifestTermsDirPath, key)
|
||||
try {
|
||||
await fsp.rm(termsDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await syncManifestWith(key, '', 'remove')
|
||||
}
|
||||
}
|
||||
@@ -192,6 +206,19 @@ export async function renamePack(oldKey: string, newKey: string, pack: PackDefin
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
// 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이
|
||||
// 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다.
|
||||
const oldTermsDir = path.join(manifestTermsDirPath, oldKey)
|
||||
const newTermsDir = path.join(manifestTermsDirPath, safeNew)
|
||||
try {
|
||||
await fsp.rename(oldTermsDir, newTermsDir)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
// 옛 약관 폴더가 없으면 그대로 둔다. 새 폴더가 이미 있어 충돌하면 그것도 그냥 둔다
|
||||
// (renamePack 단계에서 사용자에게 보낼 마땅한 UX 가 없고, 다음 약관 접근 때
|
||||
// 새 폴더 내용이 정상적으로 사용된다).
|
||||
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error
|
||||
}
|
||||
await syncManifestWith(oldKey, '', 'remove')
|
||||
}
|
||||
await syncManifestWith(safeNew, pack.name, 'upsert')
|
||||
@@ -229,6 +256,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 +286,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
|
||||
@@ -272,6 +315,291 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
||||
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
||||
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
|
||||
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `<packKey>/_meta.json` 에 저장.
|
||||
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
|
||||
export type TermKind = string
|
||||
|
||||
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
|
||||
export const BUILTIN_TERM_KINDS = ['map', 'resourcepack', 'mod', 'installer', 'installer-rp'] as const
|
||||
export type BuiltinTermKind = typeof BUILTIN_TERM_KINDS[number]
|
||||
|
||||
/** builtin 라벨. 사용자 정의 kind 는 _meta.json 에 저장된 라벨을 쓴다. */
|
||||
const BUILTIN_TERM_LABELS: Record<BuiltinTermKind, string> = {
|
||||
'map': '맵 약관',
|
||||
'resourcepack': '리소스팩 약관',
|
||||
'mod': '모드 약관',
|
||||
'installer': '설치기 약관',
|
||||
'installer-rp': '리소스팩 설치기 약관'
|
||||
}
|
||||
|
||||
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
|
||||
export function isTermKind(value: unknown): value is TermKind {
|
||||
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
||||
}
|
||||
|
||||
export function isBuiltinTermKind(value: string): value is BuiltinTermKind {
|
||||
return (BUILTIN_TERM_KINDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
interface TermsMeta {
|
||||
/** 사용자 정의 kind 라벨. builtin 은 들어가지 않는다. */
|
||||
customLabels: Record<string, string>
|
||||
}
|
||||
|
||||
const TERMS_META_FILE = '_meta.json'
|
||||
|
||||
function termsDirForPack(packKey: string): string {
|
||||
return path.join(manifestTermsDirPath, packKey)
|
||||
}
|
||||
|
||||
function isValidPackKey(packKey: string): boolean {
|
||||
return typeof packKey === 'string'
|
||||
&& packKey.length > 0
|
||||
&& /^[a-zA-Z0-9_\-]+$/.test(packKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
|
||||
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
|
||||
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
|
||||
*
|
||||
* 공개 라우트(`/manifest/terms/<packKey>/<file>`) 에서도 호출되므로 export 한다.
|
||||
* 라우트 측은 packKey 가 실제 존재하는 pack 인지 확인한 다음에 호출해야 한다
|
||||
* (그렇지 않으면 임의 키로 빈 폴더가 생성될 수 있다).
|
||||
*/
|
||||
export async function ensurePackTermsDir(packKey: string): Promise<string> {
|
||||
const dir = termsDirForPack(packKey)
|
||||
try {
|
||||
await fsp.access(dir)
|
||||
return dir
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
// 레거시 전역 파일을 시드로 복사.
|
||||
try {
|
||||
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
|
||||
for (const ent of legacyEntries) {
|
||||
if (!ent.isFile()) continue
|
||||
const name = ent.name
|
||||
if (name === TERMS_META_FILE) {
|
||||
try {
|
||||
await fsp.copyFile(
|
||||
path.join(manifestTermsDirPath, name),
|
||||
path.join(dir, name)
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
continue
|
||||
}
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const kind = name.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
try {
|
||||
await fsp.copyFile(
|
||||
path.join(manifestTermsDirPath, name),
|
||||
path.join(dir, name)
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(dir, TERMS_META_FILE), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const customLabels: Record<string, string> = {}
|
||||
if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') {
|
||||
for (const [k, v] of Object.entries(parsed.customLabels as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && TERM_KIND_RE.test(k)) customLabels[k] = v
|
||||
}
|
||||
}
|
||||
return { customLabels }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { customLabels: {} }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
await fsp.writeFile(
|
||||
path.join(dir, TERMS_META_FILE),
|
||||
`${JSON.stringify(meta, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export interface TermItem {
|
||||
kind: string
|
||||
label: string
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크의 .md 파일 + _meta.json 을 합쳐 약관 목록을 만든다.
|
||||
* - builtin 5종은 파일 존재 여부와 무관하게 항상 포함된다 (인스톨러가 fetch 하므로).
|
||||
* - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함.
|
||||
* - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지.
|
||||
*/
|
||||
export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
const items: TermItem[] = []
|
||||
for (const kind of BUILTIN_TERM_KINDS) {
|
||||
items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true })
|
||||
}
|
||||
// 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출.
|
||||
let onDisk: string[] = []
|
||||
try {
|
||||
onDisk = await fsp.readdir(dir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const customKinds = new Set<string>()
|
||||
for (const fname of onDisk) {
|
||||
if (!fname.toLowerCase().endsWith('.md')) continue
|
||||
const kind = fname.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
if (isBuiltinTermKind(kind)) continue
|
||||
customKinds.add(kind)
|
||||
}
|
||||
// _meta.json 에 라벨이 등록된 것만 노출 (라벨 없는 orphan .md 는 무시).
|
||||
for (const kind of Object.keys(meta.customLabels).sort((a, b) => a.localeCompare(b, 'ko'))) {
|
||||
if (!customKinds.has(kind)) continue
|
||||
items.push({ kind, label: meta.customLabels[kind], builtin: false })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
|
||||
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
return meta.customLabels[kind] ?? kind
|
||||
}
|
||||
|
||||
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
|
||||
if (!isTermKind(kind)) return ''
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTerm(packKey: string, kind: TermKind, markdown: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
|
||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
|
||||
export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created')
|
||||
const cleanLabel = label.trim()
|
||||
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.customLabels[kind]) throw new Error('term kind already exists')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
||||
try {
|
||||
await fsp.access(filePath)
|
||||
throw new Error('term file already exists')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
||||
meta.customLabels[kind] = cleanLabel
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
|
||||
/** 사용자 정의 약관 삭제. builtin 은 거부. */
|
||||
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.customLabels[kind]) {
|
||||
delete meta.customLabels[kind]
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
||||
* - source 의 모든 .md 와 _meta.json 을 target 에 덮어쓴다.
|
||||
* - target 에만 있던 사용자 정의 약관은 그대로 둔다 (source 에는 없으니 안 건드림).
|
||||
* - 동일한 kind 가 source 에도 있다면 source 값으로 덮어씀.
|
||||
*/
|
||||
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
|
||||
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
|
||||
throw new Error('invalid pack key')
|
||||
}
|
||||
if (targetPackKey === sourcePackKey) throw new Error('source and target are identical')
|
||||
const sourceDir = await ensurePackTermsDir(sourcePackKey)
|
||||
const targetDir = await ensurePackTermsDir(targetPackKey)
|
||||
|
||||
const sourceMeta = await loadTermsMeta(sourcePackKey)
|
||||
const targetMeta = await loadTermsMeta(targetPackKey)
|
||||
|
||||
// source 의 .md 파일을 모두 target 으로 복사.
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = await fsp.readdir(sourceDir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const kind = name.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
|
||||
}
|
||||
|
||||
// 사용자 정의 라벨도 source 기준으로 머지 (덮어쓰기).
|
||||
const mergedLabels: Record<string, string> = { ...targetMeta.customLabels }
|
||||
for (const [k, v] of Object.entries(sourceMeta.customLabels)) {
|
||||
mergedLabels[k] = v
|
||||
}
|
||||
await saveTermsMeta(targetPackKey, { customLabels: mergedLabels })
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 라우트(`/manifest/terms/<packKey>/<file>`)에서 호출.
|
||||
* - packKey 가 영문/숫자/언더스코어/하이픈만 사용했는지 검사.
|
||||
* - 파일명이 .md 로 끝나고 정상 kind 패턴인지 검사.
|
||||
* - _meta.json 같은 시스템 파일은 차단.
|
||||
*/
|
||||
export function isPublicTermsFile(packKey: string, fileName: string): boolean {
|
||||
if (!isValidPackKey(packKey)) return false
|
||||
if (!fileName.toLowerCase().endsWith('.md')) return false
|
||||
const kind = fileName.slice(0, -3)
|
||||
return TERM_KIND_RE.test(kind)
|
||||
}
|
||||
|
||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
|
||||
@@ -16,6 +16,14 @@ export interface PackDefinition {
|
||||
modsFolder: string
|
||||
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
|
||||
resourcepackPath: string
|
||||
/**
|
||||
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
|
||||
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.
|
||||
* 마인크래프트 리소스팩 목록에서 사용자에게 제목처럼 보이는 값이므로
|
||||
* 한글 등 자유 입력을 그대로 보존하고, 파일 시스템에서 사용할 때 금지 문자만
|
||||
* `_` 로 치환한다(치환 책임은 설치기 측에 있음).
|
||||
*/
|
||||
outputPackName: string
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
@@ -47,6 +55,8 @@ export interface MusicListEntry {
|
||||
artist: string
|
||||
/** 노래 길이 (초). */
|
||||
durationSec: number
|
||||
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
export interface ImageListEntry {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<div class="dashboardActions">
|
||||
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
|
||||
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
|
||||
<a class="secondaryButton" href="/op/agreement"><%= t('dashboard.editTerms') %></a>
|
||||
<form method="post" action="/op/dashboard/create" class="inlineForm">
|
||||
<button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button>
|
||||
</form>
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('datapack.hint') %></p>
|
||||
|
||||
<section class="dpControls">
|
||||
<button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
|
||||
<span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
|
||||
@@ -25,6 +27,9 @@
|
||||
<p class="muted" id="countLabel"></p>
|
||||
|
||||
<section class="dpActions" hidden id="dpActions">
|
||||
<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>
|
||||
@@ -42,7 +47,10 @@
|
||||
<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) { %>
|
||||
@@ -60,8 +68,6 @@
|
||||
|
||||
<script>
|
||||
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
|
||||
// 데이터팩 출력 본문의 "총 N곡" 패턴은 datapackOutput.summary 와 동일.
|
||||
var SUMMARY_PATTERN = <%- JSON.stringify(localeDict.datapackOutput.summary) %>;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
@@ -76,16 +82,26 @@
|
||||
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')
|
||||
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 () {})
|
||||
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 () {
|
||||
@@ -102,13 +118,25 @@
|
||||
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 = I18N.totalCount.replace('{{count}}', m[1])
|
||||
s.textContent = I18N.exported
|
||||
})
|
||||
.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
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformDownloadField">
|
||||
<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"><%- t('editor.platformDownloadHint') %></small>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformLoaderField" hidden>
|
||||
<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=""><%= t('common.loading') %></option>
|
||||
@@ -98,6 +98,11 @@
|
||||
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
|
||||
<small class="muted"><%= t('editor.resourcepackHint') %></small>
|
||||
</label>
|
||||
<label class="fullSpan">
|
||||
<span><%= t('editor.outputPackName') %></span>
|
||||
<input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" />
|
||||
<small class="muted"><%= t('editor.outputPackNameHint') %></small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
|
||||
@@ -131,9 +136,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', '')
|
||||
|
||||
@@ -106,6 +106,24 @@
|
||||
</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">
|
||||
|
||||
147
views/op/terms-pack.ejs
Normal file
147
views/op/terms-pack.ejs
Normal file
@@ -0,0 +1,147 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.packBrowserTitle', { name: pack.name }) %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<style>
|
||||
/* 약관 목록 — 카드 한 줄(가로 풀폭) 씩 세로로 쌓이도록. */
|
||||
.termsList { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
|
||||
.termsRow {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.termsRow .termsRowMain { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.termsRow .termsRowLabel { display: flex; align-items: center; gap: 8px; }
|
||||
.termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; }
|
||||
.termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||
.termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; }
|
||||
.builtinBadge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
background: rgba(255,255,255,0.08); color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.termsSideBySide {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.termsSideBySide { grid-template-columns: 1fr; }
|
||||
}
|
||||
.termsSection {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.termsSection h2 { margin: 0 0 12px; font-size: 15px; }
|
||||
.termsAddForm { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: end; }
|
||||
.termsAddForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.termsAddForm label { font-size: 12px; color: var(--text-muted); }
|
||||
.termsAddForm input, .termsImportForm select {
|
||||
background: var(--bg-alt); color: var(--text);
|
||||
border: 1px solid var(--border, #30363d); border-radius: 6px;
|
||||
padding: 8px 10px; font-size: 13px;
|
||||
}
|
||||
.termsAddForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||
.termsAddForm .formActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
|
||||
.termsImportForm { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
.termsImportForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.termsImportForm label { font-size: 12px; color: var(--text-muted); }
|
||||
.termsImportForm .formActions { display: flex; justify-content: flex-end; }
|
||||
.termsImportForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('terms.packTitle', { name: pack.name }) %></h1>
|
||||
<p class="muted"><%= packKey %>.json</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('terms.hint') %></p>
|
||||
|
||||
<section class="termsList">
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="termsRow">
|
||||
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
|
||||
<div class="termsRowLabel">
|
||||
<h2><%= item.label %></h2>
|
||||
<% if (item.builtin) { %>
|
||||
<span class="builtinBadge"><%= t('terms.builtinBadge') %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="termsRowSub"><%= item.kind %>.md</div>
|
||||
</a>
|
||||
<div class="termsRowActions">
|
||||
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
|
||||
<% if (!item.builtin) { %>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
|
||||
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
|
||||
style="margin:0;">
|
||||
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</article>
|
||||
<% }) %>
|
||||
</section>
|
||||
|
||||
<section class="termsSideBySide">
|
||||
<div class="termsSection">
|
||||
<h2><%= t('terms.addHeading') %></h2>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/create" class="termsAddForm">
|
||||
<div class="field">
|
||||
<label for="newKind"><%= t('terms.kindLabel') %></label>
|
||||
<input id="newKind" name="kind" type="text" required
|
||||
pattern="[a-z0-9][a-z0-9-]{0,31}"
|
||||
placeholder="<%= t('terms.kindPlaceholder') %>" />
|
||||
<span class="hint"><%= t('terms.kindHint') %></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="newLabel"><%= t('terms.labelLabel') %></label>
|
||||
<input id="newLabel" name="label" type="text" required maxlength="50"
|
||||
placeholder="<%= t('terms.labelPlaceholder') %>" />
|
||||
</div>
|
||||
<div class="formActions">
|
||||
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="termsSection">
|
||||
<h2><%= t('terms.importHeading') %></h2>
|
||||
<% if (sourceCandidates.length === 0) { %>
|
||||
<p class="muted"><%= t('terms.importEmpty') %></p>
|
||||
<% } else { %>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/import" class="termsImportForm"
|
||||
onsubmit="return confirm('<%= t('terms.importConfirm').replace(/'/g, "\\'") %>');">
|
||||
<div class="field">
|
||||
<label for="importSource"><%= t('terms.importSourceLabel') %></label>
|
||||
<select id="importSource" name="source" required>
|
||||
<option value=""><%= t('terms.importSourcePlaceholder') %></option>
|
||||
<% sourceCandidates.forEach(function (cand) { %>
|
||||
<option value="<%= cand.key %>"><%= cand.definition ? cand.definition.name : cand.key %> (<%= cand.key %>)</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<span class="hint"><%= t('terms.importHint') %></span>
|
||||
</div>
|
||||
<div class="formActions">
|
||||
<button type="submit" class="primaryButton"><%= t('terms.importButton') %></button>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
44
views/op/terms.ejs
Normal file
44
views/op/terms.ejs
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.browserTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('terms.title') %></h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('terms.pickPackHint') %></p>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (items.length === 0) { %>
|
||||
<p class="muted"><%= t('site.empty') %></p>
|
||||
<% } %>
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard">
|
||||
<a class="cardLink" href="/op/agreement/<%= 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><%= 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>
|
||||
</article>
|
||||
<% }) %>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
50
views/op/termsEditor.ejs
Normal file
50
views/op/termsEditor.ejs
Normal file
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.editorBrowserTitle', { label: label }) %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="/static/termsEditor.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/agreement/<%= packKey %>"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
|
||||
<p class="muted"><%= pack.name %> · <%= kind %>.md</p>
|
||||
</div>
|
||||
<div class="dirtyMark" id="dirty-mark" hidden>*</div>
|
||||
</section>
|
||||
|
||||
<div class="listActionsRow" style="align-items:center;">
|
||||
<button type="button" class="primaryButton" id="saveBtn"><%= t('terms.save') %></button>
|
||||
<div class="tabBar" style="margin:0 0 0 12px;">
|
||||
<button type="button" class="tabBtn active" data-mode="edit"><%= t('terms.edit') %></button>
|
||||
<button type="button" class="tabBtn" data-mode="preview"><%= t('terms.preview') %></button>
|
||||
</div>
|
||||
<span class="statusText" id="status"></span>
|
||||
</div>
|
||||
|
||||
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
|
||||
|
||||
<div id="editorWrap" class="termsEditorWrap">
|
||||
<textarea id="editor" class="termsEditor" spellcheck="false"></textarea>
|
||||
<div id="preview" class="termsPreview" hidden></div>
|
||||
<div id="slashMenu" class="slashMenu" hidden></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||
var TERM_KIND = <%- JSON.stringify(kind) %>;
|
||||
var INITIAL = <%- JSON.stringify(content) %>;
|
||||
var I18N = <%- JSON.stringify(localeDict.terms) %>;
|
||||
I18N.common = <%- JSON.stringify(localeDict.common) %>;
|
||||
</script>
|
||||
<script src="/static/termsEditor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user