On install failure the temp folder is now preserved instead of wiped, so already-downloaded songs/images are skipped on the next attempt. The error screen offers 재시도 (resume from the failed item) and 처음으로 (discard the partial download and restart). Closing the program without retrying still wipes the partial download via window-all-closed, and an explicit cancel also clears it. Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
564 lines
21 KiB
JavaScript
564 lines
21 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) {
|
|
// 취소: backend 가 임시 파일을 이미 정리했음. 조용히 처음 단계로.
|
|
renderStep1()
|
|
return
|
|
}
|
|
// 그 외 오류: 받아둔 음악·사진은 보존되어 있으므로 '재시도' 로 이어받을 수 있다.
|
|
showInstallError((err && err.message) || String(err))
|
|
})
|
|
}
|
|
|
|
// 설치 실패 화면: 이어받기('재시도')와 처음으로('처음으로') 선택지를 제공한다.
|
|
// 재시도 시 이미 받아둔 곡·사진은 건너뛰고 실패한 지점부터 이어서 설치한다.
|
|
function showInstallError(message) {
|
|
setActiveStep(2)
|
|
clearPage()
|
|
var section = document.createElement('section')
|
|
section.className = 'page'
|
|
section.innerHTML =
|
|
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
|
|
'<p class="formMessage error">' + escapeHtml(tt('install.errorMessage', { message: message })) + '</p>' +
|
|
'<p class="formMessage">' + escapeHtml(tt('install.resumeHint')) + '</p>' +
|
|
'<div class="actionRow">' +
|
|
' <button class="secondaryBtn" id="startOver">' + escapeHtml(tt('install.startOver')) + '</button>' +
|
|
' <button class="primaryBtn" id="retry">' + escapeHtml(tt('install.retry')) + '</button>' +
|
|
'</div>'
|
|
pageHost.appendChild(section)
|
|
section.querySelector('#retry').addEventListener('click', function () {
|
|
// 같은 음악퀴즈로 설치를 다시 시작. backend 가 받아둔 산출물을 건너뛴다.
|
|
renderStep2()
|
|
})
|
|
section.querySelector('#startOver').addEventListener('click', function () {
|
|
// 이어받지 않고 처음으로: 받아둔 임시 파일을 정리한 뒤 1단계로.
|
|
api.discardInstall().then(function () {
|
|
renderStep1()
|
|
}).catch(function () {
|
|
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()
|
|
})()
|