op/datapack: add painting_variant JSON zip export button
데이터팩 수정 페이지에 "이미지.zip 출력" 버튼과 크기 입력(기본 4, 1~16) 을 추가. 누르면 GET /op/datapack/:key/images-zip?size=N 으로 음악 개수만큼 cover_NN.json (asset_id, width=size, height=size, title, author) 을 zip 으로 스트리밍해서 내려준다. 사용자가 맵 데이터팩의 data/musicquiz/painting_variant/ 에 그대로 풀어 넣을 수 있다. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -143,7 +143,11 @@
|
|||||||
"exporting": "출력 중…",
|
"exporting": "출력 중…",
|
||||||
"exported": "출력 완료",
|
"exported": "출력 완료",
|
||||||
"failed": "실패: {{message}}",
|
"failed": "실패: {{message}}",
|
||||||
"modalPickTitle": "음악퀴즈 선택"
|
"modalPickTitle": "음악퀴즈 선택",
|
||||||
|
"imagesZip": "이미지.zip 출력",
|
||||||
|
"imagesZipSizeLabel": "크기",
|
||||||
|
"imagesZipDownloading": "이미지.zip 생성 중…",
|
||||||
|
"imagesZipDone": "이미지.zip 다운로드 완료"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
|
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
import archiver from 'archiver'
|
||||||
import {
|
import {
|
||||||
createPack,
|
createPack,
|
||||||
deletePackKeys,
|
deletePackKeys,
|
||||||
@@ -252,6 +253,48 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
<p class="muted" id="countLabel"></p>
|
<p class="muted" id="countLabel"></p>
|
||||||
|
|
||||||
<section class="dpActions" hidden id="dpActions">
|
<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="exportBtn"><%= t('datapack.export') %></button>
|
||||||
<button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
|
<button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
|
||||||
<span class="statusText" id="dp-status"></span>
|
<span class="statusText" id="dp-status"></span>
|
||||||
@@ -119,6 +122,21 @@
|
|||||||
})
|
})
|
||||||
.catch(function (err) { s.textContent = I18N.failed.replace('{{message}}', err.message); s.classList.add('error') })
|
.catch(function (err) { s.textContent = I18N.failed.replace('{{message}}', err.message); s.classList.add('error') })
|
||||||
})
|
})
|
||||||
|
document.getElementById('imagesZipBtn').addEventListener('click', function () {
|
||||||
|
if (!pickedKey) return
|
||||||
|
var sizeInput = document.getElementById('imagesZipSize')
|
||||||
|
var size = parseInt(sizeInput.value, 10)
|
||||||
|
if (!isFinite(size) || size < 1) size = 4
|
||||||
|
if (size > 16) size = 16
|
||||||
|
sizeInput.value = String(size)
|
||||||
|
var s = document.getElementById('dp-status')
|
||||||
|
s.textContent = I18N.imagesZipDownloading; s.classList.remove('error')
|
||||||
|
// 브라우저 기본 다운로드로 위임. 인증 쿠키는 자동으로 따라간다.
|
||||||
|
var url = '/op/datapack/' + encodeURIComponent(pickedKey) + '/images-zip?size=' + size
|
||||||
|
window.location.href = url
|
||||||
|
// 다운로드 시작은 비동기지만, 사용자에게 즉시 피드백.
|
||||||
|
setTimeout(function () { s.textContent = I18N.imagesZipDone }, 500)
|
||||||
|
})
|
||||||
document.getElementById('copyBtn').addEventListener('click', function () {
|
document.getElementById('copyBtn').addEventListener('click', function () {
|
||||||
var out = document.getElementById('codeOut')
|
var out = document.getElementById('codeOut')
|
||||||
if (out.hidden) return
|
if (out.hidden) return
|
||||||
|
|||||||
Reference in New Issue
Block a user