feat(installer-rp): auto-start install with progress card grid
2단계 페이지 진입 즉시 설치를 시작하고, 음악·사진을 1번부터 카드 그리드로 한눈에 볼 수 있게 만든다. 다운로드는 % 게이지로, 완료/실패는 색상으로 표시. - main: prep/item/package phase 의 ProgressEvent 를 renderer 로 송신 - music.ts: yt-dlp stdout 의 [download] X% 라인을 파싱해 onProgress 호출 - preload: onProgress 채널 구독 함수 노출 - renderer: 다음 버튼 제거, prep chip + music/image 카드 그리드 + 빌드 상태 - styles: progressCard / prepChip / progressGrid 스타일 추가 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -104,51 +104,136 @@ function renderStep1() {
|
||||
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>2단계. 리소스팩 설치</h2>' +
|
||||
'<p class="formMessage">아래 "다음"을 누르면 음악·사진을 받아 리소스팩을 만들고 ' +
|
||||
'<code>%appdata%/.minecraft/resourcepacks/</code> 에 넣습니다.</p>' +
|
||||
'<p class="formMessage">음악·사진을 받아 리소스팩을 만들고 ' +
|
||||
'<code>%appdata%/.minecraft/resourcepacks/</code> 에 자동 설치합니다.</p>' +
|
||||
'<div class="prepRow">' +
|
||||
' <span class="prepChip" id="chip-ytdlp">yt-dlp 준비</span>' +
|
||||
' <span class="prepChip" id="chip-ffmpeg">ffmpeg 준비</span>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>음악 다운로드</h3>' +
|
||||
' <div class="sectionSub" id="music-sub">' + musicTotal + '곡</div>' +
|
||||
' <div class="progressGrid" id="musicGrid"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>사진 다운로드</h3>' +
|
||||
' <div class="sectionSub" id="image-sub">' + imageTotal + '장</div>' +
|
||||
' <div class="progressGrid" id="imageGrid"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>리소스팩 빌드</h3>' +
|
||||
' <div class="sectionSub" id="pkg-sub">대기 중…</div>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow">' +
|
||||
' <button class="secondaryBtn" id="prev">이전</button>' +
|
||||
' <button class="primaryBtn" id="start">다음</button>' +
|
||||
' <button class="secondaryBtn" id="cancel" hidden>취소</button>' +
|
||||
' <span></span>' +
|
||||
' <button class="dangerBtn" id="cancel">취소</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
var prevBtn = section.querySelector('#prev')
|
||||
var startBtn = section.querySelector('#start')
|
||||
|
||||
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')
|
||||
|
||||
prevBtn.addEventListener('click', function () {
|
||||
if (state.installing) return
|
||||
renderStep1()
|
||||
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">대기</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 = '완료'
|
||||
if (icon) icon.textContent = '✓'
|
||||
if (bar) bar.style.width = '100%'
|
||||
} else if (status === 'error') {
|
||||
if (pct) pct.textContent = '실패'
|
||||
if (icon) icon.textContent = '✕'
|
||||
} else if (status === 'running') {
|
||||
if (pct) pct.textContent = Math.round(percent) + '%'
|
||||
if (icon) icon.textContent = '⏳'
|
||||
} else {
|
||||
if (pct) pct.textContent = '대기'
|
||||
if (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 ? '설치 완료' : (payload.message || '빌드 중…')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
startBtn.addEventListener('click', function () {
|
||||
if (state.installing) return
|
||||
state.installing = true
|
||||
startBtn.disabled = true
|
||||
prevBtn.disabled = true
|
||||
cancelBtn.hidden = false
|
||||
logViewer.hidden = false
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
if (!state.installing) return
|
||||
cancelBtn.disabled = true
|
||||
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
|
||||
startBtn.disabled = false
|
||||
prevBtn.disabled = false
|
||||
cancelBtn.hidden = true
|
||||
alert('설치 실패: ' + (err.message || err))
|
||||
})
|
||||
})
|
||||
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
api.cancelInstall()
|
||||
if (stopProgress) stopProgress()
|
||||
alert('설치 실패: ' + ((err && err.message) || err))
|
||||
renderStep1()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -221,3 +221,85 @@ main {
|
||||
.statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); }
|
||||
.statusBadge.warn { background: rgba(248, 197, 49, 0.2); color: #f0c244; }
|
||||
.statusBadge.fail { background: rgba(248, 81, 73, 0.2); color: var(--danger); }
|
||||
|
||||
/* 설치 진행 카드 그리드 */
|
||||
.progressSection { margin: 18px 0 8px; }
|
||||
.progressSection h3 { margin: 0 0 10px; font-size: 15px; }
|
||||
.progressSection .sectionSub { font-size: 12px; color: var(--text-muted); margin-bottom: 10px; }
|
||||
|
||||
.progressGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progressCard {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 10px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 72px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.progressCard.running { border-color: var(--accent); background: rgba(47, 129, 247, 0.10); }
|
||||
.progressCard.done { border-color: var(--success); background: rgba(63, 185, 80, 0.10); }
|
||||
.progressCard.error { border-color: var(--danger); background: rgba(248, 81, 73, 0.10); }
|
||||
|
||||
.progressCard .cardTop {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.progressCard .cardTop .label { color: var(--text); }
|
||||
.progressCard .cardTop .icon { font-size: 14px; }
|
||||
.progressCard.pending .cardTop .icon { color: var(--text-muted); }
|
||||
.progressCard.running .cardTop .icon { color: var(--accent); }
|
||||
.progressCard.done .cardTop .icon { color: var(--success); }
|
||||
.progressCard.error .cardTop .icon { color: var(--danger); }
|
||||
|
||||
.progressCard .bar {
|
||||
height: 6px;
|
||||
background: #2a2f37;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progressCard .bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width 0.18s linear;
|
||||
}
|
||||
.progressCard.done .bar > span { background: var(--success); }
|
||||
.progressCard.error .bar > span { background: var(--danger); }
|
||||
|
||||
.progressCard .pct {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.prepRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.prepChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.prepChip.active { border-color: var(--accent); color: var(--text); }
|
||||
.prepChip.done { border-color: var(--success); color: var(--success); }
|
||||
|
||||
@@ -69,6 +69,24 @@ function sendLog(line: string): void {
|
||||
mainWindow.webContents.send('log', stamped)
|
||||
}
|
||||
|
||||
type ProgressEvent =
|
||||
| { phase: 'prep'; message: string; done?: boolean }
|
||||
| {
|
||||
phase: 'item'
|
||||
kind: 'music' | 'image'
|
||||
index: number
|
||||
total: number
|
||||
percent: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
message?: string
|
||||
}
|
||||
| { phase: 'package'; message: string; done?: boolean }
|
||||
|
||||
function sendProgress(payload: ProgressEvent): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
mainWindow.webContents.send('progress', payload)
|
||||
}
|
||||
|
||||
function fetchBuffer(url: string): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const target = new URL(url)
|
||||
@@ -161,67 +179,92 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
const tempRoot = path.join(getMcCustomDir(), '.temp')
|
||||
await fsp.mkdir(tempRoot, { recursive: true })
|
||||
|
||||
const musicTotal = pack.list.music.length
|
||||
const imageTotal = pack.list.images.length
|
||||
|
||||
try {
|
||||
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
|
||||
sendLog('yt-dlp 준비 중…')
|
||||
sendProgress({ phase: 'prep', message: 'yt-dlp 준비 중' })
|
||||
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
||||
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
|
||||
throwIfCancelled()
|
||||
sendLog('ffmpeg 준비 중…')
|
||||
sendProgress({ phase: 'prep', message: 'ffmpeg 준비 중' })
|
||||
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
||||
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
|
||||
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-2. 음악 다운로드 (1번부터 순차, ogg 변환)
|
||||
const musicDir = path.join(tempRoot, 'music')
|
||||
await fsp.mkdir(musicDir, { recursive: true })
|
||||
sendLog(`음악 다운로드 시작 (${pack.list.music.length}곡)`)
|
||||
for (let i = 0; i < pack.list.music.length; i++) {
|
||||
sendLog(`음악 다운로드 시작 (${musicTotal}곡)`)
|
||||
for (let i = 0; i < musicTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.music[i]
|
||||
sendLog(`${i + 1}번 노래 다운로드 중…`)
|
||||
const idx = i + 1
|
||||
sendLog(`${idx}번 노래 다운로드 중…`)
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||
try {
|
||||
const outPath = await downloadMusicTrack({
|
||||
ytdlpExe: ytDlpBin,
|
||||
ffmpegExe: ffmpegBin,
|
||||
tempDir: musicDir,
|
||||
index: i + 1,
|
||||
index: idx,
|
||||
url: entry.url,
|
||||
log: sendLog,
|
||||
onChild: (c) => { state.currentChild = c }
|
||||
onChild: (c) => { state.currentChild = c },
|
||||
onProgress: (pct) => {
|
||||
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
|
||||
sendProgress({
|
||||
phase: 'item', kind: 'music', index: idx, total: musicTotal,
|
||||
percent: Math.min(90, pct * 0.9), status: 'running'
|
||||
})
|
||||
}
|
||||
})
|
||||
state.currentChild = null
|
||||
sendLog(`${i + 1}번 노래 완료: ${path.basename(outPath)}`)
|
||||
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
} catch (err) {
|
||||
state.currentChild = null
|
||||
// 취소된 경우는 throwIfCancelled 가 일관된 메시지로 다시 던지게 함.
|
||||
if (state.cancelRequested) throwIfCancelled()
|
||||
throw new Error(`${i + 1}번 노래 다운로드 실패: ${(err as Error).message}`)
|
||||
if (state.cancelRequested) {
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: '취소됨' })
|
||||
throwIfCancelled()
|
||||
}
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2-3. 사진 다운로드 + painting variant 정규화
|
||||
const paintingDir = path.join(tempRoot, 'painting')
|
||||
await fsp.mkdir(paintingDir, { recursive: true })
|
||||
sendLog(`사진 다운로드 시작 (${pack.list.images.length}장)`)
|
||||
for (let i = 0; i < pack.list.images.length; i++) {
|
||||
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
|
||||
for (let i = 0; i < imageTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.images[i]
|
||||
sendLog(`${i + 1}번 사진 다운로드 중…`)
|
||||
const idx = i + 1
|
||||
sendLog(`${idx}번 사진 다운로드 중…`)
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
|
||||
let buf: Buffer
|
||||
try {
|
||||
buf = await downloadImage(entry.url)
|
||||
} catch (err) {
|
||||
throw new Error(`${i + 1}번 사진 다운로드 실패: ${(err as Error).message}`)
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 사진 다운로드 실패: ${(err as Error).message}`)
|
||||
}
|
||||
throwIfCancelled()
|
||||
const outPath = path.join(paintingDir, coverFileName(i + 1))
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
|
||||
const outPath = path.join(paintingDir, coverFileName(idx))
|
||||
try {
|
||||
await normalizeToCover(buf, outPath)
|
||||
} catch (err) {
|
||||
throw new Error(`${i + 1}번 사진 정규화 실패: ${(err as Error).message}`)
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(`${idx}번 사진 정규화 실패: ${(err as Error).message}`)
|
||||
}
|
||||
sendLog(`${i + 1}번 사진 완료: ${path.basename(outPath)}`)
|
||||
sendLog(`${idx}번 사진 완료: ${path.basename(outPath)}`)
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
||||
}
|
||||
|
||||
// 2-4. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지)
|
||||
@@ -230,6 +273,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
const resourcepackDir = path.join(getAppDataDir(), '.minecraft', 'resourcepacks')
|
||||
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||
sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`)
|
||||
sendProgress({ phase: 'package', message: 'zip 빌드 중' })
|
||||
await buildResourcepackZip({
|
||||
musicDir,
|
||||
paintingDir,
|
||||
@@ -242,6 +286,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
|
||||
|
||||
// 2-5. %appdata%/.minecraft/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||
sendLog(`설치 완료: ${resourcepackPath}`)
|
||||
sendProgress({ phase: 'package', message: '설치 완료', done: true })
|
||||
return { resourcepackPath }
|
||||
} finally {
|
||||
// 임시 파일 정리
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface DownloadMusicOptions {
|
||||
log?: (line: string) => void
|
||||
/** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */
|
||||
onChild?: (child: ChildProcess) => void
|
||||
/** yt-dlp 의 다운로드 퍼센트 (0~100). 변환 단계는 별도. */
|
||||
onProgress?: (percent: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,9 +42,28 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
|
||||
const child = spawn(opts.ytdlpExe, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
opts.onChild?.(child)
|
||||
let stderr = ''
|
||||
let stdoutBuf = ''
|
||||
let lastReportedPct = -1
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
const line = chunk.toString('utf8').trimEnd()
|
||||
if (line) opts.log?.(`yt-dlp> ${line}`)
|
||||
stdoutBuf += chunk.toString('utf8')
|
||||
// yt-dlp 는 `[download] 3.3% of 3.72MiB at ...` 형식으로
|
||||
// \r 로 같은 줄을 갱신한다. \r 과 \n 을 모두 split 해서 마지막 진행률을 뽑는다.
|
||||
const lines = stdoutBuf.split(/[\r\n]/)
|
||||
stdoutBuf = lines.pop() ?? ''
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line) continue
|
||||
opts.log?.(`yt-dlp> ${line}`)
|
||||
const m = line.match(/\[download\]\s+([\d.]+)%/)
|
||||
if (m) {
|
||||
const pct = Math.min(100, Math.max(0, parseFloat(m[1])))
|
||||
// 너무 잦은 이벤트를 피하기 위해 1% 단위로만 전달.
|
||||
if (Math.floor(pct) !== lastReportedPct) {
|
||||
lastReportedPct = Math.floor(pct)
|
||||
opts.onProgress?.(pct)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
|
||||
@@ -27,6 +27,13 @@ const api = {
|
||||
const listener = (_event: unknown, line: string) => handler(line)
|
||||
ipcRenderer.on('log', listener)
|
||||
return () => ipcRenderer.removeListener('log', listener)
|
||||
},
|
||||
|
||||
/** 설치 진행 이벤트 구독. payload 구조는 renderer 가 알아서 분기. */
|
||||
onProgress: (handler: (payload: unknown) => void): (() => void) => {
|
||||
const listener = (_event: unknown, payload: unknown) => handler(payload)
|
||||
ipcRenderer.on('progress', listener)
|
||||
return () => ipcRenderer.removeListener('progress', listener)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user