Add /op/list, /op/list/:pack, /op/datapack web admin + spec lock
docs/add.md - 사진 PNG 규격을 1024×1024 (4×4 블록 슬롯 × ×16 배율) 로 못박음 - 짧은 변 기준 가운데 정사각 크롭 + 1024 초과 시만 축소, 미만은 native 유지 신규 라우트 (모두 requireAuth): - GET /op/list → manifest 카드 목록 - GET /op/list/:pack → 음악목록·사진목록 탭 편집기 - POST /op/list/:pack → file/list/<pack>.json 저장 (JSON) - POST /op/list/:pack/playlist → yt-dlp 로 플레이리스트 펼치기 - GET /op/datapack → 음악퀴즈 선택 + 출력 - GET /op/datapack/:pack/generate → 임시 포맷 mcfunction 텍스트 shared/types.ts: PackList / MusicListEntry / ImageListEntry shared/store.ts: loadPackList, savePackList, normalizePackList shared/paths.ts: fileListDirPath = file/list/ server/youtube.ts: yt-dlp 플레이리스트 펼치기 (--flat-playlist --dump-json), 설치 안 됐을 때 NO_YTDLP 코드로 503. UI: - views/op/list.ejs: 가로 카드 목록 + 돌아가기 버튼 - views/op/listEditor.ejs + public/listEditor.js: 탭 전환, 드래그 정렬, 우클릭 컨텍스트 메뉴(수정/삭제), 사진 수정 모달의 [유튜브 / 이미지] 토글, 목록 저장·초기화·플레이리스트 불러오기 확인 팝업 - views/op/datapack.ejs: 음악퀴즈 카드 선택 팝업 → 데이터팩 출력 + 복사 - views/op/dashboard.ejs: 상단에 [음악목록 수정] [데이터팩 수정] 버튼 - public/styles.css: 탭, 트랙 로우, 이미지 그리드, 컨텍스트 메뉴, 모달, 코드블록 .gitignore: conversations/ 추가. 스모크: login → /op/list 렌더, POST 저장 라운드트립 OK, /op/datapack/.../generate 텍스트 출력 OK, 플레이리스트 fetch는 yt-dlp 미설치 환경에서 503 NO_YTDLP 메시지 정상. Section 1 (리소스팩 설치기 EXE: yt-dlp 음악 다운로드, painting variant 텍스처 패키징, 리소스팩 zip 배치) 은 후속 커밋에서 작업. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist/
|
|||||||
release/
|
release/
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
conversations/
|
||||||
|
|||||||
14
docs/add.md
14
docs/add.md
@@ -49,11 +49,15 @@ yt-dlp로 음악 다운로드, 별도 경로로 사진 다운로드 → 리소
|
|||||||
다운로드 후 painting variant 슬롯 규격에 맞춰 정규화한다 (자세한 슬롯 구조는
|
다운로드 후 painting variant 슬롯 규격에 맞춰 정규화한다 (자세한 슬롯 구조는
|
||||||
`docs/painting-variant.md` 참고).
|
`docs/painting-variant.md` 참고).
|
||||||
|
|
||||||
- 데이터팩에 슬롯 `cover_01 … cover_N` 이 미리 등록돼 있음. 각 슬롯의 가로·세로(블록 단위)는 고정.
|
- **슬롯 규격(고정, 데이터팩 측)**: `4 × 4` 블록 정사각, `cover_01 … cover_N`.
|
||||||
- 설치기는 이미지 N장을 받아 슬롯 N개의 PNG 로 변환:
|
- **최종 PNG 규격(리소스팩 측)**: 정사각 1:1, 최대 `1024 × 1024` px.
|
||||||
- 목표 해상도 = `width * 16 × height * 16` (또는 그 정수배).
|
- `4 × 4` 블록 × 블록당 `256` px (×16 배율) → 1024×1024 가 픽셀 그리드와 정확히 일치.
|
||||||
- 비율이 다르면 letterbox(투명 패딩)로 맞춰 잘림 방지.
|
- **정규화 알고리즘**:
|
||||||
- 파일명은 `cover_<NN>.png`, 저장 경로는 `resourcepack/assets/musicquiz/textures/painting/`.
|
1. 가운데 정사각 크롭: `s = min(원본 가로, 원본 세로)` → `s × s`.
|
||||||
|
2. `s > 1024` 이면 `1024 × 1024` 로 축소 (Lanczos 권장).
|
||||||
|
3. `s ≤ 1024` 이면 그대로 `s × s` 유지 (업스케일 없음).
|
||||||
|
- 파일명: `cover_<NN>.png` (`NN` 은 2자리 0패딩).
|
||||||
|
- 저장 경로: `resourcepack/assets/musicquiz/textures/painting/`.
|
||||||
- 패키지 완성된 리소스팩을 `%appdata%/.minecraft/resourcepacks/` 에 zip 으로 배치.
|
- 패키지 완성된 리소스팩을 `%appdata%/.minecraft/resourcepacks/` 에 zip 으로 배치.
|
||||||
|
|
||||||
### 3) 설치 완료
|
### 3) 설치 완료
|
||||||
|
|||||||
6
file/list/music-quiz.json
Normal file
6
file/list/music-quiz.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"musicPlaylistUrl": "",
|
||||||
|
"imagePlaylistUrl": "",
|
||||||
|
"music": [],
|
||||||
|
"images": []
|
||||||
|
}
|
||||||
313
public/listEditor.js
Normal file
313
public/listEditor.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
var state = {
|
||||||
|
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
|
||||||
|
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
|
||||||
|
music: Array.isArray(INITIAL.music) ? INITIAL.music.slice() : [],
|
||||||
|
images: Array.isArray(INITIAL.images) ? INITIAL.images.slice() : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 탭 ────────────────────────────────────────────
|
||||||
|
var tabBtns = document.querySelectorAll('.tabBtn')
|
||||||
|
tabBtns.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
tabBtns.forEach(function (b) { b.classList.remove('active') })
|
||||||
|
btn.classList.add('active')
|
||||||
|
var key = btn.getAttribute('data-tab')
|
||||||
|
document.getElementById('tab-music').hidden = (key !== 'music')
|
||||||
|
document.getElementById('tab-image').hidden = (key !== 'image')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 유틸 ──────────────────────────────────────────
|
||||||
|
function ytIdFromUrl(url) {
|
||||||
|
if (!url) return ''
|
||||||
|
var m = url.match(/[?&]v=([\w-]{11})/) || url.match(/youtu\.be\/([\w-]{11})/) ||
|
||||||
|
url.match(/\/embed\/([\w-]{11})/) || url.match(/\/shorts\/([\w-]{11})/)
|
||||||
|
return m ? m[1] : ''
|
||||||
|
}
|
||||||
|
function isYtUrl(url) { return ytIdFromUrl(url).length > 0 }
|
||||||
|
function thumbUrl(url) {
|
||||||
|
var id = ytIdFromUrl(url)
|
||||||
|
if (id) return 'https://i.ytimg.com/vi/' + id + '/hqdefault.jpg'
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
function fmtTime(sec) {
|
||||||
|
var s = Math.max(0, Math.floor(Number(sec) || 0))
|
||||||
|
var m = Math.floor(s / 60)
|
||||||
|
var rem = s % 60
|
||||||
|
return m + ':' + (rem < 10 ? '0' : '') + rem
|
||||||
|
}
|
||||||
|
function setStatus(id, text, isError) {
|
||||||
|
var el = document.getElementById(id)
|
||||||
|
el.textContent = text || ''
|
||||||
|
el.classList.toggle('error', !!isError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 렌더 ──────────────────────────────────────────
|
||||||
|
function renderMusic() {
|
||||||
|
var ol = document.getElementById('music-list')
|
||||||
|
ol.innerHTML = ''
|
||||||
|
state.music.forEach(function (entry, idx) {
|
||||||
|
var li = document.createElement('li')
|
||||||
|
li.className = 'trackRow'
|
||||||
|
li.draggable = true
|
||||||
|
li.dataset.index = String(idx)
|
||||||
|
li.innerHTML =
|
||||||
|
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||||
|
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
||||||
|
'<div class="rowMeta">' +
|
||||||
|
'<div class="rowTitle">' + escapeHtml(entry.title || '(제목 없음)') + '</div>' +
|
||||||
|
'<div class="rowSub">' + escapeHtml(entry.artist || '') + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||||
|
attachRowEvents(li, 'music', idx)
|
||||||
|
ol.appendChild(li)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImage() {
|
||||||
|
var grid = document.getElementById('image-list')
|
||||||
|
grid.innerHTML = ''
|
||||||
|
state.images.forEach(function (entry, idx) {
|
||||||
|
var card = document.createElement('div')
|
||||||
|
card.className = 'imageCard'
|
||||||
|
card.draggable = true
|
||||||
|
card.dataset.index = String(idx)
|
||||||
|
card.innerHTML =
|
||||||
|
'<span class="cardNum">' + (idx + 1) + '</span>' +
|
||||||
|
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>'
|
||||||
|
attachRowEvents(card, 'image', idx)
|
||||||
|
grid.appendChild(card)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 행/카드 이벤트 (드래그·우클릭) ─────────────────
|
||||||
|
var dragSrc = null
|
||||||
|
function attachRowEvents(el, type, idx) {
|
||||||
|
el.addEventListener('dragstart', function (e) {
|
||||||
|
dragSrc = { type: type, index: idx }
|
||||||
|
el.classList.add('dragging')
|
||||||
|
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
})
|
||||||
|
el.addEventListener('dragend', function () {
|
||||||
|
el.classList.remove('dragging')
|
||||||
|
dragSrc = null
|
||||||
|
})
|
||||||
|
el.addEventListener('dragover', function (e) {
|
||||||
|
if (!dragSrc || dragSrc.type !== type) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
el.classList.add('dragOver')
|
||||||
|
})
|
||||||
|
el.addEventListener('dragleave', function () { el.classList.remove('dragOver') })
|
||||||
|
el.addEventListener('drop', function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
el.classList.remove('dragOver')
|
||||||
|
if (!dragSrc || dragSrc.type !== type) return
|
||||||
|
var srcIdx = dragSrc.index
|
||||||
|
var dstIdx = idx
|
||||||
|
if (srcIdx === dstIdx) return
|
||||||
|
var arr = (type === 'music') ? state.music : state.images
|
||||||
|
var moved = arr.splice(srcIdx, 1)[0]
|
||||||
|
arr.splice(dstIdx, 0, moved)
|
||||||
|
if (type === 'music') renderMusic(); else renderImage()
|
||||||
|
})
|
||||||
|
el.addEventListener('contextmenu', function (e) {
|
||||||
|
e.preventDefault()
|
||||||
|
openCtxMenu(e.pageX, e.pageY, type, idx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 컨텍스트 메뉴 ─────────────────────────────────
|
||||||
|
var ctxMenu = document.getElementById('ctxMenu')
|
||||||
|
var ctxTarget = null
|
||||||
|
function openCtxMenu(x, y, type, idx) {
|
||||||
|
ctxTarget = { type: type, index: idx }
|
||||||
|
ctxMenu.style.left = x + 'px'
|
||||||
|
ctxMenu.style.top = y + 'px'
|
||||||
|
ctxMenu.hidden = false
|
||||||
|
}
|
||||||
|
function closeCtxMenu() {
|
||||||
|
ctxMenu.hidden = true
|
||||||
|
ctxTarget = null
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (ctxMenu.hidden) return
|
||||||
|
if (!ctxMenu.contains(e.target)) closeCtxMenu()
|
||||||
|
})
|
||||||
|
ctxMenu.querySelectorAll('button').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
if (!ctxTarget) return
|
||||||
|
var action = b.getAttribute('data-ctx')
|
||||||
|
var t = ctxTarget
|
||||||
|
closeCtxMenu()
|
||||||
|
if (action === 'delete') {
|
||||||
|
if (t.type === 'music') state.music.splice(t.index, 1)
|
||||||
|
else state.images.splice(t.index, 1)
|
||||||
|
if (t.type === 'music') renderMusic(); else renderImage()
|
||||||
|
} else if (action === 'edit') {
|
||||||
|
openEditModal(t.type, t.index)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 수정 팝업 ─────────────────────────────────────
|
||||||
|
var editMusic = document.getElementById('editMusicModal')
|
||||||
|
var editImage = document.getElementById('editImageModal')
|
||||||
|
var editingIdx = -1
|
||||||
|
var editingImageMode = 'yt'
|
||||||
|
|
||||||
|
function openEditModal(type, idx) {
|
||||||
|
editingIdx = idx
|
||||||
|
if (type === 'music') {
|
||||||
|
document.getElementById('edit-music-url').value = state.music[idx].url || ''
|
||||||
|
editMusic.hidden = false
|
||||||
|
} else {
|
||||||
|
var url = state.images[idx].url || ''
|
||||||
|
editingImageMode = isYtUrl(url) ? 'yt' : 'img'
|
||||||
|
updateSegButtons()
|
||||||
|
document.getElementById('edit-image-url').value = url
|
||||||
|
editImage.hidden = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeAllModals() {
|
||||||
|
document.querySelectorAll('.modalOverlay').forEach(function (m) { m.hidden = true })
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-modal-close]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', closeAllModals)
|
||||||
|
})
|
||||||
|
document.querySelectorAll('.modalOverlay').forEach(function (m) {
|
||||||
|
m.addEventListener('click', function (e) {
|
||||||
|
if (e.target === m) closeAllModals()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('edit-music-save').addEventListener('click', function () {
|
||||||
|
var url = document.getElementById('edit-music-url').value.trim()
|
||||||
|
if (!url) { return }
|
||||||
|
state.music[editingIdx].url = url
|
||||||
|
closeAllModals()
|
||||||
|
renderMusic()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 이미지 수정 팝업의 토글 (유튜브 주소 / 이미지 주소)
|
||||||
|
function updateSegButtons() {
|
||||||
|
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
|
||||||
|
b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
editingImageMode = b.getAttribute('data-seg')
|
||||||
|
updateSegButtons()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
document.getElementById('edit-image-save').addEventListener('click', function () {
|
||||||
|
var url = document.getElementById('edit-image-url').value.trim()
|
||||||
|
if (!url) return
|
||||||
|
state.images[editingIdx].url = url
|
||||||
|
closeAllModals()
|
||||||
|
renderImage()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 액션 (save/clear/fetch) ───────────────────────
|
||||||
|
var confirmModal = document.getElementById('confirmModal')
|
||||||
|
function ask(title, message, onOk) {
|
||||||
|
document.getElementById('confirm-title').textContent = title
|
||||||
|
document.getElementById('confirm-message').textContent = message
|
||||||
|
confirmModal.hidden = false
|
||||||
|
var ok = document.getElementById('confirm-ok')
|
||||||
|
var handler = function () {
|
||||||
|
ok.removeEventListener('click', handler)
|
||||||
|
confirmModal.hidden = true
|
||||||
|
onOk()
|
||||||
|
}
|
||||||
|
ok.addEventListener('click', handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var action = btn.getAttribute('data-action')
|
||||||
|
var target = btn.getAttribute('data-target')
|
||||||
|
if (action === 'clear') {
|
||||||
|
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
|
||||||
|
if (target === 'music') { state.music = []; renderMusic() }
|
||||||
|
else { state.images = []; renderImage() }
|
||||||
|
})
|
||||||
|
} else if (action === 'save') {
|
||||||
|
doSave(target)
|
||||||
|
} else if (action === 'fetch') {
|
||||||
|
doFetch(target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function doSave(target) {
|
||||||
|
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
|
||||||
|
state.imagePlaylistUrl = document.getElementById('image-playlist-url').value.trim()
|
||||||
|
var statusId = 'status-' + target
|
||||||
|
setStatus(statusId, '저장 중…')
|
||||||
|
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(state)
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||||
|
}).then(function (result) {
|
||||||
|
if (result.ok && result.body.ok) setStatus(statusId, '저장 완료')
|
||||||
|
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
|
||||||
|
}).catch(function (err) {
|
||||||
|
setStatus(statusId, '저장 실패: ' + err.message, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function doFetch(target) {
|
||||||
|
var input = document.getElementById(target + '-playlist-url')
|
||||||
|
var url = input.value.trim()
|
||||||
|
if (!url) {
|
||||||
|
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () {
|
||||||
|
setStatus('status-' + target, '불러오는 중…')
|
||||||
|
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url })
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result.ok || !result.body.ok) {
|
||||||
|
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var entries = result.body.entries || []
|
||||||
|
if (target === 'music') {
|
||||||
|
state.music = entries.map(function (e) {
|
||||||
|
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0 }
|
||||||
|
})
|
||||||
|
renderMusic()
|
||||||
|
} else {
|
||||||
|
state.images = entries.map(function (e) { return { url: e.url } })
|
||||||
|
renderImage()
|
||||||
|
}
|
||||||
|
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
|
||||||
|
}).catch(function (err) {
|
||||||
|
setStatus('status-' + target, '실패: ' + err.message, true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 초기 렌더
|
||||||
|
renderMusic()
|
||||||
|
renderImage()
|
||||||
|
})()
|
||||||
@@ -357,3 +357,125 @@ body.siteBody.centerLayout {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── /op/list, /op/list/:pack, /op/datapack ────────────── */
|
||||||
|
|
||||||
|
.tabBar { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||||
|
.tabBtn {
|
||||||
|
background: transparent; border: none; color: var(--text-muted);
|
||||||
|
padding: 10px 18px; cursor: pointer; font-size: 14px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tabBtn:hover { color: var(--text); }
|
||||||
|
.tabBtn.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
.tabPanel { display: block; }
|
||||||
|
|
||||||
|
.listActionsRow { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.statusText { font-size: 13px; color: var(--text-muted); margin-left: 8px; }
|
||||||
|
.statusText.error { color: var(--danger); }
|
||||||
|
|
||||||
|
.playlistRow { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
.textInput {
|
||||||
|
flex: 1; background: var(--bg); color: var(--text);
|
||||||
|
border: 1px solid var(--border); padding: 10px 12px; border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.textInput:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* 음악 행 */
|
||||||
|
.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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.trackRow.dragging { opacity: 0.5; }
|
||||||
|
.trackRow.dragOver { border-color: var(--accent); }
|
||||||
|
.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; }
|
||||||
|
.rowTitle { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.rowSub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||||||
|
.rowDur { color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* 사진 그리드 */
|
||||||
|
.imageGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.imageCard {
|
||||||
|
position: relative; aspect-ratio: 1 / 1; background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
overflow: hidden; cursor: grab; user-select: none;
|
||||||
|
}
|
||||||
|
.imageCard.dragging { opacity: 0.5; }
|
||||||
|
.imageCard.dragOver { border-color: var(--accent); }
|
||||||
|
.imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.cardNum {
|
||||||
|
position: absolute; top: 6px; left: 6px;
|
||||||
|
background: rgba(0,0,0,0.7); color: #fff;
|
||||||
|
padding: 2px 8px; border-radius: 999px;
|
||||||
|
font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 컨텍스트 메뉴 */
|
||||||
|
.ctxMenu {
|
||||||
|
position: absolute; z-index: 200;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 4px; min-width: 120px;
|
||||||
|
box-shadow: 0 12px 24px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.ctxMenu button {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
background: transparent; border: none; color: var(--text);
|
||||||
|
padding: 8px 12px; cursor: pointer; font-size: 13px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.ctxMenu button:hover { background: var(--bg); }
|
||||||
|
|
||||||
|
/* 모달 (음악퀴즈 인스톨러의 modalOverlay 와 호환) */
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
|
}
|
||||||
|
.modalOverlay[hidden] { display: none; }
|
||||||
|
.modalCard {
|
||||||
|
background: var(--bg-alt); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; width: min(560px, 92vw); max-height: 86vh;
|
||||||
|
display: grid; grid-template-rows: auto 1fr auto; overflow: hidden;
|
||||||
|
}
|
||||||
|
.modalCard > header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modalCard > header h3 { margin: 0; font-size: 16px; }
|
||||||
|
.modalCard > footer { padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||||
|
.modalClose { background: transparent; border: none; color: var(--text-muted); font-size: 22px; cursor: pointer; }
|
||||||
|
.modalClose:hover { color: var(--text); }
|
||||||
|
.modalBody { padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.modalBody label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* 토글 버튼 (segmented) */
|
||||||
|
.segmentedRow { display: flex; gap: 4px; }
|
||||||
|
.segBtn {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border); color: var(--text-muted);
|
||||||
|
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
.segBtn.active { border-color: var(--accent); color: var(--text); background: rgba(47,129,247,0.15); }
|
||||||
|
|
||||||
|
/* 데이터팩 페이지 */
|
||||||
|
.dpControls { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.dpActions { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
|
||||||
|
.codeBlock {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 14px 16px; overflow-x: auto;
|
||||||
|
font-family: 'Consolas','SFMono-Regular',monospace; font-size: 13px;
|
||||||
|
white-space: pre-wrap; word-break: break-word;
|
||||||
|
max-height: 60vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.packCard.pickable { cursor: pointer; }
|
||||||
|
.packCard.pickable:hover { border-color: var(--accent); }
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import {
|
|||||||
deletePackKeys,
|
deletePackKeys,
|
||||||
listPackKeys,
|
listPackKeys,
|
||||||
loadPackDefinition,
|
loadPackDefinition,
|
||||||
|
loadPackList,
|
||||||
normalizePackDefinition,
|
normalizePackDefinition,
|
||||||
|
normalizePackList,
|
||||||
readAccounts,
|
readAccounts,
|
||||||
renamePack,
|
renamePack,
|
||||||
sanitizePackKey
|
sanitizePackKey,
|
||||||
|
savePackList
|
||||||
} from '../../shared/store'
|
} from '../../shared/store'
|
||||||
import { fetchReleaseVersions } from '../../shared/mojang'
|
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||||
|
import { fetchPlaylistEntries, YtDlpUnavailableError } from '../youtube'
|
||||||
import { requireAuth } from '../middleware/auth'
|
import { requireAuth } from '../middleware/auth'
|
||||||
import type { PackDefinition } from '../../shared/types'
|
import type { PackDefinition, PackList } from '../../shared/types'
|
||||||
|
|
||||||
export const opRouter = Router()
|
export const opRouter = Router()
|
||||||
|
|
||||||
@@ -117,6 +121,119 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── /op/list ──────────────────────────────────────────────────────────
|
||||||
|
// 음악퀴즈를 카드 한 줄로 표시. 카드 클릭 → /op/list/:packName
|
||||||
|
opRouter.get('/op/list', 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/list', { userId: req.session.userId, items })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 음악퀴즈 음악/사진 목록 편집 페이지.
|
||||||
|
opRouter.get('/op/list/: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('해당 음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = await loadPackList(packKey)
|
||||||
|
res.render('op/listEditor', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
packKey,
|
||||||
|
pack: definition,
|
||||||
|
list
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 음악/사진 목록 저장. JSON body.
|
||||||
|
opRouter.post('/op/list/:packName', 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: '음악퀴즈를 찾을 수 없습니다.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const normalized = normalizePackList(req.body)
|
||||||
|
await savePackList(packKey, normalized)
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환.
|
||||||
|
// body: { url: string }
|
||||||
|
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
||||||
|
const url = pickFirstValue(req.body?.url).trim()
|
||||||
|
if (!url) {
|
||||||
|
res.status(400).json({ ok: false, message: '플레이리스트 주소를 입력해 주세요.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const entries = await fetchPlaylistEntries(url)
|
||||||
|
res.json({ ok: true, entries })
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof YtDlpUnavailableError) {
|
||||||
|
res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.status(500).json({ ok: false, message: (error as Error).message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── /op/datapack ──────────────────────────────────────────────────────
|
||||||
|
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)
|
||||||
|
})))
|
||||||
|
res.render('op/datapack', { userId: req.session.userId, items })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 데이터팩 출력: 임시 포맷의 mcfunction 텍스트를 반환.
|
||||||
|
opRouter.get('/op/datapack/:packName/generate', 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('음악퀴즈를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = await loadPackList(packKey)
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push(`# === musicquiz: ${definition.name} ===`)
|
||||||
|
lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}장`)
|
||||||
|
lines.push(`say [musicquiz] 데이터팩 초기화`)
|
||||||
|
lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`)
|
||||||
|
list.music.forEach((entry, index) => {
|
||||||
|
const title = entry.title || '(제목 없음)'
|
||||||
|
const artist = entry.artist || '(가수 미상)'
|
||||||
|
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
|
||||||
|
})
|
||||||
|
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
||||||
|
} 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))
|
||||||
|
|||||||
82
src/server/youtube.ts
Normal file
82
src/server/youtube.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
|
export interface YtPlaylistEntry {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
channel: string
|
||||||
|
durationSec: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class YtDlpUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('서버에 yt-dlp가 설치돼 있지 않습니다. (수동 입력으로 진행)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yt-dlp 가 시스템에 있는지와 그 경로를 빠르게 확인.
|
||||||
|
* 없으면 YtDlpUnavailableError.
|
||||||
|
*/
|
||||||
|
async function probeYtDlp(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = spawn('yt-dlp', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let stderr = ''
|
||||||
|
probe.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||||
|
probe.on('error', () => reject(new YtDlpUnavailableError()))
|
||||||
|
probe.on('close', (code) => {
|
||||||
|
if (code === 0) resolve('yt-dlp')
|
||||||
|
else reject(new Error(`yt-dlp 실행 실패 (code=${code}): ${stderr}`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이리스트 URL 을 yt-dlp 로 펼쳐 각 영상의 메타데이터를 가져온다.
|
||||||
|
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
||||||
|
*/
|
||||||
|
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
||||||
|
const bin = await probeYtDlp()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(bin, [
|
||||||
|
'--flat-playlist',
|
||||||
|
'--dump-json',
|
||||||
|
'--no-warnings',
|
||||||
|
url
|
||||||
|
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||||
|
child.on('error', (err) => reject(err))
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`yt-dlp 플레이리스트 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
||||||
|
const parsed: YtPlaylistEntry[] = []
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line) as Record<string, unknown>
|
||||||
|
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||||
|
if (!id) continue
|
||||||
|
parsed.push({
|
||||||
|
id,
|
||||||
|
title: typeof obj.title === 'string' ? obj.title : '',
|
||||||
|
channel: typeof obj.channel === 'string'
|
||||||
|
? obj.channel
|
||||||
|
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
|
||||||
|
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
|
||||||
|
url: typeof obj.url === 'string' && obj.url.length > 0
|
||||||
|
? obj.url
|
||||||
|
: `https://www.youtube.com/watch?v=${id}`
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 한 줄이 깨져도 나머지는 살림
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(parsed)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,5 +6,6 @@ export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
|||||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||||
export const accountFilePath = path.join(projectRoot, 'account.json')
|
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||||
export const fileDirPath = path.join(projectRoot, 'file')
|
export const fileDirPath = path.join(projectRoot, 'file')
|
||||||
|
export const fileListDirPath = path.join(fileDirPath, 'list')
|
||||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||||
export const publicDirPath = path.join(projectRoot, 'public')
|
export const publicDirPath = path.join(projectRoot, 'public')
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import fsp from 'node:fs/promises'
|
import fsp from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { manifestRootPath, manifestDirPath, accountFilePath } from './paths'
|
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths'
|
||||||
import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types'
|
import type {
|
||||||
|
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
|
||||||
|
PackList, MusicListEntry, ImageListEntry
|
||||||
|
} from './types'
|
||||||
|
|
||||||
export async function readManifest(): Promise<Manifest> {
|
export async function readManifest(): Promise<Manifest> {
|
||||||
try {
|
try {
|
||||||
@@ -204,6 +207,63 @@ async function syncManifestWith(key: string, name: string, action: ManifestSyncA
|
|||||||
await writeManifest({ packs: filtered })
|
await writeManifest({ packs: filtered })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultPackList(): PackList {
|
||||||
|
return { musicPlaylistUrl: '', imagePlaylistUrl: '', music: [], images: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStr(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeNumber(value: unknown): number {
|
||||||
|
const n = typeof value === 'number' ? value : Number(value)
|
||||||
|
if (!Number.isFinite(n) || n < 0) return 0
|
||||||
|
return Math.floor(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePackList(input: unknown): PackList {
|
||||||
|
const fallback = defaultPackList()
|
||||||
|
if (!input || typeof input !== 'object') return fallback
|
||||||
|
const obj = input as Record<string, unknown>
|
||||||
|
const music = Array.isArray(obj.music) ? obj.music : []
|
||||||
|
const images = Array.isArray(obj.images) ? obj.images : []
|
||||||
|
return {
|
||||||
|
musicPlaylistUrl: sanitizeStr(obj.musicPlaylistUrl),
|
||||||
|
imagePlaylistUrl: sanitizeStr(obj.imagePlaylistUrl),
|
||||||
|
music: music
|
||||||
|
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
|
||||||
|
.map((entry): MusicListEntry => ({
|
||||||
|
url: sanitizeStr(entry.url),
|
||||||
|
title: sanitizeStr(entry.title),
|
||||||
|
artist: sanitizeStr(entry.artist),
|
||||||
|
durationSec: sanitizeNumber(entry.durationSec)
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.url.length > 0),
|
||||||
|
images: images
|
||||||
|
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
|
||||||
|
.map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) }))
|
||||||
|
.filter((entry) => entry.url.length > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPackList(packKey: string): Promise<PackList> {
|
||||||
|
const filePath = path.join(fileListDirPath, `${packKey}.json`)
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(filePath, 'utf8')
|
||||||
|
return normalizePackList(JSON.parse(raw))
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultPackList()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePackList(packKey: string, list: PackList): Promise<void> {
|
||||||
|
await fsp.mkdir(fileListDirPath, { recursive: true })
|
||||||
|
const filePath = path.join(fileListDirPath, `${packKey}.json`)
|
||||||
|
const normalized = normalizePackList(list)
|
||||||
|
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||||
|
|||||||
@@ -36,3 +36,26 @@ export interface AccountEntry {
|
|||||||
id: string
|
id: string
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MusicListEntry {
|
||||||
|
/** 유튜브 영상 주소. */
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
/** 노래 길이 (초). */
|
||||||
|
durationSec: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageListEntry {
|
||||||
|
/** 유튜브 영상 주소 또는 일반 이미지 URL. */
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackList {
|
||||||
|
/** 음악 플레이리스트 원본 주소 (저장 시 기억해서 재사용). */
|
||||||
|
musicPlaylistUrl: string
|
||||||
|
/** 사진 플레이리스트 원본 주소. */
|
||||||
|
imagePlaylistUrl: string
|
||||||
|
music: MusicListEntry[]
|
||||||
|
images: ImageListEntry[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<section class="dashboardHeader">
|
<section class="dashboardHeader">
|
||||||
<h1>음악퀴즈 목록</h1>
|
<h1>음악퀴즈 목록</h1>
|
||||||
<div class="dashboardActions">
|
<div class="dashboardActions">
|
||||||
|
<a class="secondaryButton" href="/op/list">음악목록 수정</a>
|
||||||
|
<a class="secondaryButton" href="/op/datapack">데이터팩 수정</a>
|
||||||
<form method="post" action="/op/dashboard/create" class="inlineForm">
|
<form method="post" action="/op/dashboard/create" class="inlineForm">
|
||||||
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
|
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
121
views/op/datapack.ejs
Normal file
121
views/op/datapack.ejs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>데이터팩 수정</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">← 돌아가기</a>
|
||||||
|
<h1 style="margin-top:8px;">데이터팩 수정</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dpControls">
|
||||||
|
<button type="button" class="primaryButton" id="pickPackBtn">음악퀴즈 선택</button>
|
||||||
|
<span class="muted" id="pickedLabel">선택된 음악퀴즈 없음</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="muted" id="countLabel"></p>
|
||||||
|
|
||||||
|
<section class="dpActions" hidden id="dpActions">
|
||||||
|
<button type="button" class="secondaryButton" id="exportBtn">데이터팩 출력</button>
|
||||||
|
<button type="button" class="secondaryButton" id="copyBtn">복사</button>
|
||||||
|
<span class="statusText" id="dp-status"></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<pre class="codeBlock" id="codeOut" hidden></pre>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 음악퀴즈 선택 팝업 -->
|
||||||
|
<div class="modalOverlay" id="pickModal" hidden>
|
||||||
|
<div class="modalCard">
|
||||||
|
<header><h3>음악퀴즈 선택</h3>
|
||||||
|
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||||
|
</header>
|
||||||
|
<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 %>">
|
||||||
|
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||||
|
<p class="muted"><%= item.key %>.json</p>
|
||||||
|
<% if (item.definition) { %>
|
||||||
|
<ul class="metaList">
|
||||||
|
<li>MC <%= item.definition.mcVersion %></li>
|
||||||
|
<li>플랫폼 <%= item.definition.platform.type %></li>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var pickModal = document.getElementById('pickModal')
|
||||||
|
var pickedKey = ''
|
||||||
|
document.getElementById('pickPackBtn').addEventListener('click', function () {
|
||||||
|
pickModal.hidden = false
|
||||||
|
})
|
||||||
|
document.querySelectorAll('[data-modal-close]').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () { pickModal.hidden = true })
|
||||||
|
})
|
||||||
|
pickModal.addEventListener('click', function (e) {
|
||||||
|
if (e.target === pickModal) pickModal.hidden = true
|
||||||
|
})
|
||||||
|
document.querySelectorAll('#pickList .pickable').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
pickedKey = card.getAttribute('data-key')
|
||||||
|
var name = card.getAttribute('data-name')
|
||||||
|
document.getElementById('pickedLabel').textContent = '선택: ' + name
|
||||||
|
pickModal.hidden = true
|
||||||
|
document.getElementById('dpActions').hidden = false
|
||||||
|
// 곡 수 미리 가져오기
|
||||||
|
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
|
||||||
|
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
|
||||||
|
document.getElementById('countLabel').textContent = ''
|
||||||
|
document.getElementById('codeOut').hidden = true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
document.getElementById('exportBtn').addEventListener('click', function () {
|
||||||
|
if (!pickedKey) return
|
||||||
|
var s = document.getElementById('dp-status')
|
||||||
|
s.textContent = '출력 중…'; s.classList.remove('error')
|
||||||
|
fetch('/op/datapack/' + encodeURIComponent(pickedKey) + '/generate')
|
||||||
|
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
s.textContent = '실패: ' + res.text; s.classList.add('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 = '총 ' + m[1] + '개의 음악을 찾았습니다.'
|
||||||
|
s.textContent = '출력 완료'
|
||||||
|
})
|
||||||
|
.catch(function (err) { s.textContent = '실패: ' + err.message; s.classList.add('error') })
|
||||||
|
})
|
||||||
|
document.getElementById('copyBtn').addEventListener('click', function () {
|
||||||
|
var out = document.getElementById('codeOut')
|
||||||
|
if (out.hidden) return
|
||||||
|
navigator.clipboard.writeText(out.textContent).then(function () {
|
||||||
|
var s = document.getElementById('dp-status')
|
||||||
|
s.textContent = '복사됨'
|
||||||
|
s.classList.remove('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
views/op/list.ejs
Normal file
42
views/op/list.ejs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>음악목록 수정</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">← 돌아가기</a>
|
||||||
|
<h1 style="margin-top:8px;">음악목록 수정</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cardRow horizontalScroll">
|
||||||
|
<% if (items.length === 0) { %>
|
||||||
|
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<% items.forEach(function (item) { %>
|
||||||
|
<article class="packCard">
|
||||||
|
<a class="cardLink" href="/op/list/<%= 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>MC <%= item.definition.mcVersion %></li>
|
||||||
|
<li>플랫폼 <%= item.definition.platform.type %></li>
|
||||||
|
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
131
views/op/listEditor.ejs
Normal file
131
views/op/listEditor.ejs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= pack.name %> — 음악/사진 목록</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/list">← 돌아가기</a>
|
||||||
|
<h1 style="margin-top:8px;"><%= pack.name %></h1>
|
||||||
|
<p class="muted"><%= packKey %>.json</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="tabBar">
|
||||||
|
<button type="button" class="tabBtn active" data-tab="music">음악목록</button>
|
||||||
|
<button type="button" class="tabBtn" data-tab="image">사진목록</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 음악 탭 -->
|
||||||
|
<section class="tabPanel" id="tab-music">
|
||||||
|
<div class="listActionsRow">
|
||||||
|
<button type="button" class="primaryButton" data-action="save" data-target="music">목록 저장</button>
|
||||||
|
<button type="button" class="dangerButton" data-action="clear" data-target="music">목록 초기화</button>
|
||||||
|
<span class="statusText" id="status-music"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playlistRow">
|
||||||
|
<input type="url" class="textInput" id="music-playlist-url"
|
||||||
|
placeholder="유튜브 플레이리스트 URL"
|
||||||
|
value="<%= list.musicPlaylistUrl %>" />
|
||||||
|
<button type="button" class="secondaryButton" data-action="fetch" data-target="music">플레이리스트 불러오기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="trackList" id="music-list"></ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 사진 탭 -->
|
||||||
|
<section class="tabPanel" id="tab-image" hidden>
|
||||||
|
<div class="listActionsRow">
|
||||||
|
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
|
||||||
|
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
|
||||||
|
<span class="statusText" id="status-image"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playlistRow">
|
||||||
|
<input type="url" class="textInput" id="image-playlist-url"
|
||||||
|
placeholder="유튜브 플레이리스트 URL"
|
||||||
|
value="<%= list.imagePlaylistUrl %>" />
|
||||||
|
<button type="button" class="secondaryButton" data-action="fetch" data-target="image">플레이리스트 불러오기</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="imageGrid" id="image-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Context menu -->
|
||||||
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
|
<button type="button" data-ctx="edit">수정</button>
|
||||||
|
<button type="button" data-ctx="delete">삭제</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm modal -->
|
||||||
|
<div class="modalOverlay" id="confirmModal" hidden>
|
||||||
|
<div class="modalCard">
|
||||||
|
<header><h3 id="confirm-title">확인</h3>
|
||||||
|
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||||
|
</header>
|
||||||
|
<div class="modalBody">
|
||||||
|
<p id="confirm-message"></p>
|
||||||
|
</div>
|
||||||
|
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
||||||
|
<button type="button" class="primaryButton" id="confirm-ok">확인</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit modal (music) -->
|
||||||
|
<div class="modalOverlay" id="editMusicModal" hidden>
|
||||||
|
<div class="modalCard">
|
||||||
|
<header><h3>음악 항목 수정</h3>
|
||||||
|
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||||
|
</header>
|
||||||
|
<div class="modalBody">
|
||||||
|
<label>유튜브 영상 주소
|
||||||
|
<input type="url" id="edit-music-url" class="textInput" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
||||||
|
<button type="button" class="primaryButton" id="edit-music-save">저장</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit modal (image) -->
|
||||||
|
<div class="modalOverlay" id="editImageModal" hidden>
|
||||||
|
<div class="modalCard">
|
||||||
|
<header><h3>사진 항목 수정</h3>
|
||||||
|
<button class="modalClose" type="button" data-modal-close>×</button>
|
||||||
|
</header>
|
||||||
|
<div class="modalBody">
|
||||||
|
<div class="segmentedRow">
|
||||||
|
<button type="button" class="segBtn active" data-seg="yt">유튜브 주소</button>
|
||||||
|
<button type="button" class="segBtn" data-seg="img">이미지 주소</button>
|
||||||
|
</div>
|
||||||
|
<label>주소
|
||||||
|
<input type="url" id="edit-image-url" class="textInput" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
||||||
|
<button type="button" class="primaryButton" id="edit-image-save">저장</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||||
|
var INITIAL = <%- JSON.stringify(list) %>;
|
||||||
|
</script>
|
||||||
|
<script src="/static/listEditor.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user