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:
@@ -4,14 +4,18 @@ import {
|
||||
deletePackKeys,
|
||||
listPackKeys,
|
||||
loadPackDefinition,
|
||||
loadPackList,
|
||||
normalizePackDefinition,
|
||||
normalizePackList,
|
||||
readAccounts,
|
||||
renamePack,
|
||||
sanitizePackKey
|
||||
sanitizePackKey,
|
||||
savePackList
|
||||
} from '../../shared/store'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||
import { fetchPlaylistEntries, YtDlpUnavailableError } from '../youtube'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { PackDefinition } from '../../shared/types'
|
||||
import type { PackDefinition, PackList } from '../../shared/types'
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
|
||||
Reference in New Issue
Block a user