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:
@@ -6,5 +6,6 @@ export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||
export const fileDirPath = path.join(projectRoot, 'file')
|
||||
export const fileListDirPath = path.join(fileDirPath, 'list')
|
||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||
export const publicDirPath = path.join(projectRoot, 'public')
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { manifestRootPath, manifestDirPath, accountFilePath } from './paths'
|
||||
import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types'
|
||||
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths'
|
||||
import type {
|
||||
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
|
||||
PackList, MusicListEntry, ImageListEntry
|
||||
} from './types'
|
||||
|
||||
export async function readManifest(): Promise<Manifest> {
|
||||
try {
|
||||
@@ -204,6 +207,63 @@ async function syncManifestWith(key: string, name: string, action: ManifestSyncA
|
||||
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[]> {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
|
||||
@@ -36,3 +36,26 @@ export interface AccountEntry {
|
||||
id: 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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user