Reviewer caught that v0.3.4 was bypassing the agreement step entirely on network/server errors, letting users install without ever seeing terms. Now only the explicit empty-list response (terms:[]) skips the step. Network errors, 404s, and IPC failures render an error page with Back/Retry buttons; no next button is exposed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
531 lines
20 KiB
JavaScript
531 lines
20 KiB
JavaScript
'use strict'
|
|
|
|
const api = window.rpInstaller
|
|
|
|
const state = {
|
|
packs: [],
|
|
selectedKey: null,
|
|
installing: false,
|
|
installed: false,
|
|
resourcepackPath: ''
|
|
}
|
|
|
|
let I18N = {}
|
|
|
|
function tt(key, params) {
|
|
var parts = String(key).split('.')
|
|
var cur = I18N
|
|
for (var i = 0; i < parts.length; i++) {
|
|
if (cur && typeof cur === 'object' && parts[i] in cur) {
|
|
cur = cur[parts[i]]
|
|
} else {
|
|
return key
|
|
}
|
|
}
|
|
if (typeof cur !== 'string') return key
|
|
if (!params) return cur
|
|
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
|
return name in params ? String(params[name]) : '{{' + name + '}}'
|
|
})
|
|
}
|
|
|
|
const pageHost = document.getElementById('pageHost')
|
|
const stepIndicator = document.getElementById('stepIndicator')
|
|
const logViewer = document.getElementById('logViewer')
|
|
const logBody = document.getElementById('logBody')
|
|
const logToggle = document.getElementById('logToggle')
|
|
|
|
logToggle.addEventListener('click', function () {
|
|
logViewer.classList.toggle('collapsed')
|
|
if (logViewer.classList.contains('collapsed')) {
|
|
logViewer.style.height = '36px'
|
|
logToggle.textContent = tt('logViewer.expand')
|
|
} else {
|
|
logViewer.style.height = ''
|
|
logToggle.textContent = tt('logViewer.collapse')
|
|
}
|
|
})
|
|
|
|
api.onLog(function (line) {
|
|
logViewer.hidden = false
|
|
logBody.textContent += line + '\n'
|
|
logBody.scrollTop = logBody.scrollHeight
|
|
})
|
|
|
|
function applyStaticI18n() {
|
|
document.title = tt('app.title')
|
|
var h1 = document.querySelector('.appHeader h1')
|
|
if (h1) h1.textContent = tt('app.title')
|
|
var stepLis = stepIndicator.querySelectorAll('li')
|
|
stepLis.forEach(function (item) {
|
|
var idx = item.getAttribute('data-step')
|
|
if (idx === '1') item.textContent = tt('stepIndicator.step1')
|
|
else if (idx === '2') item.textContent = tt('stepIndicator.step2')
|
|
else if (idx === '3') item.textContent = tt('stepIndicator.step3')
|
|
})
|
|
var logH2 = logViewer.querySelector('header h2')
|
|
if (logH2) logH2.textContent = tt('logViewer.heading')
|
|
logToggle.textContent = tt('logViewer.collapse')
|
|
}
|
|
|
|
function setActiveStep(step) {
|
|
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
|
var index = Number(item.getAttribute('data-step'))
|
|
item.classList.remove('active', 'done')
|
|
if (index < step) item.classList.add('done')
|
|
if (index === step) item.classList.add('active')
|
|
})
|
|
}
|
|
|
|
function clearPage() { pageHost.innerHTML = '' }
|
|
|
|
// ── 1단계: 음악퀴즈 선택 ────────────────────────────
|
|
function renderStep1() {
|
|
setActiveStep(1)
|
|
clearPage()
|
|
var section = document.createElement('section')
|
|
section.className = 'page'
|
|
section.innerHTML =
|
|
'<h2>' + escapeHtml(tt('step1.heading')) + '</h2>' +
|
|
'<div id="packList" class="cardChoice"><p class="formMessage">' + escapeHtml(tt('common.loading')) + '</p></div>' +
|
|
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button></div>'
|
|
pageHost.appendChild(section)
|
|
var listEl = section.querySelector('#packList')
|
|
var nextBtn = section.querySelector('#next')
|
|
|
|
function renderList() {
|
|
listEl.innerHTML = ''
|
|
if (state.packs.length === 0) {
|
|
listEl.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('common.noPacks')) + '</p>'
|
|
return
|
|
}
|
|
state.packs.forEach(function (pack) {
|
|
var card = document.createElement('button')
|
|
card.type = 'button'
|
|
card.className = 'choiceCard'
|
|
if (state.selectedKey === pack.key) card.classList.add('selected')
|
|
var verLabel = pack.mcVersion
|
|
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
|
|
: ''
|
|
card.innerHTML =
|
|
'<strong>' + escapeHtml(pack.name) + '</strong>' +
|
|
'<small>' + verLabel +
|
|
escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) +
|
|
'</small>'
|
|
card.addEventListener('click', function () {
|
|
state.selectedKey = pack.key
|
|
nextBtn.disabled = false
|
|
renderList()
|
|
})
|
|
listEl.appendChild(card)
|
|
})
|
|
}
|
|
|
|
nextBtn.addEventListener('click', function () {
|
|
if (!state.selectedKey) return
|
|
api.selectPack(state.selectedKey).then(function () {
|
|
renderAgreement()
|
|
}).catch(function (err) {
|
|
alert(err.message || tt('common.selectFailed'))
|
|
})
|
|
})
|
|
|
|
api.loadPacks().then(function (packs) {
|
|
state.packs = packs || []
|
|
renderList()
|
|
}).catch(function (err) {
|
|
listEl.innerHTML = '<p class="formMessage error">' +
|
|
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
|
|
'</p>'
|
|
})
|
|
}
|
|
|
|
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
|
// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 명시적으로 빈 목록(terms:[])
|
|
// 정상 응답일 때만 단계를 건너뛰고, 네트워크/서버 오류는 차단 후 다시 시도 UI를 보여준다.
|
|
function renderAgreement() {
|
|
setActiveStep(1)
|
|
clearPage()
|
|
var loadingSection = document.createElement('section')
|
|
loadingSection.className = 'page'
|
|
loadingSection.innerHTML = '<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
|
'<p class="formMessage">' + escapeHtml(tt('agreement.loading')) + '</p>'
|
|
pageHost.appendChild(loadingSection)
|
|
|
|
api.getTermsList().then(function (res) {
|
|
if (!res || !res.ok) {
|
|
showAgreementError((res && res.message) || 'unknown')
|
|
return
|
|
}
|
|
var terms = (res.terms || []).map(function (t) {
|
|
return { id: t.kind, tab: t.label }
|
|
})
|
|
if (terms.length === 0) {
|
|
renderStep2()
|
|
return
|
|
}
|
|
clearPage()
|
|
renderAgreementWithKinds(terms)
|
|
}).catch(function (err) {
|
|
showAgreementError(err && err.message ? err.message : 'unknown')
|
|
})
|
|
}
|
|
|
|
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도 옵션. 동의 없이 설치 단계로
|
|
// 자동 진입하지 않도록 next 버튼을 두지 않는다.
|
|
function showAgreementError(message) {
|
|
clearPage()
|
|
var section = document.createElement('section')
|
|
section.className = 'page'
|
|
section.innerHTML =
|
|
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
|
'<p class="formMessage error">' + escapeHtml(tt('agreement.listLoadFailed', { message: message })) + '</p>' +
|
|
'<div class="actionRow">' +
|
|
'<button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
|
|
'<button class="primaryBtn" id="retry">' + escapeHtml(tt('agreement.retry')) + '</button>' +
|
|
'</div>'
|
|
pageHost.appendChild(section)
|
|
section.querySelector('#back').addEventListener('click', renderStep1)
|
|
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
|
}
|
|
|
|
function renderAgreementWithKinds(KINDS) {
|
|
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)
|
|
clearPage()
|
|
|
|
var pack = null
|
|
for (var i = 0; i < state.packs.length; i++) {
|
|
if (state.packs[i].key === state.selectedKey) { pack = state.packs[i]; break }
|
|
}
|
|
var musicTotal = pack ? pack.list.music.length : 0
|
|
var imageTotal = pack ? pack.list.images.length : 0
|
|
|
|
var section = document.createElement('section')
|
|
section.className = 'page'
|
|
section.innerHTML =
|
|
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
|
|
'<p class="formMessage">' + tt('step2.description') + '</p>' +
|
|
'<div class="prepRow">' +
|
|
' <span class="prepChip" id="chip-ytdlp">' + escapeHtml(tt('step2.chipYtdlp')) + '</span>' +
|
|
' <span class="prepChip" id="chip-ffmpeg">' + escapeHtml(tt('step2.chipFfmpeg')) + '</span>' +
|
|
'</div>' +
|
|
'<div class="progressSection">' +
|
|
' <h3>' + escapeHtml(tt('step2.musicHeading')) + '</h3>' +
|
|
' <div class="sectionSub" id="music-sub">' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '</div>' +
|
|
' <div class="progressGrid" id="musicGrid"></div>' +
|
|
'</div>' +
|
|
'<div class="progressSection">' +
|
|
' <h3>' + escapeHtml(tt('step2.imageHeading')) + '</h3>' +
|
|
' <div class="sectionSub" id="image-sub">' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '</div>' +
|
|
' <div class="progressGrid" id="imageGrid"></div>' +
|
|
'</div>' +
|
|
'<div class="progressSection">' +
|
|
' <h3>' + escapeHtml(tt('step2.packageHeading')) + '</h3>' +
|
|
' <div class="sectionSub" id="pkg-sub">' + escapeHtml(tt('step2.packageWaiting')) + '</div>' +
|
|
'</div>' +
|
|
'<div class="actionRow">' +
|
|
' <span></span>' +
|
|
' <button class="dangerBtn" id="cancel">' + escapeHtml(tt('common.cancel')) + '</button>' +
|
|
'</div>'
|
|
pageHost.appendChild(section)
|
|
|
|
var musicGrid = section.querySelector('#musicGrid')
|
|
var imageGrid = section.querySelector('#imageGrid')
|
|
var chipYtdlp = section.querySelector('#chip-ytdlp')
|
|
var chipFfmpeg = section.querySelector('#chip-ffmpeg')
|
|
var pkgSub = section.querySelector('#pkg-sub')
|
|
var cancelBtn = section.querySelector('#cancel')
|
|
|
|
function buildCard(idx) {
|
|
var card = document.createElement('div')
|
|
card.className = 'progressCard pending'
|
|
card.setAttribute('data-idx', String(idx))
|
|
card.innerHTML =
|
|
'<div class="cardTop"><span class="label">' + idx + '</span><span class="icon">○</span></div>' +
|
|
'<div class="bar"><span></span></div>' +
|
|
'<div class="pct">' + escapeHtml(tt('step2.cardWaiting')) + '</div>'
|
|
return card
|
|
}
|
|
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
|
|
for (var k = 1; k <= imageTotal; k++) imageGrid.appendChild(buildCard(k))
|
|
|
|
function updateCard(grid, index, percent, status) {
|
|
var card = grid.querySelector('[data-idx="' + index + '"]')
|
|
if (!card) return
|
|
card.classList.remove('pending', 'running', 'done', 'error')
|
|
card.classList.add(status)
|
|
var bar = card.querySelector('.bar > span')
|
|
if (bar) bar.style.width = Math.max(0, Math.min(100, percent)) + '%'
|
|
var pct = card.querySelector('.pct')
|
|
var icon = card.querySelector('.icon')
|
|
if (status === 'done') {
|
|
if (pct) pct.textContent = tt('step2.cardDone')
|
|
if (icon) icon.textContent = '✓'
|
|
if (bar) bar.style.width = '100%'
|
|
} else if (status === 'error') {
|
|
if (pct) pct.textContent = tt('step2.cardError')
|
|
if (icon) icon.textContent = '✕'
|
|
} else if (status === 'running') {
|
|
if (pct) pct.textContent = Math.round(percent) + '%'
|
|
if (icon) icon.textContent = '⏳'
|
|
} else {
|
|
if (pct) pct.textContent = tt('step2.cardWaiting')
|
|
if (icon) icon.textContent = '○'
|
|
}
|
|
}
|
|
|
|
var stopProgress = api.onProgress(function (payload) {
|
|
if (!payload || typeof payload !== 'object') return
|
|
if (payload.phase === 'prep') {
|
|
if (payload.done) {
|
|
chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done')
|
|
chipFfmpeg.classList.remove('active'); chipFfmpeg.classList.add('done')
|
|
return
|
|
}
|
|
if (payload.message && payload.message.indexOf('yt-dlp') >= 0) {
|
|
chipYtdlp.classList.add('active')
|
|
} else if (payload.message && payload.message.indexOf('ffmpeg') >= 0) {
|
|
chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done')
|
|
chipFfmpeg.classList.add('active')
|
|
}
|
|
return
|
|
}
|
|
if (payload.phase === 'item') {
|
|
var grid = payload.kind === 'music' ? musicGrid : imageGrid
|
|
updateCard(grid, payload.index, payload.percent || 0, payload.status)
|
|
return
|
|
}
|
|
if (payload.phase === 'package') {
|
|
pkgSub.textContent = payload.done
|
|
? tt('step2.packageDone')
|
|
: (payload.message || tt('step2.packageBuilding'))
|
|
return
|
|
}
|
|
})
|
|
|
|
// 사용자가 취소를 눌렀는지 추적. 취소 흐름에서는 installFailed 알림을 띄우지 않고
|
|
// 조용히 step1 로 돌아간다.
|
|
var cancelInitiated = false
|
|
cancelBtn.addEventListener('click', function () {
|
|
if (!state.installing || cancelInitiated) return
|
|
cancelInitiated = true
|
|
cancelBtn.disabled = true
|
|
cancelBtn.textContent = tt('agreement.cancelling')
|
|
// 사용자에게 어느 단계든 즉시 "취소 중" 신호가 보이도록 패키지 섹션 상태 갱신.
|
|
pkgSub.textContent = tt('agreement.cancelling')
|
|
api.cancelInstall()
|
|
})
|
|
|
|
// 페이지 진입 즉시 설치 시작
|
|
state.installing = true
|
|
logViewer.hidden = false
|
|
api.startInstall().then(function (result) {
|
|
state.installing = false
|
|
state.installed = true
|
|
state.resourcepackPath = (result && result.resourcepackPath) || ''
|
|
if (stopProgress) stopProgress()
|
|
renderStep3()
|
|
}).catch(function (err) {
|
|
state.installing = false
|
|
if (stopProgress) stopProgress()
|
|
if (!cancelInitiated) {
|
|
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
|
}
|
|
renderStep1()
|
|
})
|
|
}
|
|
|
|
// ── 3단계: 완료 ────────────────────────────────────
|
|
function renderStep3() {
|
|
setActiveStep(3)
|
|
clearPage()
|
|
var section = document.createElement('section')
|
|
section.className = 'page'
|
|
section.innerHTML =
|
|
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
|
|
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
|
|
(state.resourcepackPath
|
|
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
|
|
: '') +
|
|
'<div class="actionRow">' +
|
|
' <button class="secondaryBtn" id="openFolder">' + escapeHtml(tt('common.openFolder')) + '</button>' +
|
|
' <button class="primaryBtn" id="finish">' + escapeHtml(tt('common.confirm')) + '</button>' +
|
|
'</div>'
|
|
pageHost.appendChild(section)
|
|
section.querySelector('#openFolder').addEventListener('click', function () {
|
|
api.openResourcepackFolder()
|
|
})
|
|
section.querySelector('#finish').addEventListener('click', function () {
|
|
api.quit()
|
|
})
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
|
|
})
|
|
}
|
|
|
|
;(async function () {
|
|
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
|
|
applyStaticI18n()
|
|
renderStep1()
|
|
})()
|