Compare commits
82 Commits
678e886a52
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ae771668de | |||
| 40c47fbeb3 | |||
| 6e170646a7 | |||
| 3017e77479 | |||
| c8da4207fc | |||
|
|
dfb60046c8 | ||
|
|
6472b12d58 | ||
| bc974ecd24 | |||
| 132700720d | |||
| c527efc42f | |||
| 4ee0a59f2b | |||
| 06b35abcb1 | |||
| ca1c5f237f | |||
| 5ea9b49630 | |||
| 49f320fa71 | |||
| 848fac500e | |||
| 212e70cd56 | |||
| 3ca93abae9 | |||
| a8b9b689c2 | |||
| 1665f05c55 | |||
| 40b2ff81f5 | |||
| 9cb7c05b43 | |||
| 671831535b | |||
| 506e506cfa | |||
| 9db70d0bea | |||
| c8911a9a62 | |||
| 2a500a381f | |||
| ea72051e43 | |||
| c0472bb57b | |||
| de08f9a810 | |||
| af884706d4 | |||
| 2344c4b8d2 | |||
| f9cf373550 | |||
| f92dc02879 | |||
| 5e418a5c21 | |||
| 6cd402121b | |||
| 135bc98840 | |||
| c2fcc2fbbf | |||
| 401d72622e | |||
| 69ed4ad744 | |||
| 894a86a117 | |||
| 475bf924a0 | |||
| d194e28cf2 | |||
| a9b766d14d | |||
| 99ed5076c1 | |||
| c621185abc | |||
| d0e7aa4f41 | |||
| b407a2ca6a | |||
| 7d0f1719f3 | |||
| 536e94474f | |||
| d440514fdc | |||
| e31c6ed55b | |||
| c2fb7d03a6 | |||
| d630c90862 | |||
| 5a018bcb8d | |||
| 82307d9d16 | |||
| 45540f3db7 | |||
| df3d0a5cda | |||
| bb43e8b125 | |||
| 861e5678fc | |||
| 9f9cffffeb | |||
| d5079125cb | |||
| 4b83d95cbf | |||
| 8525517a87 | |||
| 9e96366956 | |||
| 5e3a42ff4f | |||
| 860c30fdfe | |||
| db5a1e0eac | |||
| da3a398684 | |||
| 4d18c93369 | |||
| 633a895617 | |||
| f27c3690e3 | |||
| e617c71b0a | |||
| 7ac07a58ef | |||
| 635c22c7ad | |||
| 7316477e23 | |||
| 7349d4e71e | |||
| a532ce5507 | |||
| a2817c921d | |||
| 26cc625de6 | |||
| d4ef76bc7f | |||
| 8574aeffa2 |
4
.env.build
Normal file
4
.env.build
Normal file
@@ -0,0 +1,4 @@
|
||||
# 빌드용 환경변수 — `npm run dist:win` / `npm run dist:win:rp` 로 패키징될 때
|
||||
# 설치기 exe 의 `resources/.env.build` 로 함께 배포되어 런타임에 로드됨.
|
||||
# 서버 운영용(PORT/HOST/SESSION_SECRET) 값은 여기 두지 말고 `.env` 에.
|
||||
SITE_BASE_URL=https://mc.tkrmagid.kr
|
||||
37
.env.build.example
Normal file
37
.env.build.example
Normal file
@@ -0,0 +1,37 @@
|
||||
# =============================================================================
|
||||
# 음악퀴즈 통합 패키지 — 빌드용 환경변수 템플릿
|
||||
#
|
||||
# 이 파일은 `npm run dist:win` / `npm run dist:win:rp` 로 exe 를 패키징할 때
|
||||
# 설치기(installer / installer-rp) 안에 함께 묶이는 값들입니다.
|
||||
# 개발 실행에서 쓰는 `.env` 와는 분리되어 있어, 운영 도메인 같은 값을 빌드용
|
||||
# 으로만 관리할 수 있습니다.
|
||||
#
|
||||
# 사용법:
|
||||
# 1) 이 파일을 복사해 `.env.build` 로 만든다.
|
||||
# 2) 운영 도메인 등 배포에 들어갈 값으로 채운다.
|
||||
# 3) `npm run dist:win` 또는 `npm run dist:win:rp` 로 빌드한다.
|
||||
# → electron-builder 가 `.env.build` 를 패키지된 exe 의
|
||||
# `resources/.env.build` 로 함께 배포.
|
||||
# → 런타임에서 `env.ts` 가 우선 로드.
|
||||
#
|
||||
# `.env.build` 는 .gitignore 로 제외되어 있습니다.
|
||||
# 서버(express) 운영용 PORT / HOST / SESSION_SECRET 같은 변수는 여기 두지 말고
|
||||
# 서버 측 `.env` 에 두세요. 이 파일은 설치기 exe 에 묶이는 값 전용입니다.
|
||||
# =============================================================================
|
||||
|
||||
# ----- 사이트 도메인(설치기가 manifest 를 받아갈 주소) -----
|
||||
|
||||
# 설치기 두 종(installer / installer-rp) 이 첫 화면에서 자동으로 채워 넣는
|
||||
# manifest 의 호스트. 프로토콜 + 호스트(+포트) 까지만 적고 슬래시는 끝에 붙이지 않음.
|
||||
# 예) 운영 도메인 : https://mq.example.com
|
||||
# 로컬 개발 : http://127.0.0.1:3000
|
||||
SITE_BASE_URL=https://mq.example.com
|
||||
|
||||
# 위 SITE_BASE_URL 로부터 자동으로 `${SITE_BASE_URL}/manifest.json` 이 생성됩니다.
|
||||
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
|
||||
# MANIFEST_URL=https://mq.example.com/manifest.json
|
||||
|
||||
# ----- 리소스팩 설치기 -----
|
||||
|
||||
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.
|
||||
# MUSIC_CONCURRENCY=
|
||||
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# =============================================================================
|
||||
# 음악퀴즈 통합 패키지 — 환경변수 템플릿
|
||||
# 이 파일을 복사해 `.env` 로 만든 뒤 값만 수정해 사용하세요.
|
||||
# `.env` 는 .gitignore 로 제외되어 있습니다.
|
||||
# =============================================================================
|
||||
|
||||
# ----- 관리 사이트(서버) -----
|
||||
|
||||
# 서버가 listen 할 포트
|
||||
PORT=3000
|
||||
|
||||
# 서버 바인드 주소. 127.0.0.1 이면 로컬 전용, 0.0.0.0 이면 외부 노출.
|
||||
HOST=127.0.0.1
|
||||
|
||||
# Express 세션 시크릿. 운영 환경에서는 반드시 추측 어려운 무작위 값으로.
|
||||
SESSION_SECRET=music-quiz-installer-dev-secret
|
||||
|
||||
# ----- 사이트 도메인(설치기가 manifest 를 받아갈 주소) -----
|
||||
|
||||
# 설치기 두 종(installer / installer-rp) 이 첫 화면에서 자동으로 채워 넣는
|
||||
# manifest 의 호스트. 프로토콜 + 호스트(+포트) 까지만 적고 슬래시는 끝에 붙이지 않음.
|
||||
# 예) 운영 도메인 : https://mq.example.com
|
||||
# 로컬 개발 : http://127.0.0.1:3000
|
||||
SITE_BASE_URL=http://127.0.0.1:3000
|
||||
|
||||
# 위 SITE_BASE_URL 로부터 자동으로 `${SITE_BASE_URL}/manifest.json` 이 생성됩니다.
|
||||
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
|
||||
# MANIFEST_URL=http://127.0.0.1:3000/manifest.json
|
||||
|
||||
# ----- 리소스팩 설치기 -----
|
||||
|
||||
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.
|
||||
# MUSIC_CONCURRENCY=
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,5 +3,7 @@ dist/
|
||||
release/
|
||||
logs/
|
||||
*.log
|
||||
file/*
|
||||
!file/.gitkeep
|
||||
conversations/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
462
README.md
462
README.md
@@ -1,356 +1,192 @@
|
||||
# 마인크래프트 음악퀴즈 간편설치기 + 관리 사이트 개발 명세서
|
||||
# 마인크래프트 음악퀴즈 통합 패키지
|
||||
|
||||
## 프로젝트 개요
|
||||
마인크래프트 음악퀴즈를 한 번에 배포·관리할 수 있도록 만든 통합 프로젝트입니다.
|
||||
|
||||
마인크래프트 음악퀴즈를 `.exe` 하나로 간편하게 설치할 수 있는 설치기와, 음악퀴즈 정보를 관리하는 웹사이트를 개발한다.
|
||||
- **관리 사이트** — 음악퀴즈 정보(JSON)와 음악·사진 목록, 데이터팩 출력을 한 곳에서 운영.
|
||||
- **음악퀴즈 간편설치기 (`.exe`)** — `manifest.json` 기반으로 사용자가 마인크래프트 본체·서버·모드를 자동 설치.
|
||||
- **리소스팩 간편설치기 (`.exe`)** — 음악퀴즈 음악·표지를 yt-dlp 로 받아 painting variant 텍스처 리소스팩으로 패키징.
|
||||
|
||||
### 핵심 컨셉
|
||||
- 이 프로젝트는 `%appdata%\.mc_custom` 폴더를 생성하여 모드 적용 및 서버 실행을 독립적으로 관리한다.
|
||||
- 음악퀴즈 정보는 **`manifest.json`** 으로 중앙 관리한다.
|
||||
---
|
||||
|
||||
### .mc_custom 폴더 구조
|
||||
## 무엇이 들어 있나
|
||||
|
||||
| 디렉터리 | 역할 | 진입점 |
|
||||
| --- | --- | --- |
|
||||
| `src/server/` | 음악퀴즈 관리 웹사이트 (Express + EJS) | `bun start` 또는 `npm start` |
|
||||
| `src/installer/` | 음악퀴즈 간편설치기 (Electron) | `npm run installer` |
|
||||
| `src/installer-rp/` | 리소스팩 간편설치기 (Electron) | `npm run installer:rp` |
|
||||
| `src/shared/` | 두 설치기와 서버가 공유하는 타입·스토어 | — |
|
||||
| `views/` | EJS 템플릿 (관리 사이트) | — |
|
||||
| `manifest/` | 음악퀴즈별 정의 JSON | — |
|
||||
| `file/list/` | 음악퀴즈별 음악·사진 목록 JSON | — |
|
||||
| `file/` | 정적 파일(서버 zip / 맵 zip / 모드 jar 등) 호스팅 | — |
|
||||
|
||||
---
|
||||
|
||||
## 핵심 컨셉
|
||||
|
||||
설치기는 사용자의 `%APPDATA%\.minecraft` 를 더럽히지 않기 위해 **`.mc_custom`** 을 별도 게임 디렉터리로 사용합니다.
|
||||
|
||||
```
|
||||
%appdata%\.mc_custom\
|
||||
├── mods/ ← 모드 (.jar) 저장 및 자동 적용
|
||||
├── resourcepacks/ ← 리소스팩 (.zip) 저장 및 자동 적용
|
||||
├── saves/ ← 월드 저장
|
||||
├── config/ ← 모드 설정 파일
|
||||
├── screenshots/ ← 스크린샷
|
||||
└── options.txt ← 게임 설정
|
||||
%APPDATA%\
|
||||
├─ .minecraft\ ← 원래 마인크래프트 폴더(공용 자원: assets, libraries, versions, runtime)
|
||||
└─ .mc_custom\ ← 음악퀴즈 전용 게임 폴더(설치기가 자동 생성)
|
||||
├─ mods\ ← 음악퀴즈가 지정한 모드(.jar)
|
||||
├─ resourcepacks\ ← 리소스팩(.zip)
|
||||
├─ saves\ ← 단일 맵 .zip 압축 해제 결과
|
||||
├─ assets\ (junction → .minecraft\assets)
|
||||
├─ libraries\ (junction → .minecraft\libraries)
|
||||
├─ versions\ (junction → .minecraft\versions)
|
||||
├─ options.txt 등 ← `.minecraft` 의 최상위 설정 파일을 복사해 사용
|
||||
└─ launcher_profiles ← 실제 파일은 `.minecraft\launcher_profiles.json` 을 수정해 gameDir=.mc_custom 으로 지정
|
||||
```
|
||||
|
||||
- 마인크래프트 런처 프로필의 `gameDir`을 `%appdata%\.mc_custom`으로 설정하면, 마인크래프트가 이 폴더를 기준으로 모든 파일을 읽고 저장한다.
|
||||
- 버전 파일과 assets는 기존 `%appdata%\.minecraft`를 그대로 사용한다.
|
||||
이렇게 분리해 두면 사용자가 평소 쓰던 마인크래프트와 음악퀴즈 설정이 섞이지 않고, 음악퀴즈만 삭제해도 본체에는 영향이 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 파트 1. 간편설치기 (`.exe`)
|
||||
## 빠른 시작
|
||||
|
||||
> 설치기는 아래 단계를 순서대로 페이지 단위로 진행한다. 각 번호 = 1페이지.
|
||||
전제: Node.js 18+, npm. 윈도우 빌드를 만들 때만 추가로 Electron 의 PE 서명·아이콘 도구가 필요합니다.
|
||||
|
||||
### 1단계: 음악퀴즈 선택
|
||||
```bash
|
||||
# 의존성 설치
|
||||
npm install
|
||||
|
||||
- 음악퀴즈사이트(아래 파트 2 참고)에서 `manifest.json`을 가져와 등록된 음악퀴즈 목록을 표시한다.
|
||||
- 사용자가 설치할 음악퀴즈를 선택한다.
|
||||
# 환경변수 템플릿 복사 (처음 한 번만)
|
||||
cp .env.example .env
|
||||
|
||||
---
|
||||
# 1) 관리 사이트 개발 실행 (http://localhost:3000)
|
||||
npm start
|
||||
|
||||
### 2단계: 싱글 / 멀티 선택
|
||||
# 2) 음악퀴즈 간편설치기를 Electron 으로 실행해 보기
|
||||
npm run installer
|
||||
|
||||
- 싱글 또는 멀티 중 하나를 선택하는 화면을 표시한다.
|
||||
- **멀티 선택 시**: 3단계(서버 설치)를 거친 후 4단계로 진행한다.
|
||||
- **싱글 선택 시**: 3단계를 건너뛰고 4단계로 바로 진행한다.
|
||||
# 3) 리소스팩 간편설치기를 Electron 으로 실행해 보기
|
||||
npm run installer:rp
|
||||
|
||||
---
|
||||
|
||||
### 3단계: 서버 관련 설정
|
||||
|
||||
- 2단계에서 **멀티**를 선택한 경우에만 진입한다. 싱글 선택 시 이 단계 전체를 건너뛴다.
|
||||
- 3단계의 각 소항목(3-1 ~ 3-5)은 완료되어도 자동으로 다음으로 넘어가지 않으며, 사용자가 **확인 버튼**을 눌러야 다음 소항목으로 진행된다.
|
||||
|
||||
#### 3-1. 서버 설치 경로 설정
|
||||
|
||||
- 서버를 생성할 폴더 경로를 사용자가 직접 지정한다.
|
||||
- **경로에 한글이 포함되면 안 된다.** 한글 포함 시 경고 메시지를 표시하고 다음 단계로 진행 불가.
|
||||
|
||||
#### 3-2. JDK 확인 / 설치
|
||||
|
||||
- 폴더 선택 UI로 JDK 경로를 지정할 수 있다.
|
||||
- JDK 자동 탐색 우선순위:
|
||||
1. 시스템 환경변수 (`JAVA_HOME` 등)
|
||||
2. 로컬 환경변수
|
||||
3. `C:\Program Files\Java` (JDK 기본 설치 경로)
|
||||
- 위 경로에서 JDK가 발견되면 해당 경로를 기본값으로 자동 설정한다.
|
||||
- JDK가 없으면 설치를 안내한다.
|
||||
|
||||
#### 3-3. 서버 다운로드 및 설치
|
||||
- 처음 파일 다운로드는 자동으로 시작
|
||||
|
||||
##### 3-3-1. 파일 다운로드
|
||||
- JSON의 `packPath` 필드 값을 `도메인/file/` 뒤에 붙여 서버 파일 다운로드 URL을 구성한다.
|
||||
- 예: `packPath`가 `music-quiz/files`이면 → `도메인/file/music-quiz/files`
|
||||
- 해당 경로의 모든 파일을 순서대로 다운로드한다.
|
||||
|
||||
##### 3-3-2. 설치 로그
|
||||
- 다른 프로그램 설치 화면처럼 실시간 로그를 표시하는 로그 뷰어를 제공한다.
|
||||
|
||||
##### 3-3-3. EULA 동의
|
||||
- 설치 중간에 Minecraft EULA 동의 화면을 표시하고, 사용자가 직접 동의해야 다음 단계로 진행된다.
|
||||
- 음악퀴즈 내에 `eula.txt`가 포함되어 있으면 **삭제하고** 새로 동의를 받는다.
|
||||
|
||||
##### 3-3-4. 램 검사 로직
|
||||
|
||||
> 아래 기준은 모두 JSON의 서버 램 필드(`serverMinRam`, `serverMaxRam`)를 사용한다.
|
||||
|
||||
```
|
||||
유저 시스템 램 >= serverMaxRam → serverMaxRam으로 설정
|
||||
유저 시스템 램 >= serverMinRam → serverMinRam으로 설정 (경고 메시지 표시)
|
||||
유저 시스템 램 < serverMinRam → "플레이 불가" 메시지 출력 후 설치 중단
|
||||
# 4) 음악퀴즈 간편설치기 윈도우 .exe 빌드
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
- 서버 실행 시 `-Xmx`에 `serverMaxRam`, `-Xms`에 `serverMinRam` 값을 JVM 인자로 사용한다.
|
||||
|
||||
#### 3-4. 서버 설정
|
||||
|
||||
- **로컬 웹서버**를 띄워 브라우저에서 서버 설정 파일을 GUI로 편집할 수 있게 한다.
|
||||
- 메모장으로 수정해야 했던 파일들을 설명과 함께 편리하게 수정 가능:
|
||||
- `bukkit.yml`
|
||||
- `server.properties`
|
||||
- 기타 설정 파일
|
||||
- 수정 후 "적용" 버튼으로 실제 파일에 반영한다.
|
||||
|
||||
#### 3-5. 서버 포트포워딩 설정
|
||||
|
||||
1. **이미 포트포워딩 되어 있는 경우** (UPnP 포함): 외부 접속 주소를 화면에 표시하고 다음 단계로 진행.
|
||||
2. **포트포워딩 안 된 경우 → UPnP 시도**:
|
||||
- UPnP로 포트 개방 가능 여부 확인
|
||||
- 가능하면 자동으로 개방 후 외부 접속 테스트
|
||||
- 접속 확인되면 다음 단계로 진행 가능
|
||||
3. **UPnP 불가 시**: "직접 포트포워딩을 해주세요." 메시지를 안내와 함께 표시.
|
||||
리소스팩 설치기는 `yt-dlp` 가 필요합니다. 자동 다운로드되지만, 막혀 있는 환경이라면 [`docs/yt-dlp-setup.md`](docs/yt-dlp-setup.md) 참고.
|
||||
|
||||
---
|
||||
|
||||
### 4단계: 유저 클라이언트 설정
|
||||
- 음악퀴즈 JSON 파일에 명시된 클라이언트 설정을 기반으로 설치한다.
|
||||
## 자주 쓰는 문서
|
||||
|
||||
#### 4-1. 모드 플랫폼 설치
|
||||
- JSON 파일의 `platform.type`에 명시된 플랫폼 이름을 화면에 표시하고, **설치** / **건너뛰기** 버튼으로 사용자가 직접 선택한다.
|
||||
- `vanilla`가 아닌 경우: `platform.downloadUrl`을 기반으로 다운로드 후 설치한다.
|
||||
- **건너뛰기** 선택 시: 플랫폼 설치 없이 바닐라로 진행한다.
|
||||
|
||||
#### 4-2. 설치설정 설정
|
||||
- `%appdata%\.minecraft\launcher_profiles.json`에 프로필을 추가한다.
|
||||
- 이미 동일한 이름의 프로필이 있으면 새로 만들지 않고 기존 프로필을 수정한다.
|
||||
- **`gameDir`을 `%appdata%\.mc_custom`으로 설정한다.**
|
||||
- 이 설정으로 인해 모드, 리소스팩, 세이브 등 모든 파일이 `.mc_custom` 기준으로 읽히고 저장된다.
|
||||
- 램 설정(`javaArgs`)을 JSON의 서버 램 필드 기준으로 적용한다.
|
||||
- `-Xmx`에 `serverMaxRam`, `-Xms`에 `serverMinRam` 값을 사용한다.
|
||||
- 플랫폼(Forge 등) 설치 버전을 `lastVersionId`로 설정한다.
|
||||
|
||||
```json
|
||||
// launcher_profiles.json 프로필 예시
|
||||
{
|
||||
"음악퀴즈": {
|
||||
"name": "음악퀴즈",
|
||||
"type": "custom",
|
||||
"gameDir": "%appdata%\\.mc_custom",
|
||||
"lastVersionId": "1.20.1-forge-47.2.0",
|
||||
"javaArgs": "-Xmx4G -Xms2G"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4-3. 모드 및 리소스팩 설치
|
||||
- JSON 파일에 명시된 모드와 리소스팩을 다운로드하여 `.mc_custom` 하위 폴더에 저장한다.
|
||||
- 다운로드 진행 상황을 실시간 로그 뷰어로 표시한다. (다른 프로그램 설치 화면과 동일한 형태)
|
||||
|
||||
| 파일 종류 | 저장 경로 |
|
||||
|-----------|-----------|
|
||||
| 모드 (`.jar`) | `%appdata%\.mc_custom\mods\` |
|
||||
| 리소스팩 (`.zip`) | `%appdata%\.mc_custom\resourcepacks\` |
|
||||
|
||||
- 이미 동일한 파일명이 존재하면 덮어쓴다.
|
||||
- 다운로드 완료 후 마인크래프트 실행 시 자동으로 적용된다. (별도 설정 불필요)
|
||||
| 문서 | 내용 |
|
||||
| --- | --- |
|
||||
| [`docs/installer.md`](docs/installer.md) | 음악퀴즈 간편설치기 사용자 흐름(단계별 화면·동작). |
|
||||
| [`docs/admin-site.md`](docs/admin-site.md) | 관리 사이트 운영자 가이드(음악퀴즈 추가·편집, 음악 목록, 데이터팩 출력). |
|
||||
| [`docs/resourcepack-installer.md`](docs/resourcepack-installer.md) | 리소스팩 간편설치기 동작 명세(yt-dlp 흐름, 이미지 정규화 규칙). |
|
||||
| [`docs/painting-variant.md`](docs/painting-variant.md) | 1.21+ painting variant 슬롯/리소스팩 텍스처 규격. |
|
||||
| [`docs/yt-dlp-setup.md`](docs/yt-dlp-setup.md) | yt-dlp 자동/수동 설치, 트러블슈팅. |
|
||||
|
||||
---
|
||||
|
||||
### 5단계: 완료 페이지
|
||||
## 데이터 포맷 (요약)
|
||||
|
||||
- 멀티로 진행해서 서버도 설치했다면:
|
||||
- **서버 폴더 열기** 버튼
|
||||
- **바탕화면에 서버 실행 바로가기 만들기** 토글 (기본값: ON)
|
||||
- **서버 바로 실행** 토글 (기본값: ON)
|
||||
- 마인크래프트 런처 실행 토글 (기본값: ON)
|
||||
자세한 필드 설명은 `docs/admin-site.md` 의 "음악퀴즈 JSON" 절을 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 파트 2. 음악퀴즈 관리 웹사이트
|
||||
|
||||
> **기술 스택: Node.js + TypeScript + Express + EJS**
|
||||
|
||||
### 라우팅 구조
|
||||
|
||||
| 경로 | 설명 |
|
||||
|------|------|
|
||||
| `/` | 메인 페이지 (음악퀴즈 목록) |
|
||||
| `/manifest.json` | manifest.json 파일 직접 접근 |
|
||||
| `/file/` | 음악퀴즈 파일 제공 경로 |
|
||||
| `/op` | 관리자 로그인 페이지 |
|
||||
| `/op/dashboard` | 관리자 대시보드 |
|
||||
| `/op/dashboard/:packName` | 음악퀴즈 JSON 편집 페이지 |
|
||||
|
||||
---
|
||||
|
||||
### 메인 페이지 (`/`)
|
||||
|
||||
- `manifest.json`에 등록된 음악퀴즈를 **가로 한 줄 목록(카드) 형식**으로 표시한다.
|
||||
|
||||
---
|
||||
|
||||
### 관리자 인증 (`/op`)
|
||||
|
||||
- `/op` 하위 모든 경로는 로그인 없이 접근 불가 (미들웨어로 처리).
|
||||
- 로그인 화면: 아이디 + 비밀번호 입력.
|
||||
- 계정 정보는 **`account.json`** 파일에 저장.
|
||||
- `account.json`은 **서버 내부에서만 접근 가능**, 외부 HTTP 요청으로 절대 노출되지 않아야 함.
|
||||
- 로그인 성공 시 `/op/dashboard`로 리다이렉트.
|
||||
|
||||
#### account.json 예시 구조
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "admin",
|
||||
"password": "admin"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 관리자 대시보드 (`/op/dashboard`)
|
||||
|
||||
#### 공통 레이아웃 (메뉴바)
|
||||
- **왼쪽**: 로고 + "관리자 페이지" 텍스트 → 클릭 시 `/op/dashboard`로 이동
|
||||
- **오른쪽**: 로그인한 아이디 표시 → 클릭 시 드롭다운 메뉴 표시
|
||||
- 드롭다운 항목: **로그아웃** 버튼
|
||||
|
||||
#### 음악퀴즈 목록
|
||||
- `/manifest` 폴더 안의 JSON 파일들을 가져와 **가로 한 줄 카드 형식**으로 표시.
|
||||
- 카드 클릭 → `/op/dashboard/:packName` 으로 이동하여 JSON 편집 시작.
|
||||
|
||||
#### 음악퀴즈 추가 버튼
|
||||
- `/manifest/` 폴더 안에 새 JSON 파일 생성.
|
||||
- 기본 이름: `new.json`
|
||||
- 이미 존재하면: `new2.json`, `new3.json` ... 순으로 증가.
|
||||
- 생성과 동시에 `manifest.json`에도 자동 등록.
|
||||
|
||||
#### 음악퀴즈 삭제 버튼
|
||||
- 버튼 클릭 시:
|
||||
- 목록 카드에 **체크박스** 표시
|
||||
- 버튼 아래 **취소** / **확인** 버튼 표시
|
||||
- 확인 클릭 시 체크된 JSON 파일 삭제 + `manifest.json`에서도 자동 제거.
|
||||
|
||||
---
|
||||
|
||||
### 음악퀴즈 편집 페이지 (`/op/dashboard/:packName`)
|
||||
|
||||
- 해당 JSON 파일의 내용을 GUI 폼으로 편집한다.
|
||||
- **JSON 파일 이름 변경** 기능 제공.
|
||||
- 이름 변경 후 적용 클릭 시 현재 브라우저 URL의 `:packName` 부분도 자동 변경 (리다이렉트).
|
||||
|
||||
---
|
||||
|
||||
## 파트 3. 음악퀴즈 JSON 구조 및 편집 항목
|
||||
|
||||
> `/manifest/*.json` 파일의 구조. 관리자 편집 페이지에서 아래 항목들을 GUI로 수정 가능.
|
||||
|
||||
### JSON 필드 정의
|
||||
|
||||
| 필드명 | 타입 | 설명 |
|
||||
|--------|------|------|
|
||||
| `name` | `string` | 음악퀴즈 이름 |
|
||||
| `mcVersion` | `string` | 마인크래프트 버전 (스냅샷 제외한 정식 릴리즈만) |
|
||||
| `platform` | `object` | 모드 플랫폼 정보 (아래 참고) |
|
||||
| `platform.type` | `string` | 플랫폼 종류 (`vanilla` / `forge` / `fabric` / `neoforge` 등) |
|
||||
| `platform.downloadUrl` | `string` | 플랫폼 설치파일 다운로드 URL (바닐라면 생략) |
|
||||
| `mods` | `array` | 설치할 모드 목록 (아래 참고) |
|
||||
| `mods[].name` | `string` | 모드 이름 |
|
||||
| `mods[].downloadUrl` | `string` | 모드 다운로드 URL |
|
||||
| `resourcepacks` | `array` | 설치할 리소스팩 목록 |
|
||||
| `resourcepacks[].name` | `string` | 리소스팩 이름 |
|
||||
| `resourcepacks[].downloadUrl` | `string` | 리소스팩 다운로드 URL |
|
||||
| `serverMinRam` | `number` | 서버 최소 램 (MB 단위) |
|
||||
| `serverMaxRam` | `number` | 서버 최대 램 (MB 단위) |
|
||||
| `clientMinRam` | `number` | 유저(클라이언트) 최소 램 (MB 단위) |
|
||||
| `clientRecommendedRam` | `number` | 유저(클라이언트) 권장 램 (MB 단위) |
|
||||
| `packPath` | `string` | 서버 파일 경로 (`/file/` 이후의 경로, 멀티 전용) |
|
||||
|
||||
### JSON 예시
|
||||
```json
|
||||
{
|
||||
"name": "음악퀴즈 v1",
|
||||
"mcVersion": "1.20.1",
|
||||
"platform": {
|
||||
"type": "forge",
|
||||
"downloadUrl": "https://example.com/forge-installer.jar"
|
||||
},
|
||||
"mods": [
|
||||
{
|
||||
"name": "ExampleMod",
|
||||
"downloadUrl": "https://example.com/examplemod.jar"
|
||||
}
|
||||
],
|
||||
"resourcepacks": [
|
||||
{
|
||||
"name": "ExampleResourcePack",
|
||||
"downloadUrl": "https://example.com/resourcepack.zip"
|
||||
}
|
||||
],
|
||||
"serverMinRam": 2048,
|
||||
"serverMaxRam": 8192,
|
||||
"clientMinRam": 4096,
|
||||
"clientRecommendedRam": 8192,
|
||||
"packPath": "music-quiz/files"
|
||||
}
|
||||
```
|
||||
|
||||
### 편집 UI 항목별 비고
|
||||
|
||||
| 항목 | UI 형태 | 비고 |
|
||||
|------|---------|------|
|
||||
| `mcVersion` | 드롭다운 | Mojang API에서 정식 릴리즈만 가져와 표시, 스냅샷 제외 |
|
||||
| `platform.type` | 드롭다운 | `vanilla` 선택 시 `downloadUrl` 입력란 숨김 |
|
||||
| `mods` | 동적 목록 | 항목 추가 / 삭제 가능 |
|
||||
| `resourcepacks` | 동적 목록 | 항목 추가 / 삭제 가능 |
|
||||
| 램 관련 필드 | 숫자 입력 | MB 단위, `clientMinRam` ≤ `clientRecommendedRam` 유효성 검사 |
|
||||
| `packPath` | 텍스트 입력 | `/file/` 이후 경로만 입력 |
|
||||
|
||||
---
|
||||
|
||||
## 파트 4. manifest.json 구조
|
||||
|
||||
> 사이트 루트의 `manifest.json`. 설치기와 메인 페이지가 이 파일을 읽는다.
|
||||
### `manifest.json` — 사이트 루트, 음악퀴즈 목록
|
||||
|
||||
```json
|
||||
{
|
||||
"packs": [
|
||||
{
|
||||
"name": "음악퀴즈 이름",
|
||||
"file": "new"
|
||||
}
|
||||
{ "name": "음악퀴즈 v1", "file": "mq-v1" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `file`: `/manifest/` 폴더 안의 JSON 파일 이름 (확장자 제외).
|
||||
- 음악퀴즈 추가/삭제 시 자동으로 이 파일도 업데이트된다.
|
||||
- `file` 은 `/manifest/<file>.json` 의 파일명(확장자 제외).
|
||||
- 관리 사이트에서 음악퀴즈를 추가/삭제하면 자동으로 갱신됩니다.
|
||||
|
||||
### `/manifest/<key>.json` — 음악퀴즈 정의
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "음악퀴즈 v1",
|
||||
"mcVersion": "1.21.4",
|
||||
"platform": {
|
||||
"type": "fabric",
|
||||
"loaderVersion": "0.16.10"
|
||||
},
|
||||
"modsFolder": "mq-v1",
|
||||
"resourcepackPath": "mq-v1.zip",
|
||||
"mapPath": "mq-v1-map.zip",
|
||||
"serverPath": "mq-v1-server.zip",
|
||||
"serverMinRam": 4096,
|
||||
"serverMaxRam": 8192,
|
||||
"clientMinRam": 4096,
|
||||
"clientRecommendedRam": 8192
|
||||
}
|
||||
```
|
||||
|
||||
- `platform.type` = `vanilla` / `forge` / `fabric` / `neoforge`.
|
||||
- `fabric` 은 `loaderVersion` 만 지정하면 설치기가 최신 fabric-installer 로 자동 CLI 설치합니다.
|
||||
- 나머지(forge/neoforge) 는 `platform.downloadUrl` 에 설치 jar URL.
|
||||
- `modsFolder` → `/file/mods/<폴더>/` 의 모든 `.jar` 를 자동으로 받습니다.
|
||||
- `serverPath` / `mapPath` / `resourcepackPath` → `/file/servers/`, `/file/maps/`, `/file/resourcepacks/` 아래 zip 파일명.
|
||||
|
||||
### `/file/list/<key>.json` — 음악·사진 목록 (리소스팩 설치기용)
|
||||
|
||||
```json
|
||||
{
|
||||
"musicPlaylistUrl": "https://www.youtube.com/playlist?list=...",
|
||||
"imagePlaylistUrl": "https://www.youtube.com/playlist?list=...",
|
||||
"music": [
|
||||
{ "url": "https://www.youtube.com/watch?v=...", "title": "...", "artist": "...", "durationSec": 213 }
|
||||
],
|
||||
"images": [
|
||||
{ "url": "https://www.youtube.com/watch?v=..." },
|
||||
{ "url": "https://example.com/cover.png" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조 (웹사이트)
|
||||
## 디렉터리 구조 (전체)
|
||||
|
||||
```
|
||||
project-root/
|
||||
├── manifest.json # 음악퀴즈 목록 (외부 접근 가능)
|
||||
├── account.json # 관리자 계정 정보 (외부 접근 절대 불가)
|
||||
├── /manifest/ # 음악퀴즈 JSON 파일 저장 폴더
|
||||
│ ├── new.json
|
||||
│ └── music-quiz.json
|
||||
├── /file/ # 서버 파일 제공 경로 (멀티 전용)
|
||||
├── /src/ # TypeScript 소스
|
||||
│ ├── app.ts
|
||||
│ ├── routes/
|
||||
│ │ ├── index.ts # 메인 페이지
|
||||
│ │ └── op.ts # 관리자 라우트
|
||||
│ └── middleware/
|
||||
│ └── auth.ts # /op 인증 미들웨어
|
||||
└── /views/ # EJS 템플릿
|
||||
├── index.ejs
|
||||
├── op/
|
||||
│ ├── login.ejs
|
||||
│ ├── dashboard.ejs
|
||||
│ └── editor.ejs
|
||||
└── partials/
|
||||
└── navbar.ejs
|
||||
minecraft_launcher/
|
||||
├─ src/
|
||||
│ ├─ server/ Express + EJS 관리 사이트
|
||||
│ ├─ installer/ 음악퀴즈 간편설치기 (Electron 메인 + preload)
|
||||
│ ├─ installer-rp/ 리소스팩 간편설치기 (Electron 메인 + 음악/이미지 파이프라인)
|
||||
│ └─ shared/ 공용 타입, 매니페스트 스토어, mojang/upnp 유틸
|
||||
├─ installer/ 음악퀴즈 설치기 렌더러(HTML/CSS/JS)
|
||||
├─ installer-rp/ 리소스팩 설치기 렌더러(HTML/CSS/JS)
|
||||
├─ views/ 관리 사이트 EJS 템플릿
|
||||
├─ public/ 관리 사이트 정적 파일(styles.css 등)
|
||||
├─ manifest/ 음악퀴즈 JSON 정의 (운영자가 편집)
|
||||
├─ file/
|
||||
│ ├─ servers/ 서버 zip
|
||||
│ ├─ maps/ 맵 zip
|
||||
│ ├─ mods/<폴더>/ 모드 jar 묶음 (index.json 자동 생성)
|
||||
│ ├─ resourcepacks/ 리소스팩 zip
|
||||
│ ├─ platforms/ Forge / NeoForge 설치 jar
|
||||
│ └─ list/<key>.json 음악·사진 목록
|
||||
├─ docs/ 사용·운영 문서
|
||||
├─ manifest.json 사이트 루트 매니페스트 (자동 관리)
|
||||
├─ account.json 관리자 계정 (절대 외부 노출 금지)
|
||||
├─ package.json
|
||||
└─ tsconfig.{,server,installer,installer-rp}.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 빌드 산출물 / 배포
|
||||
|
||||
| 산출물 | 빌드 명령 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 관리 사이트 (Node 실행) | `npm start` | systemd 등으로 띄우기. 외부 도메인이 manifest 의 base URL 이 됩니다. |
|
||||
| 음악퀴즈 간편설치기 `.exe` | `npm run dist:win` | `electron-builder.yml` 설정 사용. |
|
||||
| 리소스팩 간편설치기 `.exe` | `tsconfig.installer-rp.json` 빌드 후 `electron-builder` 수동 패키징 | |
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
내부 프로젝트. 외부 공개 시 별도 명시.
|
||||
|
||||
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
141
docs/admin-site.md
Normal file
141
docs/admin-site.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 관리 사이트 운영 가이드
|
||||
|
||||
음악퀴즈 정의(`/manifest/*.json`), 음악·사진 목록(`/file/list/*.json`), 데이터팩 출력을 한 곳에서 관리하는 Express + EJS 사이트입니다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # 처음 한 번만. 운영 도메인이면 SITE_BASE_URL 만 바꾸면 됩니다.
|
||||
npm start # 기본 포트 3000.
|
||||
```
|
||||
|
||||
배포 시에는 시스템 서비스(systemd 등) 로 등록해 두면 됩니다.
|
||||
|
||||
### 환경변수 (`.env`)
|
||||
|
||||
| 키 | 기본값 | 설명 |
|
||||
| --- | --- | --- |
|
||||
| `PORT` | `3000` | Express 서버 listen 포트. |
|
||||
| `HOST` | `127.0.0.1` | 바인드 주소. 외부 노출하려면 `0.0.0.0`. |
|
||||
| `SESSION_SECRET` | dev secret | `/op` 세션 쿠키 서명 키. 운영에서는 반드시 임의값으로 교체. |
|
||||
| `SITE_BASE_URL` | `http://127.0.0.1:3000` | 설치기 두 종이 첫 화면에서 자동으로 채우는 manifest 호스트. 운영 도메인으로 바꿔두면 manifest URL 도 자동으로 따라갑니다. |
|
||||
| `MANIFEST_URL` | — | 특별히 다른 경로를 쓰고 싶을 때만 지정. 비우면 `${SITE_BASE_URL}/manifest.json`. |
|
||||
| `MUSIC_CONCURRENCY` | (자동) | 리소스팩 설치기 yt-dlp 동시 다운로드 수(1~8). |
|
||||
|
||||
`.env` 는 `.gitignore` 로 제외되어 있습니다. 새 환경을 셋업할 때 `.env.example` 을 복사해서 시작하세요. 쉘에서 직접 환경변수를 지정한 경우에는 `.env` 값을 덮어쓰지 않습니다.
|
||||
|
||||
#### 설치기 `.exe` 빌드에도 적용됨
|
||||
|
||||
`npm run dist:win` 으로 만든 설치기 `.exe` 도 빌드 시점의 `.env` 를 함께 가져갑니다 (`electron-builder` 의 `extraResources` 로 `resources/.env` 에 배포). 런타임에서는 `process.resourcesPath/.env` → `<프로젝트 루트>/.env` 순으로 찾기 때문에:
|
||||
|
||||
- 운영자가 `SITE_BASE_URL=https://mq.example.com` 으로 `.env` 를 설정해 두고 빌드 → 최종 사용자가 받은 `.exe` 가 그 도메인의 `manifest.json` 을 자동으로 받아옴.
|
||||
- 빌드 이후라도 설치된 폴더(`resources/.env`) 를 텍스트 편집기로 고치면 도메인을 바꿀 수 있음.
|
||||
- 빌드 시점에 `.env` 가 없으면 단순히 빠진 채로 패키징되고, 코드 기본값(`http://127.0.0.1:3000`) 이 그대로 사용됩니다.
|
||||
|
||||
## 도메인 / 경로 구성
|
||||
|
||||
| 경로 | 내용 |
|
||||
| --- | --- |
|
||||
| `/` | 음악퀴즈 카드 한 줄 목록(공개). 카드 클릭 시 해당 음악퀴즈 페이지. |
|
||||
| `/manifest.json` | 사이트 루트 매니페스트(공개). 설치기가 첫 화면에서 로드. |
|
||||
| `/file/...` | 정적 파일 호스팅. `servers/`, `maps/`, `mods/<폴더>/`, `resourcepacks/`, `platforms/`, `list/`. |
|
||||
| `/op` | 관리자 로그인. 미들웨어로 `/op/*` 전체를 보호. |
|
||||
| `/op/dashboard` | 음악퀴즈 카드 목록 + 추가/삭제 + 편집 진입. |
|
||||
| `/op/dashboard/:packKey` | 음악퀴즈 정의(JSON) 편집기. |
|
||||
| `/op/list` | 카드 목록(음악·사진 편집 진입). |
|
||||
| `/op/list/:packKey` | 음악퀴즈의 음악·사진 목록 편집(드래그/우클릭). |
|
||||
| `/op/datapack` | 데이터팩 mcfunction 출력. |
|
||||
| `/op/datapack/:packKey/generate` | 텍스트 한 덩어리로 mcfunction 반환. |
|
||||
|
||||
## 계정
|
||||
|
||||
`account.json` 에 정의합니다(루트 디렉터리). **외부 HTTP 로 절대 노출되지 않도록 라우팅에서 제외돼 있습니다.**
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": "admin", "password": "비번" }
|
||||
]
|
||||
```
|
||||
|
||||
> 운영 환경에서는 평문 비밀번호 대신 해시를 쓰도록 추후 보강할 여지가 있습니다.
|
||||
|
||||
## 대시보드 (`/op/dashboard`)
|
||||
|
||||
- 상단 메뉴: 좌측 로고 = 대시보드로 이동, 우측 아이디 드롭다운에 **로그아웃**.
|
||||
- 상단 버튼: `[음악목록 수정]` → `/op/list`, `[데이터팩 수정]` → `/op/datapack`.
|
||||
- 카드 목록: `manifest.json` 의 음악퀴즈를 가로 한 줄 카드로 표시.
|
||||
- **음악퀴즈 추가** — `/manifest/new.json` 생성. 이미 있으면 `new2.json`, `new3.json` … 으로 증가. 동시에 `manifest.json` 갱신.
|
||||
- **음악퀴즈 삭제** — 카드에 체크박스가 뜨고, 확인 시 선택한 JSON 과 매니페스트 항목을 삭제.
|
||||
|
||||
## 음악퀴즈 정의 편집 (`/op/dashboard/:packKey`)
|
||||
|
||||
폼 필드:
|
||||
|
||||
| 필드 | 설명 |
|
||||
| --- | --- |
|
||||
| 음악퀴즈 이름 | 사용자에게 보이는 표시명. |
|
||||
| JSON 파일 이름 | URL 키. 영문/숫자/`_`/`-` 만 허용. 변경 시 파일명 + 매니페스트가 동시에 갱신되며 URL 도 새 키로 리다이렉트. |
|
||||
| 마인크래프트 버전 | Mojang 공식 API 에서 정식 릴리즈만 드롭다운으로 표시. |
|
||||
| 모드 플랫폼 | `vanilla` / `forge` / `fabric` / `neoforge`. |
|
||||
| 플랫폼 설치파일 URL | `forge` / `neoforge` 용. 도메인 없이 입력하면 `/file/platforms/<파일명>` 으로 해석. |
|
||||
| Fabric Loader 버전 | `fabric` 선택 시 자동 표시. Fabric Meta API 에서 선택한 mcVersion 의 로더 목록을 가져옴. 설치기는 최신 fabric-installer 로 CLI 자동 설치. |
|
||||
| 서버 최소/최대 램 (MB) | 설치기 RAM 검사 및 `run.bat` 의 `-Xms/-Xmx`. |
|
||||
| 클라이언트 최소/권장 램 (MB) | 사용자 PC 요구치 + 마인크래프트 런처 JVM 인수에 사용. |
|
||||
| 맵 파일 (.zip) | `/file/maps/` 아래 zip 파일명. `.mc_custom/saves/` 로 압축 해제. |
|
||||
| 서버 파일 (.zip) | `/file/servers/` 아래 zip. 멀티 전용. |
|
||||
| 모드 폴더 이름 | `/file/mods/<폴더>/` 아래 모든 `.jar` 자동 다운로드. 빈 값이면 받지 않음. |
|
||||
| 리소스팩 (.zip) | `/file/resourcepacks/` 아래 zip 파일명. |
|
||||
|
||||
저장 시 `clientMinRam ≤ clientRecommendedRam` 검증이 들어갑니다.
|
||||
|
||||
## 음악·사진 목록 편집 (`/op/list/:packKey`)
|
||||
|
||||
상단 탭: **음악목록** / **사진목록**.
|
||||
|
||||
### 음악목록 탭
|
||||
|
||||
- `[목록 저장]`, `[목록 초기화]`.
|
||||
- 플레이리스트 주소 + `[플레이리스트 불러오기]`. 확인 팝업에서 동의해야 기존 순서를 덮어씀.
|
||||
- 항목 표시: 좌측 번호 배지, 썸네일, 제목 / 가수 / 길이.
|
||||
- 드래그로 순서 변경. 우클릭: **수정** / **삭제**.
|
||||
- **수정** → 새 유튜브 주소를 입력하면 yt-dlp 가 메타데이터를 가져와 자동 채움.
|
||||
|
||||
### 사진목록 탭
|
||||
|
||||
- `[목록 저장]`, `[목록 초기화]`, 플레이리스트 불러오기는 동일.
|
||||
- 카드 그리드(반응형) 로 표시. 우클릭에서 수정/삭제. 수정 팝업은 [유튜브 주소] / [이미지 주소] 토글.
|
||||
|
||||
저장 포맷은 `/file/list/<packKey>.json` (README 데이터 포맷 참고).
|
||||
|
||||
> yt-dlp 메타데이터/플레이리스트 조회를 위해 서버에 yt-dlp 가 설치되어 있어야 합니다. ([`yt-dlp-setup.md`](yt-dlp-setup.md))
|
||||
|
||||
## 데이터팩 (`/op/datapack`)
|
||||
|
||||
- `[음악퀴즈 선택]` → 팝업에서 음악퀴즈 선택.
|
||||
- "총 N개의 음악을 찾았습니다." 와 `[데이터팩 출력]` 버튼.
|
||||
- 출력 클릭 시 코드 영역에 mcfunction(임시 포맷) + 복사 버튼. 실제 포맷은 추후 확정.
|
||||
|
||||
```
|
||||
# === musicquiz: <pack name> ===
|
||||
# 총 N곡 / 사진 M장
|
||||
say [musicquiz] 데이터팩 초기화
|
||||
# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.
|
||||
# 1. <title> - <artist> (<duration>s)
|
||||
```
|
||||
|
||||
## 파일 업로드 / 정리 운영
|
||||
|
||||
| 카테고리 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| 서버 zip | `file/servers/<이름>.zip` | EULA / `run.bat` 포함 권장. 설치기가 `run.bat` 에 UPnP/JVM 후처리 자동 주입. |
|
||||
| 맵 zip | `file/maps/<이름>.zip` | 압축 해제 시 `.mc_custom/saves/` 아래에 풀림. |
|
||||
| 모드 jar | `file/mods/<폴더>/...jar` | 해당 폴더의 `index.json` 이 자동 생성됩니다. 신규 jar 를 넣은 뒤에는 사이트가 인덱스를 다시 만들도록 한 번 호출. |
|
||||
| 리소스팩 zip | `file/resourcepacks/<이름>.zip` | — |
|
||||
| 플랫폼 설치 jar | `file/platforms/<이름>.jar` | forge/neoforge 용. fabric 은 사이트에 둘 필요 없음(자동 다운로드). |
|
||||
|
||||
## 보안 주의
|
||||
|
||||
- `account.json` 은 라우팅에서 차단되어 있으나, 디스크 권한도 운영자만 접근 가능하게 두는 것이 안전합니다.
|
||||
- 관리자 비밀번호는 충분히 강하게 설정.
|
||||
- 모든 `/op/*` 라우트는 세션 기반 인증 미들웨어를 거칩니다. 세션 만료 시 자동으로 로그인 페이지로 리다이렉트.
|
||||
105
docs/installer.md
Normal file
105
docs/installer.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 음악퀴즈 간편설치기 사용 가이드
|
||||
|
||||
`%APPDATA%\.mc_custom\` 을 게임 디렉터리로 쓰는 별도 인스턴스를 자동으로 구축합니다. 평소 쓰던 `.minecraft` 는 그대로 유지되며, 음악퀴즈 모드/리소스팩/맵/서버만 분리된 폴더에 자동 설치됩니다.
|
||||
|
||||
설치기는 단계별 화면으로 진행됩니다. 단계 사이에는 "이전 / 다음" 버튼이 있고, 일부 단계는 페이지 진입 시 자동으로 작업을 시작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1단계 — 음악퀴즈 선택
|
||||
|
||||
`manifest.json` 을 읽어 등록된 음악퀴즈를 카드 한 줄로 표시합니다. 하나를 선택하면 다음 단계로 진행됩니다.
|
||||
|
||||
> manifest URL 은 기본값이 박혀 있지만, 다른 호스트의 음악퀴즈를 받고 싶다면 첫 화면 상단의 URL 입력란에 붙여 넣고 새로고침 버튼을 누르면 됩니다.
|
||||
|
||||
## 2단계 — 싱글 / 멀티 선택
|
||||
|
||||
| 선택 | 흐름 |
|
||||
| --- | --- |
|
||||
| **싱글** | 3단계(서버 설치)를 건너뛰고 곧장 4단계로 진행. |
|
||||
| **멀티** | 3단계의 5개 소항목을 거친 뒤 4단계로 진행. |
|
||||
|
||||
## 3단계 — 서버 관련 설정 (멀티 전용)
|
||||
|
||||
### 3-1. 서버 설치 경로
|
||||
|
||||
- 폴더 선택 또는 직접 입력.
|
||||
- **경로에 한글이 있으면 안 됩니다.** (마인크래프트 서버가 비정상 동작)
|
||||
|
||||
### 3-2. JDK 확인
|
||||
|
||||
- 환경변수(`JAVA_HOME`, `JDK_HOME`) → 자동 설치 위치(`%APPDATA%\jdk\temurin-21`) → `C:\Program Files\Java` 순으로 자동 탐색.
|
||||
- **자동 설치** 버튼을 누르면 Adoptium Temurin 21 LTS Windows x64 zip 을 받아 `%APPDATA%\jdk\temurin-21\` 에 풀어 사용합니다.
|
||||
- 설치 중 같은 버튼이 "설치 취소" 로 바뀌고, 누르면 다운로드를 즉시 중단하고 부분 파일을 정리합니다.
|
||||
|
||||
### 3-3. 서버 다운로드 및 설치
|
||||
|
||||
페이지 진입 즉시 음악퀴즈의 `serverPath` 에 지정된 서버 zip 을 다운로드해 압축을 풉니다. 압축 해제 직후 `run.bat` 에 다음 처리를 자동 주입합니다:
|
||||
|
||||
- **UPnP 자동 등록/해제**: 서버 시작 직전 `server-port` 를 읽어 PowerShell COM (`HNetCfg.NATUPnP.1`) 으로 TCP 매핑 추가, 종료 후 해제.
|
||||
- 설치 끝나면 EULA 동의 화면이 표시되고, 동의해야 다음으로 넘어갑니다. 이어서 시스템 RAM 검사가 자동 실행됩니다.
|
||||
|
||||
램 검사 규칙:
|
||||
|
||||
```
|
||||
시스템 RAM ≥ serverMaxRam → 서버 RAM = serverMaxRam
|
||||
시스템 RAM ≥ serverMinRam → 서버 RAM = serverMinRam (경고 표시)
|
||||
시스템 RAM < serverMinRam → "플레이 불가" 후 설치 중단
|
||||
```
|
||||
|
||||
### 3-4. 서버 설정 (편집기)
|
||||
|
||||
내장 로컬 웹서버를 띄워 브라우저에서 `server.properties`, `bukkit.yml`, `paper-global.yml` 등 주요 설정 파일을 GUI 로 편집합니다. 저장 누르면 실제 파일에 반영됩니다.
|
||||
|
||||
### 3-5. 서버 포트포워딩 점검
|
||||
|
||||
페이지 진입 시 자동으로 검사합니다. 흐름:
|
||||
|
||||
1. 이전 실행에서 만든 UPnP 매핑이 남아 있으면 먼저 제거.
|
||||
2. 외부 포트체크 서비스(`ifconfig.co`) 로 1차 점검. 임시 TCP 리스너를 띄워 외부에서 닿는지 확인.
|
||||
3. 이미 사용자가 라우터 규칙으로 포워딩 해 두었으면 → "포워딩 됨" 으로 통과.
|
||||
4. 아니면 UPnP 로 자동 개방 시도 후 재점검. 성공 시 테스트용 매핑은 즉시 제거(실제 개방은 `run.bat` 이 서버 기동 때마다 처리).
|
||||
5. UPnP 도 실패하면 안내 메시지 (사용자가 라우터에서 수동 포워딩) 표시.
|
||||
|
||||
> 동일 페이지에 **재점검** 버튼이 있어, 라우터 설정을 바꾼 뒤 다시 누르면 1차부터 다시 검사합니다.
|
||||
|
||||
## 4단계 — 유저 클라이언트 설정 (자동 진행)
|
||||
|
||||
페이지 진입 즉시 시작합니다.
|
||||
|
||||
1. `.mc_custom` 폴더 생성 + `mods/`, `resourcepacks/` 생성.
|
||||
2. `.minecraft` 최상위 설정 파일(`options.txt`, `optionsof.txt`, `servers.dat`, `usercache.json`, …) 을 `.mc_custom` 으로 복사. 이미 같은 이름이 있으면 보존.
|
||||
3. 플랫폼 설치:
|
||||
- `vanilla` → 건너뜀.
|
||||
- `fabric` → Adoptium 자동 설치 → 최신 fabric-installer.jar 다운로드 → `java -jar fabric-installer.jar client -mcversion X -loader Y -dir .mc_custom -noprofile` 자동 실행.
|
||||
- `forge` / `neoforge` → `platform.downloadUrl` 의 설치 jar 다운로드(사용자가 직접 실행하거나 마인크래프트 런처가 인식).
|
||||
4. `modsFolder` 의 모든 `.jar` 와 `resourcepackPath` zip, `mapPath` zip 을 자동 다운로드.
|
||||
5. `.minecraft\{assets,libraries,versions}` 를 `.mc_custom\{assets,libraries,versions}` 로 junction 링크. (없으면 "Unable to prepare assets for download" 오류로 마인크래프트가 실패하기 때문)
|
||||
6. `.minecraft\launcher_profiles.json` 에 해당 음악퀴즈 이름의 프로필을 추가/갱신:
|
||||
- `gameDir` = `%APPDATA%\.mc_custom`
|
||||
- `lastVersionId` = `vanilla` → `mcVersion`, `fabric` → `fabric-loader-<loaderVer>-<mcVer>` (forge/neoforge 는 `versions/` 폴더에서 휴리스틱 매칭)
|
||||
- `javaArgs` = `-Xmx<serverMaxRam>M` + Aikar 권장 G1 GC 플래그 6종 (`-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M`). 기존에 사용자가 지정한 키는 덮어쓰지 않음.
|
||||
|
||||
## 5단계 — 완료
|
||||
|
||||
- 멀티 설치까지 거친 경우:
|
||||
- **서버 폴더 열기**
|
||||
- **바탕화면에 서버 실행 바로가기 만들기**
|
||||
- **서버 바로 실행**
|
||||
- **마인크래프트 런처 실행** — 실행 우선순위:
|
||||
1. Win32 설치판 (`Program Files\Minecraft Launcher\MinecraftLauncher.exe` 등)
|
||||
2. App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\Minecraft.exe` 등, `cmd /c start` 경유)
|
||||
3. `explorer.exe shell:AppsFolder\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft` (MS Store MSIX)
|
||||
4. 마지막 수단: `minecraft://` URL 스킴
|
||||
|
||||
런처가 떴다면 음악퀴즈 이름의 프로필을 선택해 플레이하면 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 로그
|
||||
|
||||
설치기 화면 하단에 실시간 로그가 표시됩니다. 모든 다운로드/링크/JVM 인수 갱신/UPnP 시도 내역이 기록됩니다. 문제가 생기면 이 로그 내용을 캡처해 함께 전달해 주세요.
|
||||
|
||||
## 음악퀴즈 제거
|
||||
|
||||
`%APPDATA%\.mc_custom\` 폴더를 통째로 삭제하면 인스턴스가 사라집니다. `.minecraft\launcher_profiles.json` 에 남은 프로필은 마인크래프트 런처에서 직접 지우거나, 다른 음악퀴즈를 재설치하면 같은 이름이 갱신됩니다.
|
||||
57
docs/painting-variant.md
Normal file
57
docs/painting-variant.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 리소스팩 이미지 추가 (painting variant)
|
||||
|
||||
마인크래프트 1.21+ 의 painting variant 시스템.
|
||||
**데이터팩에 슬롯을 미리 등록 → 리소스팩 PNG만 갈아끼우면 그림 교체.**
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
```
|
||||
my-datapack/
|
||||
pack.mcmeta
|
||||
data/musicquiz/painting_variant/cover_01.json
|
||||
data/minecraft/tags/painting_variant/placeable.json
|
||||
|
||||
my-resourcepack/
|
||||
pack.mcmeta
|
||||
assets/musicquiz/textures/painting/cover_01.png
|
||||
```
|
||||
|
||||
## 슬롯 정의 (데이터팩, 고정)
|
||||
|
||||
`data/musicquiz/painting_variant/cover_01.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_id": "musicquiz:cover_01",
|
||||
"title": "표지 01",
|
||||
"author": "음악퀴즈",
|
||||
"width": 3,
|
||||
"height": 2
|
||||
}
|
||||
```
|
||||
|
||||
`data/minecraft/tags/painting_variant/placeable.json` (인벤토리·랜덤 배치에 포함)
|
||||
|
||||
```json
|
||||
{ "replace": false, "values": ["musicquiz:cover_01"] }
|
||||
```
|
||||
|
||||
## 이미지 (리소스팩, 교체 대상)
|
||||
|
||||
`assets/musicquiz/textures/painting/cover_01.png`
|
||||
|
||||
- 크기: `width*16 × height*16` (예: 3×2 → `48×32` px, 또는 그 정수배)
|
||||
- 슬롯 이름과 동일해야 함 (`cover_01.png`)
|
||||
|
||||
## 게임에서 호출
|
||||
|
||||
```
|
||||
/give @s minecraft:painting[minecraft:painting/variant="musicquiz:cover_01"]
|
||||
/summon minecraft:painting ~ ~ ~ {variant:"musicquiz:cover_01",facing:0b}
|
||||
```
|
||||
|
||||
## 운영 팁
|
||||
|
||||
- 슬롯은 크기별로 미리 다 만들어 둔다 (`cover_3x2_01..N`, `cover_2x2_01..N` …). 크기는 데이터팩에 박혀 있으므로 나중에 못 바꿈.
|
||||
- 그림 갈이는 리소스팩 PNG만 교체 → 클라이언트가 리소스팩을 새로 적용하면 즉시 반영 (월드 재진입 불필요).
|
||||
- 데이터팩 수정(슬롯 추가/삭제/크기 변경)은 `/reload` 불가 → 월드 재진입 필요.
|
||||
69
docs/resourcepack-installer.md
Normal file
69
docs/resourcepack-installer.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 리소스팩 간편설치기
|
||||
|
||||
음악퀴즈에 등록된 음악·사진 목록을 yt-dlp 로 받아 마인크래프트 1.21+ painting variant 텍스처 리소스팩으로 패키징하는 별도 설치기입니다.
|
||||
|
||||
기본 골격은 음악퀴즈 간편설치기(`src/installer/`)와 동일하므로 UI/스토어 코드를 공유합니다. 진입점은 `src/installer-rp/main.ts` + `installer-rp/` 렌더러.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
npm run installer:rp # 개발 실행
|
||||
```
|
||||
|
||||
윈도우 `.exe` 패키징은 `tsconfig.installer-rp.json` 으로 컴파일한 뒤 `electron-builder` 로 수동 패키징합니다.
|
||||
|
||||
## 단계
|
||||
|
||||
번호가 붙은 단계는 "이전 / 다음" 으로 이동 가능합니다.
|
||||
|
||||
### 1) 음악퀴즈 선택
|
||||
|
||||
- 도메인의 `manifest.json` 에서 음악퀴즈 카드 목록을 표시.
|
||||
- 선택한 음악퀴즈의 `file/list/<key>.json` 에서 다음을 로드:
|
||||
- 음악 목록(유튜브 영상 주소).
|
||||
- 사진 목록(유튜브 주소 또는 일반 이미지 주소).
|
||||
|
||||
### 2) 리소스팩 설치 (자동 시작)
|
||||
|
||||
- "다음" 누르면 즉시 시작.
|
||||
- 설치 로그를 실시간으로 표시(곡 제목 노출 없이 `n번 노래 다운로드 중…` 형식).
|
||||
- 취소 버튼 / 창 닫기 / 강제 종료 시: 진행 중 다운로드를 안전하게 중단하고 임시 파일을 정리한 뒤 정상 종료.
|
||||
|
||||
#### 2-1. yt-dlp 준비
|
||||
|
||||
- 자동 설치 위치: `%APPDATA%/.mc_custom/` (Linux `~/.config/.mc_custom/`, macOS `~/Library/Application Support/.mc_custom/`).
|
||||
- 이미 있으면 업데이트 확인 후 그대로 사용.
|
||||
- 시스템 `PATH` 에 `yt-dlp` 가 있으면 그것을 우선 사용 후 업데이트 확인.
|
||||
- 자동 설치가 실패하는 환경은 [`yt-dlp-setup.md`](yt-dlp-setup.md) 참고.
|
||||
|
||||
#### 2-2. 음악 다운로드 (순차)
|
||||
|
||||
- 임시 경로: `%APPDATA%/.mc_custom/.temp/`.
|
||||
- 각 곡을 `ogg` 로 변환(Minecraft 사운드 호환 포맷). ffmpeg 필요.
|
||||
- 중단되거나 모두 끝나면 `.temp` 내용 삭제.
|
||||
|
||||
#### 2-3. 사진 다운로드 → painting variant 텍스처
|
||||
|
||||
이미지는 두 형태로 들어옵니다.
|
||||
|
||||
- **유튜브 주소** — yt-dlp 가 알려준 영상 ID 로 `https://i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 시도, 실패하면 `hqdefault.jpg` 폴백.
|
||||
- **일반 이미지 주소** — HTTP GET 으로 그대로.
|
||||
|
||||
정규화 규칙:
|
||||
|
||||
- **슬롯 규격(고정, 데이터팩 측)**: `4 × 4` 블록 정사각, `cover_01 … cover_N`.
|
||||
- **최종 PNG 규격(리소스팩 측)**: 정사각 1:1, 최대 `1024 × 1024` px.
|
||||
- `4 × 4` 블록 × 블록당 `256` px (×16 배율) → 1024×1024 가 픽셀 그리드와 정확히 일치.
|
||||
- **알고리즘**:
|
||||
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 으로 배치.
|
||||
|
||||
painting variant 의 슬롯·태그 구조와 게임 내 호출 예시는 [`painting-variant.md`](painting-variant.md) 참고.
|
||||
|
||||
### 3) 설치 완료
|
||||
|
||||
- "확인" 누르면 프로그램 종료.
|
||||
95
docs/yt-dlp-setup.md
Normal file
95
docs/yt-dlp-setup.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# yt-dlp 설치 가이드
|
||||
|
||||
> ✅ **기본 동작: 자동 설치.** 서버가 처음 플레이리스트를 불러올 때 `%appdata%/.mc_custom/`
|
||||
> (Linux 는 `~/.config/.mc_custom/`, macOS 는 `~/Library/Application Support/.mc_custom/`)
|
||||
> 에 현재 OS/아키텍처에 맞는 `yt-dlp` 바이너리를 GitHub Releases 에서 받아 권한까지 부여합니다.
|
||||
> 이미 받아둔 게 있으면 그대로 재사용합니다. 따라서 **일반적으로는 아래 수동 설치가 필요 없습니다.**
|
||||
>
|
||||
> 자동 설치가 실패하는 환경(외부 인터넷 차단, 권한 부족 등)에서만 아래 절차로 수동 설치하세요.
|
||||
|
||||
---
|
||||
|
||||
## 1. 가장 간단한 방법 — 단일 바이너리 내려받기 (권장)
|
||||
|
||||
Python/pip 없이도 동작하며, 권한도 깔끔합니다. 서버에 SSH로 접속한 뒤:
|
||||
|
||||
```bash
|
||||
sudo curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
|
||||
sudo chmod a+rx /usr/local/bin/yt-dlp
|
||||
yt-dlp --version
|
||||
```
|
||||
|
||||
마지막 줄에서 버전(예: `2025.12.13`)이 출력되면 끝입니다.
|
||||
|
||||
업데이트는 같은 한 줄을 다시 실행하거나 `yt-dlp -U` 로 수행합니다.
|
||||
|
||||
> 만약 `/usr/local/bin` 에 쓰기 권한이 없는 환경이면 `~/.local/bin/yt-dlp` 로 받고
|
||||
> `~/.local/bin` 이 `$PATH` 에 포함돼 있는지 확인하세요.
|
||||
|
||||
---
|
||||
|
||||
## 2. pipx 사용 (이미 pipx 가 깔려 있다면)
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pipx
|
||||
pipx ensurepath
|
||||
pipx install yt-dlp
|
||||
```
|
||||
|
||||
업데이트: `pipx upgrade yt-dlp`
|
||||
|
||||
> Ubuntu 24.04 이상은 시스템 파이썬에 `pip install` 이 막혀 있어 (`PEP 668`)
|
||||
> `pipx` 가 사실상 표준입니다. `pip install yt-dlp` 는 권장하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. apt 패키지 (구버전일 가능성 있음)
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y yt-dlp
|
||||
```
|
||||
|
||||
`apt` 의 yt-dlp 는 유튜브 정책 변경을 따라가지 못해 자주 깨집니다. **1번 방법을 추천합니다.**
|
||||
|
||||
---
|
||||
|
||||
## 부가: ffmpeg
|
||||
|
||||
설치기 EXE 에서 음악을 ogg 로 변환할 때 `ffmpeg` 도 필요합니다. 음악퀴즈 관리 사이트 자체는 필요 없지만,
|
||||
설치기를 서버에서 디버깅한다면 함께 깔아두면 편합니다.
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y ffmpeg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 동작 확인
|
||||
|
||||
설치 후 관리 사이트 서비스를 재시작할 필요는 **없습니다**. 매 요청마다 `spawn('yt-dlp', ['--version'])` 으로 직접 호출하므로,
|
||||
`PATH` 상에 `yt-dlp` 가 있기만 하면 바로 인식됩니다.
|
||||
|
||||
확인:
|
||||
|
||||
```bash
|
||||
sudo -u <서버를_실행하는_사용자> yt-dlp --version
|
||||
```
|
||||
|
||||
예) systemd 로 `minecraft-launcher.service` 가 실행 중이고 사용자가 `claude` 라면:
|
||||
|
||||
```bash
|
||||
sudo -u claude yt-dlp --version
|
||||
```
|
||||
|
||||
여기서 버전이 출력되면 관리 사이트의 "플레이리스트 불러오기" 도 정상 동작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
- **`yt-dlp: command not found`** — `$PATH` 에 설치 디렉터리가 없습니다. `which yt-dlp` 로 위치 확인.
|
||||
- **`ERROR: ... HTTP Error 403`** — yt-dlp 가 너무 오래된 버전입니다. `yt-dlp -U` 로 업데이트.
|
||||
- **`Sign in to confirm you're not a bot`** — 일시적인 IP 제한. 몇 분 후 재시도하거나, 같은 서버에서 다른 영상 재생을 시도해본 적이 있다면 IP 가 풀릴 때까지 기다려야 합니다.
|
||||
- **systemd 서비스에서만 안 됨** — `PATH` 환경변수가 다를 수 있음. 서비스 유닛에 `Environment=PATH=/usr/local/bin:/usr/bin:/bin` 추가.
|
||||
41
electron-builder-rp.yml
Normal file
41
electron-builder-rp.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
appId: kr.tkrmagid.musicquiz.installer-rp
|
||||
productName: MusicQuizResourcepackInstaller
|
||||
# 루트 package.json 의 "main" 은 메인 설치기를 가리키므로, 패키지된 앱이
|
||||
# 리소스팩 설치기를 진입점으로 쓰도록 빌드 시 main 을 덮어쓴다.
|
||||
extraMetadata:
|
||||
main: dist/installer-rp/main.js
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer-rp/**
|
||||
- dist/shared/**
|
||||
- installer-rp/**
|
||||
# rp 의 index.html 은 메인 설치기와 동일한 styles.css 를 공유함
|
||||
# (`<link href="../installer/styles.css">`). asar 안에 해당 파일이 없으면
|
||||
# UI 가 무스타일로 렌더링되므로 그 한 파일만 명시적으로 포함.
|
||||
- installer/styles.css
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
|
||||
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
|
||||
- "!node_modules/@img/sharp-linux-*"
|
||||
- "!node_modules/@img/sharp-linuxmusl-*"
|
||||
- "!node_modules/@img/sharp-libvips-linux-*"
|
||||
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
|
||||
# 메인 설치기와 동일하게 빌드 전용 `.env.build` 와 locales 를 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env.build
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: portable
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
icon: build/icon.ico
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
@@ -2,14 +2,37 @@ appId: kr.tkrmagid.musicquiz.installer
|
||||
productName: MusicQuizInstaller
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
files:
|
||||
- dist/installer/**
|
||||
- dist/shared/**
|
||||
- installer/**
|
||||
- build/icon.*
|
||||
- package.json
|
||||
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
|
||||
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
|
||||
- "!node_modules/@img/sharp-linux-*"
|
||||
- "!node_modules/@img/sharp-linuxmusl-*"
|
||||
- "!node_modules/@img/sharp-libvips-linux-*"
|
||||
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
|
||||
# 빌드 전용 `.env.build` 를 설치기 옆에 함께 배포(없으면 조용히 패스).
|
||||
# `.env` 는 서버/개발 실행용이라 빌드 산출물에는 포함되지 않으며, 패키지된 exe
|
||||
# 는 `resources/.env.build` 를 우선 로드함(없으면 `resources/.env` 로 폴백).
|
||||
# 패키징 후 운영자가 `resources/.env.build` 만 교체해서 도메인을 바꿀 수 있음.
|
||||
# locales/ 폴더는 i18n.ts 가 process.resourcesPath/locales/<component>/ko-kr.json
|
||||
# 을 찾아 로드하므로, 빌드된 .exe 에서도 한국어 사전이 적용되도록 함께 배포.
|
||||
extraResources:
|
||||
- from: .
|
||||
to: .
|
||||
filter:
|
||||
- .env.build
|
||||
- from: locales
|
||||
to: locales
|
||||
filter:
|
||||
- "**/*"
|
||||
win:
|
||||
target: nsis
|
||||
artifactName: ${productName}-${version}-Setup.${ext}
|
||||
nsis:
|
||||
oneClick: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
perMachine: false
|
||||
target: portable
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
icon: build/icon.ico
|
||||
portable:
|
||||
artifactName: ${productName}-${version}-Portable.${ext}
|
||||
|
||||
6
file/list/music-quiz.json
Normal file
6
file/list/music-quiz.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"musicPlaylistUrl": "",
|
||||
"imagePlaylistUrl": "",
|
||||
"music": [],
|
||||
"images": []
|
||||
}
|
||||
BIN
file/maps/music-quiz-map.zip
Normal file
BIN
file/maps/music-quiz-map.zip
Normal file
Binary file not shown.
BIN
file/mods/music-quiz/music-quiz-test-mod.jar
Normal file
BIN
file/mods/music-quiz/music-quiz-test-mod.jar
Normal file
Binary file not shown.
BIN
file/platforms/fabric-installer.jar
Normal file
BIN
file/platforms/fabric-installer.jar
Normal file
Binary file not shown.
BIN
file/resourcepacks/music-quiz.zip
Normal file
BIN
file/resourcepacks/music-quiz.zip
Normal file
Binary file not shown.
BIN
file/servers/music-quiz-server.zip
Normal file
BIN
file/servers/music-quiz-server.zip
Normal file
Binary file not shown.
27
installer-rp/index.html
Normal file
27
installer-rp/index.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>마인크래프트 음악퀴즈 리소스팩 간편설치기</title>
|
||||
<link rel="stylesheet" href="../installer/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="appHeader">
|
||||
<h1>마인크래프트 음악퀴즈 리소스팩 간편설치기</h1>
|
||||
<ol class="stepIndicator" id="stepIndicator">
|
||||
<li data-step="1">1. 음악퀴즈</li>
|
||||
<li data-step="2">2. 설치</li>
|
||||
<li data-step="3">3. 완료</li>
|
||||
</ol>
|
||||
</header>
|
||||
|
||||
<main id="pageHost"></main>
|
||||
|
||||
<aside class="logViewer" id="logViewer" hidden>
|
||||
<header><h2>설치 로그</h2><button type="button" id="logToggle">접기</button></header>
|
||||
<pre id="logBody"></pre>
|
||||
</aside>
|
||||
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
316
installer-rp/renderer.js
Normal file
316
installer-rp/renderer.js
Normal file
@@ -0,0 +1,316 @@
|
||||
'use strict'
|
||||
|
||||
const api = window.rpInstaller
|
||||
|
||||
const state = {
|
||||
packs: [],
|
||||
selectedKey: null,
|
||||
installing: false,
|
||||
installed: false,
|
||||
resourcepackPath: ''
|
||||
}
|
||||
|
||||
let I18N = {}
|
||||
|
||||
function tt(key, params) {
|
||||
var parts = String(key).split('.')
|
||||
var cur = I18N
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur && typeof cur === 'object' && parts[i] in cur) {
|
||||
cur = cur[parts[i]]
|
||||
} else {
|
||||
return key
|
||||
}
|
||||
}
|
||||
if (typeof cur !== 'string') return key
|
||||
if (!params) return cur
|
||||
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
||||
return name in params ? String(params[name]) : '{{' + name + '}}'
|
||||
})
|
||||
}
|
||||
|
||||
const pageHost = document.getElementById('pageHost')
|
||||
const stepIndicator = document.getElementById('stepIndicator')
|
||||
const logViewer = document.getElementById('logViewer')
|
||||
const logBody = document.getElementById('logBody')
|
||||
const logToggle = document.getElementById('logToggle')
|
||||
|
||||
logToggle.addEventListener('click', function () {
|
||||
logViewer.classList.toggle('collapsed')
|
||||
if (logViewer.classList.contains('collapsed')) {
|
||||
logViewer.style.height = '36px'
|
||||
logToggle.textContent = tt('logViewer.expand')
|
||||
} else {
|
||||
logViewer.style.height = ''
|
||||
logToggle.textContent = tt('logViewer.collapse')
|
||||
}
|
||||
})
|
||||
|
||||
api.onLog(function (line) {
|
||||
logViewer.hidden = false
|
||||
logBody.textContent += line + '\n'
|
||||
logBody.scrollTop = logBody.scrollHeight
|
||||
})
|
||||
|
||||
function applyStaticI18n() {
|
||||
document.title = tt('app.title')
|
||||
var h1 = document.querySelector('.appHeader h1')
|
||||
if (h1) h1.textContent = tt('app.title')
|
||||
var stepLis = stepIndicator.querySelectorAll('li')
|
||||
stepLis.forEach(function (item) {
|
||||
var idx = item.getAttribute('data-step')
|
||||
if (idx === '1') item.textContent = tt('stepIndicator.step1')
|
||||
else if (idx === '2') item.textContent = tt('stepIndicator.step2')
|
||||
else if (idx === '3') item.textContent = tt('stepIndicator.step3')
|
||||
})
|
||||
var logH2 = logViewer.querySelector('header h2')
|
||||
if (logH2) logH2.textContent = tt('logViewer.heading')
|
||||
logToggle.textContent = tt('logViewer.collapse')
|
||||
}
|
||||
|
||||
function setActiveStep(step) {
|
||||
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||
var index = Number(item.getAttribute('data-step'))
|
||||
item.classList.remove('active', 'done')
|
||||
if (index < step) item.classList.add('done')
|
||||
if (index === step) item.classList.add('active')
|
||||
})
|
||||
}
|
||||
|
||||
function clearPage() { pageHost.innerHTML = '' }
|
||||
|
||||
// ── 1단계: 음악퀴즈 선택 ────────────────────────────
|
||||
function renderStep1() {
|
||||
setActiveStep(1)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + escapeHtml(tt('step1.heading')) + '</h2>' +
|
||||
'<div id="packList" class="cardChoice"><p class="formMessage">' + escapeHtml(tt('common.loading')) + '</p></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var listEl = section.querySelector('#packList')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
|
||||
function renderList() {
|
||||
listEl.innerHTML = ''
|
||||
if (state.packs.length === 0) {
|
||||
listEl.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('common.noPacks')) + '</p>'
|
||||
return
|
||||
}
|
||||
state.packs.forEach(function (pack) {
|
||||
var card = document.createElement('button')
|
||||
card.type = 'button'
|
||||
card.className = 'choiceCard'
|
||||
if (state.selectedKey === pack.key) card.classList.add('selected')
|
||||
var verLabel = pack.mcVersion
|
||||
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
|
||||
: ''
|
||||
card.innerHTML =
|
||||
'<strong>' + escapeHtml(pack.name) + '</strong>' +
|
||||
'<small>' + verLabel +
|
||||
escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) +
|
||||
'</small>'
|
||||
card.addEventListener('click', function () {
|
||||
state.selectedKey = pack.key
|
||||
nextBtn.disabled = false
|
||||
renderList()
|
||||
})
|
||||
listEl.appendChild(card)
|
||||
})
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.selectedKey) return
|
||||
api.selectPack(state.selectedKey).then(function () {
|
||||
renderStep2()
|
||||
}).catch(function (err) {
|
||||
alert(err.message || tt('common.selectFailed'))
|
||||
})
|
||||
})
|
||||
|
||||
api.loadPacks().then(function (packs) {
|
||||
state.packs = packs || []
|
||||
renderList()
|
||||
}).catch(function (err) {
|
||||
listEl.innerHTML = '<p class="formMessage error">' +
|
||||
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
|
||||
'</p>'
|
||||
})
|
||||
}
|
||||
|
||||
// ── 2단계: 설치 진행 ────────────────────────────────
|
||||
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>' + escapeHtml(tt('step2.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + tt('step2.description') + '</p>' +
|
||||
'<div class="prepRow">' +
|
||||
' <span class="prepChip" id="chip-ytdlp">' + escapeHtml(tt('step2.chipYtdlp')) + '</span>' +
|
||||
' <span class="prepChip" id="chip-ffmpeg">' + escapeHtml(tt('step2.chipFfmpeg')) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>' + escapeHtml(tt('step2.musicHeading')) + '</h3>' +
|
||||
' <div class="sectionSub" id="music-sub">' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '</div>' +
|
||||
' <div class="progressGrid" id="musicGrid"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>' + escapeHtml(tt('step2.imageHeading')) + '</h3>' +
|
||||
' <div class="sectionSub" id="image-sub">' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '</div>' +
|
||||
' <div class="progressGrid" id="imageGrid"></div>' +
|
||||
'</div>' +
|
||||
'<div class="progressSection">' +
|
||||
' <h3>' + escapeHtml(tt('step2.packageHeading')) + '</h3>' +
|
||||
' <div class="sectionSub" id="pkg-sub">' + escapeHtml(tt('step2.packageWaiting')) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow">' +
|
||||
' <span></span>' +
|
||||
' <button class="dangerBtn" id="cancel">' + escapeHtml(tt('common.cancel')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
|
||||
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')
|
||||
|
||||
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">' + escapeHtml(tt('step2.cardWaiting')) + '</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 = tt('step2.cardDone')
|
||||
if (icon) icon.textContent = '✓'
|
||||
if (bar) bar.style.width = '100%'
|
||||
} else if (status === 'error') {
|
||||
if (pct) pct.textContent = tt('step2.cardError')
|
||||
if (icon) icon.textContent = '✕'
|
||||
} else if (status === 'running') {
|
||||
if (pct) pct.textContent = Math.round(percent) + '%'
|
||||
if (icon) icon.textContent = '⏳'
|
||||
} else {
|
||||
if (pct) pct.textContent = tt('step2.cardWaiting')
|
||||
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
|
||||
? tt('step2.packageDone')
|
||||
: (payload.message || tt('step2.packageBuilding'))
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
if (stopProgress) stopProgress()
|
||||
alert(tt('common.installFailed', { message: (err && err.message) || err }))
|
||||
renderStep1()
|
||||
})
|
||||
}
|
||||
|
||||
// ── 3단계: 완료 ────────────────────────────────────
|
||||
function renderStep3() {
|
||||
setActiveStep(3)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
|
||||
(state.resourcepackPath
|
||||
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
|
||||
: '') +
|
||||
'<div class="actionRow">' +
|
||||
' <button class="secondaryBtn" id="openFolder">' + escapeHtml(tt('common.openFolder')) + '</button>' +
|
||||
' <button class="primaryBtn" id="finish">' + escapeHtml(tt('common.confirm')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||
api.openResourcepackFolder()
|
||||
})
|
||||
section.querySelector('#finish').addEventListener('click', function () {
|
||||
api.quit()
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
|
||||
})
|
||||
}
|
||||
|
||||
;(async function () {
|
||||
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
|
||||
applyStaticI18n()
|
||||
renderStep1()
|
||||
})()
|
||||
@@ -2,10 +2,33 @@
|
||||
|
||||
const installerApi = window.installer
|
||||
|
||||
// I18N 사전: locales/installer/ko-kr.json. 처음 한 번 메인 프로세스에서 받아오고
|
||||
// 그 뒤로는 동기적으로 lookup. tt() 가 호출될 때 사전이 비어 있어도 키를 그대로 반환해
|
||||
// 화면이 깨지지는 않는다.
|
||||
var I18N = {}
|
||||
function tt(key, params) {
|
||||
var parts = key.split('.')
|
||||
var cur = I18N
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
|
||||
else { cur = null; break }
|
||||
}
|
||||
var tpl = (typeof cur === 'string') ? cur : key
|
||||
if (!params) return tpl
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
||||
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
|
||||
})
|
||||
}
|
||||
|
||||
const state = {
|
||||
packs: [],
|
||||
selectedPackKey: null,
|
||||
mode: null, // 'single' | 'multi'
|
||||
// mode==='multi' 일 때만 의미가 있다.
|
||||
// 'host' → 서버를 직접 연다. 기존 멀티 흐름 (step3 + step4) 그대로.
|
||||
// 'participant' → 친구 서버에 접속만 한다. step3 (서버 설치) 를 건너뛰고
|
||||
// client 측에서도 맵은 받지 않는다 (참가자라 서버에 이미 있음).
|
||||
role: null, // 'host' | 'participant' | null
|
||||
serverInstall: {
|
||||
path: '',
|
||||
jdk: '',
|
||||
@@ -30,14 +53,27 @@ const logViewer = document.getElementById('logViewer')
|
||||
const logBody = document.getElementById('logBody')
|
||||
const logToggle = document.getElementById('logToggle')
|
||||
|
||||
function applyStaticI18n() {
|
||||
document.title = tt('app.browserTitle')
|
||||
var headerH1 = document.querySelector('.appHeader h1')
|
||||
if (headerH1) headerH1.textContent = tt('app.headerTitle')
|
||||
stepIndicator.querySelectorAll('li').forEach(function (item) {
|
||||
var step = Number(item.getAttribute('data-step'))
|
||||
item.textContent = tt('stepIndicator.step' + step)
|
||||
})
|
||||
var logHeader = logViewer.querySelector('h2')
|
||||
if (logHeader) logHeader.textContent = tt('logViewer.title')
|
||||
logToggle.textContent = tt('common.collapse')
|
||||
}
|
||||
|
||||
logToggle.addEventListener('click', function () {
|
||||
logViewer.classList.toggle('collapsed')
|
||||
if (logViewer.classList.contains('collapsed')) {
|
||||
logViewer.style.height = '36px'
|
||||
logToggle.textContent = '펼치기'
|
||||
logToggle.textContent = tt('common.expand')
|
||||
} else {
|
||||
logViewer.style.height = ''
|
||||
logToggle.textContent = '접기'
|
||||
logToggle.textContent = tt('common.collapse')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -66,9 +102,9 @@ function renderStep1() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>1단계. 설치할 음악퀴즈 선택</h2>' +
|
||||
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<h2>' + tt('step1.heading') + '</h2>' +
|
||||
'<div id="packList" class="cardChoice"><p class="formMessage">' + tt('step1.loading') + '</p></div>' +
|
||||
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var listEl = section.querySelector('#packList')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
@@ -76,13 +112,14 @@ function renderStep1() {
|
||||
function renderList() {
|
||||
listEl.innerHTML = ''
|
||||
if (state.packs.length === 0) {
|
||||
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
|
||||
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.empty') + '</p>'
|
||||
return
|
||||
}
|
||||
state.packs.forEach(function (pack) {
|
||||
var btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '</small>'
|
||||
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>' +
|
||||
tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + '</small>'
|
||||
if (state.selectedPackKey === pack.key) btn.classList.add('selected')
|
||||
btn.addEventListener('click', function () {
|
||||
state.selectedPackKey = pack.key
|
||||
@@ -106,7 +143,7 @@ function renderStep1() {
|
||||
state.packs = packs
|
||||
renderList()
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<p class="formMessage error">목록을 가져오지 못했습니다: ' + err.message + '</p>'
|
||||
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.fetchFailed', { message: err.message }) + '</p>'
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -117,54 +154,105 @@ function renderStep2() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>2단계. 싱글 / 멀티 선택</h2>' +
|
||||
'<h2>' + tt('step2.heading') + '</h2>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button id="single" type="button" data-mode="single"><strong>싱글</strong><br><small>혼자 즐기는 모드. 4단계만 진행합니다.</small></button>' +
|
||||
'<button id="multi" type="button" data-mode="multi"><strong>멀티</strong><br><small>친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다.</small></button>' +
|
||||
'<button id="single" type="button" data-mode="single"><strong>' + tt('step2.singleTitle') + '</strong><br><small>' + tt('step2.singleHint') + '</small></button>' +
|
||||
'<button id="multi" type="button" data-mode="multi"><strong>' + tt('step2.multiTitle') + '</strong><br><small>' + tt('step2.multiHint') + '</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var modeButtons = section.querySelectorAll('[data-mode]')
|
||||
|
||||
function applySelection(mode) {
|
||||
function applyMode(mode) {
|
||||
state.mode = mode
|
||||
modeButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-mode') === mode) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
// 모드가 바뀌면 이전에 골랐던 역할은 의미가 없어진다. 멀티→싱글 전환 시 잔존하던
|
||||
// role 이 다음 단계 분기에 영향 주지 않도록 명시적으로 초기화.
|
||||
if (mode !== 'multi') state.role = null
|
||||
}
|
||||
|
||||
modeButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applySelection(btn.getAttribute('data-mode'))
|
||||
applyMode(btn.getAttribute('data-mode'))
|
||||
})
|
||||
})
|
||||
|
||||
if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode)
|
||||
if (state.mode === 'single' || state.mode === 'multi') {
|
||||
applyMode(state.mode)
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.mode) return
|
||||
state.stepDone[2] = true
|
||||
if (state.mode === 'single') renderStep4()
|
||||
else renderStep3()
|
||||
// 멀티는 호스트/참가자 선택 탭을 거친다. 싱글은 곧장 클라이언트(step4) 로.
|
||||
if (state.mode === 'multi') renderStep2Role()
|
||||
else renderStep4()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
}
|
||||
|
||||
function renderStep2Role() {
|
||||
// 스텝 인디케이터는 여전히 2 단계 안쪽이다 — 호스트/참가자 선택은 모드 선택의
|
||||
// 하위 결정이기 때문. 별도 탭으로 분리해서 한 화면에 한 결정만 보이도록 한다.
|
||||
setActiveStep(2)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + tt('step2.roleHeading') + '</h2>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button type="button" data-role="host"><strong>' + tt('step2.hostTitle') + '</strong><br><small>' + tt('step2.hostHint') + '</small></button>' +
|
||||
'<button type="button" data-role="participant"><strong>' + tt('step2.participantTitle') + '</strong><br><small>' + tt('step2.participantHint') + '</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var roleButtons = section.querySelectorAll('[data-role]')
|
||||
|
||||
function applyRole(role) {
|
||||
state.role = role
|
||||
roleButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-role') === role) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
}
|
||||
|
||||
roleButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applyRole(btn.getAttribute('data-role'))
|
||||
})
|
||||
})
|
||||
|
||||
if (state.role === 'host' || state.role === 'participant') applyRole(state.role)
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.role) return
|
||||
// 호스트는 서버 설치(step3) 부터, 참가자는 클라이언트(step4) 로 바로.
|
||||
if (state.role === 'host') renderStep3()
|
||||
else renderStep4()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep2)
|
||||
}
|
||||
|
||||
function renderStep3() {
|
||||
setActiveStep(3)
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>3단계. 서버 관련 설정</h2>' +
|
||||
'<h2>' + tt('step3.heading') + '</h2>' +
|
||||
'<div class="subStep" id="subHost"></div>'
|
||||
pageHost.appendChild(section)
|
||||
var subHost = section.querySelector('#subHost')
|
||||
|
||||
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) }
|
||||
// step3 는 멀티+호스트 만 진입하므로 sub31 의 back 은 역할 선택 탭으로.
|
||||
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2Role, show32) }
|
||||
function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) }
|
||||
function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) }
|
||||
function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) }
|
||||
@@ -180,12 +268,12 @@ function renderStep3() {
|
||||
|
||||
function renderSubStep31(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-1. 서버 설치 경로</h3>' +
|
||||
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
|
||||
'<h3>' + tt('step3.sub31.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub31.description') + '</p>' +
|
||||
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
|
||||
'<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' +
|
||||
'<button class="secondaryBtn" id="pickFolder">' + tt('step3.sub31.pickFolder') + '</button></div>' +
|
||||
'<div class="formMessage" id="msg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
var input = host.querySelector('#installPath')
|
||||
var msg = host.querySelector('#msg')
|
||||
host.querySelector('#pickFolder').addEventListener('click', async function () {
|
||||
@@ -196,11 +284,11 @@ function renderSubStep31(host, back, done) {
|
||||
host.querySelector('#next').addEventListener('click', async function () {
|
||||
var result = await installerApi.validateInstallPath(input.value.trim())
|
||||
if (!result.ok) {
|
||||
msg.textContent = result.message || '경로가 유효하지 않습니다.'
|
||||
msg.textContent = result.message || tt('step3.sub31.invalidPath')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
msg.textContent = '경로 확정: ' + result.message
|
||||
msg.textContent = tt('step3.sub31.confirmed', { message: result.message })
|
||||
msg.classList.remove('error')
|
||||
msg.classList.add('success')
|
||||
state.serverInstall.path = input.value.trim()
|
||||
@@ -210,35 +298,102 @@ function renderSubStep31(host, back, done) {
|
||||
|
||||
function renderSubStep32(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-2. JDK 확인</h3>' +
|
||||
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.</p>' +
|
||||
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
|
||||
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
|
||||
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
|
||||
'<button class="secondaryBtn" id="auto">자동 탐색</button></div>' +
|
||||
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
|
||||
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
|
||||
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
|
||||
'<div class="formMessage" id="msg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
var input = host.querySelector('#jdkPath')
|
||||
var msg = host.querySelector('#msg')
|
||||
host.querySelector('#auto').addEventListener('click', async function () {
|
||||
var installBtn = host.querySelector('#install')
|
||||
var autoBtn = host.querySelector('#auto')
|
||||
var pickBtn = host.querySelector('#pickJdk')
|
||||
var nextBtn = host.querySelector('#next')
|
||||
var installing = false
|
||||
|
||||
function setInstallingUi(on) {
|
||||
installing = on
|
||||
if (on) {
|
||||
installBtn.textContent = tt('step3.sub32.installCancel')
|
||||
installBtn.classList.remove('secondaryBtn')
|
||||
installBtn.classList.add('dangerBtn')
|
||||
autoBtn.disabled = true
|
||||
pickBtn.disabled = true
|
||||
nextBtn.disabled = true
|
||||
input.disabled = true
|
||||
} else {
|
||||
installBtn.textContent = tt('step3.sub32.install')
|
||||
installBtn.classList.remove('dangerBtn')
|
||||
installBtn.classList.add('secondaryBtn')
|
||||
autoBtn.disabled = false
|
||||
pickBtn.disabled = false
|
||||
nextBtn.disabled = false
|
||||
input.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
autoBtn.addEventListener('click', async function () {
|
||||
if (installing) return
|
||||
var detect = await installerApi.detectJdk()
|
||||
if (detect.found) {
|
||||
input.value = detect.path
|
||||
msg.textContent = 'JDK 발견: ' + detect.path
|
||||
msg.textContent = tt('step3.sub32.found', { path: detect.path })
|
||||
msg.classList.remove('error')
|
||||
msg.classList.add('success')
|
||||
} else {
|
||||
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. 직접 선택해 주세요.'
|
||||
msg.textContent = tt('step3.sub32.notFound')
|
||||
msg.classList.remove('success')
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})
|
||||
host.querySelector('#pickJdk').addEventListener('click', async function () {
|
||||
pickBtn.addEventListener('click', async function () {
|
||||
if (installing) return
|
||||
var picked = await installerApi.pickFolder()
|
||||
if (picked) input.value = picked
|
||||
})
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', function () {
|
||||
installBtn.addEventListener('click', async function () {
|
||||
if (installing) {
|
||||
// 진행 중이면 취소.
|
||||
msg.textContent = tt('step3.sub32.cancelRequested')
|
||||
msg.classList.remove('success', 'error')
|
||||
await installerApi.cancelJdkInstall()
|
||||
return
|
||||
}
|
||||
setInstallingUi(true)
|
||||
msg.classList.remove('success', 'error')
|
||||
msg.textContent = tt('step3.sub32.downloading')
|
||||
try {
|
||||
var result = await installerApi.installJdk()
|
||||
if (result.ok && result.path) {
|
||||
input.value = result.path
|
||||
state.serverInstall.jdk = result.path
|
||||
msg.textContent = tt('step3.sub32.installComplete', { path: result.path })
|
||||
msg.classList.add('success')
|
||||
} else {
|
||||
var raw = result.message || tt('common.unknownError')
|
||||
msg.textContent = raw === '취소됨'
|
||||
? tt('step3.sub32.installCanceled')
|
||||
: tt('step3.sub32.installFailed', { message: raw })
|
||||
msg.classList.add('error')
|
||||
}
|
||||
} catch (err) {
|
||||
msg.textContent = tt('step3.sub32.installError', { message: (err && err.message) ? err.message : String(err) })
|
||||
msg.classList.add('error')
|
||||
} finally {
|
||||
setInstallingUi(false)
|
||||
}
|
||||
})
|
||||
host.querySelector('#back').addEventListener('click', function () {
|
||||
if (installing) return
|
||||
back()
|
||||
})
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (installing) return
|
||||
if (!input.value.trim()) {
|
||||
msg.textContent = 'JDK 경로를 입력해 주세요.'
|
||||
msg.textContent = tt('step3.sub32.pathRequired')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
@@ -249,70 +404,73 @@ function renderSubStep32(host, back, done) {
|
||||
var detect = await installerApi.detectJdk()
|
||||
if (detect.found && !input.value) {
|
||||
input.value = detect.path
|
||||
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
|
||||
msg.textContent = tt('step3.sub32.autoDetected', { path: detect.path })
|
||||
msg.classList.add('success')
|
||||
} else if (!detect.found) {
|
||||
msg.textContent = tt('step3.sub32.notFoundHint')
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
function renderSubStep33(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
|
||||
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
|
||||
'<button class="primaryBtn" id="startDownload">다운로드 시작</button>' +
|
||||
'<h3>' + tt('step3.sub33.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub33.description') + '</p>' +
|
||||
'<div class="formMessage" id="downloadStatus">' + tt('step3.sub33.waiting') + '</div>' +
|
||||
'<div id="ramSection" hidden style="margin-top:14px;">' +
|
||||
'<h4>램 검사</h4>' +
|
||||
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
|
||||
'<h4>' + tt('step3.sub33.ramHeading') + '</h4>' +
|
||||
'<div class="formMessage" id="ramMsg">' + tt('step3.sub33.ramChecking') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
|
||||
var startBtn = host.querySelector('#startDownload')
|
||||
var statusEl = host.querySelector('#downloadStatus')
|
||||
var ramSection = host.querySelector('#ramSection')
|
||||
var ramMsg = host.querySelector('#ramMsg')
|
||||
var nextBtn = host.querySelector('#next')
|
||||
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.serverInstall.eulaAccepted) return
|
||||
done()
|
||||
})
|
||||
|
||||
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
|
||||
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
|
||||
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
|
||||
statusEl.textContent = tt('step3.sub33.doneSummary')
|
||||
statusEl.classList.add('success')
|
||||
showRamResult(state.serverInstall.ram)
|
||||
nextBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', async function () {
|
||||
startBtn.disabled = true
|
||||
// 페이지 진입 즉시 자동 다운로드
|
||||
;(async function () {
|
||||
state.serverInstall.eulaAccepted = false
|
||||
nextBtn.disabled = true
|
||||
statusEl.classList.remove('success', 'error')
|
||||
statusEl.textContent = '다운로드 중...'
|
||||
statusEl.textContent = tt('step3.sub33.downloading')
|
||||
try {
|
||||
await installerApi.startServerInstall({
|
||||
packKey: state.selectedPackKey,
|
||||
installPath: state.serverInstall.path,
|
||||
jdkPath: state.serverInstall.jdk
|
||||
})
|
||||
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
|
||||
statusEl.textContent = tt('step3.sub33.eulaPrompt')
|
||||
var accepted = await openEulaPopup(state.serverInstall.path)
|
||||
if (!accepted) {
|
||||
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소합니다. "다운로드 시작"으로 다시 시도하세요.'
|
||||
statusEl.textContent = tt('step3.sub33.eulaRejected')
|
||||
statusEl.classList.add('error')
|
||||
startBtn.disabled = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
await installerApi.acceptEula(state.serverInstall.path)
|
||||
} catch (err) {
|
||||
statusEl.textContent = 'EULA 저장 실패: ' + err.message
|
||||
statusEl.textContent = tt('step3.sub33.eulaSaveFailed', { message: err.message })
|
||||
statusEl.classList.add('error')
|
||||
startBtn.disabled = false
|
||||
return
|
||||
}
|
||||
state.serverInstall.eulaAccepted = true
|
||||
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
|
||||
statusEl.textContent = tt('step3.sub33.doneSummary')
|
||||
statusEl.classList.add('success')
|
||||
var ram = await installerApi.checkRam(state.selectedPackKey)
|
||||
state.serverInstall.ram = ram
|
||||
@@ -320,16 +478,10 @@ function renderSubStep33(host, back, done) {
|
||||
if (ram.decision === 'tooLow') return
|
||||
nextBtn.disabled = false
|
||||
} catch (err) {
|
||||
statusEl.textContent = '다운로드 실패: ' + err.message
|
||||
statusEl.textContent = tt('step3.sub33.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
|
||||
statusEl.classList.add('error')
|
||||
startBtn.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!state.serverInstall.eulaAccepted) return
|
||||
done()
|
||||
})
|
||||
})()
|
||||
|
||||
function showRamResult(result) {
|
||||
ramSection.hidden = false
|
||||
@@ -337,44 +489,40 @@ function renderSubStep33(host, back, done) {
|
||||
if (result.decision === 'tooLow') {
|
||||
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
|
||||
var minRam = pack ? pack.pack.serverMinRam : 0
|
||||
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + minRam + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
|
||||
ramMsg.innerHTML = tt('step3.sub33.ramTooLow', { system: result.systemRamMb, min: minRam })
|
||||
ramMsg.classList.add('error')
|
||||
} else if (result.decision === 'minOk') {
|
||||
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
|
||||
ramMsg.innerHTML = tt('step3.sub33.ramMinOk', { system: result.systemRamMb, applied: result.appliedRamMb })
|
||||
ramMsg.classList.add('warn')
|
||||
} else {
|
||||
ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
|
||||
ramMsg.textContent = tt('step3.sub33.ramMaxOk', { system: result.systemRamMb, applied: result.appliedRamMb })
|
||||
ramMsg.classList.add('success')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
|
||||
async function openEulaPopup(installPath) {
|
||||
var read = await installerApi.readEula(installPath)
|
||||
// eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 EULA 페이지를 받아서
|
||||
// 표시한다 — 사용자가 실제 서버 약관을 보고 동의하도록.
|
||||
async function openEulaPopup(_installPath) {
|
||||
var bodyHtml = ''
|
||||
if (read.exists) {
|
||||
bodyHtml = '<p class="formMessage">서버 파일에 포함된 eula.txt 내용입니다.</p>' +
|
||||
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
|
||||
} else {
|
||||
var fetched = await installerApi.fetchMinecraftEula()
|
||||
if (fetched.html) {
|
||||
bodyHtml = '<p class="formMessage">서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href="' + fetched.url + '" target="_blank">' + fetched.url + '</a>).</p>' +
|
||||
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
|
||||
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
|
||||
} else {
|
||||
bodyHtml = '<p class="formMessage error">EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href="https://www.minecraft.net/en-us/eula" target="_blank">https://www.minecraft.net/en-us/eula</a></p>'
|
||||
}
|
||||
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
|
||||
}
|
||||
return new Promise(function (resolve) {
|
||||
var overlay = document.createElement('div')
|
||||
overlay.className = 'modalOverlay'
|
||||
overlay.innerHTML =
|
||||
'<div class="modalCard" role="dialog" aria-modal="true">' +
|
||||
'<header><h3>Minecraft EULA 동의</h3><button type="button" class="modalClose" aria-label="닫기">×</button></header>' +
|
||||
'<header><h3>' + tt('step3.eulaModal.title') + '</h3><button type="button" class="modalClose" aria-label="' + tt('common.close') + '">×</button></header>' +
|
||||
'<div class="modalBody">' + bodyHtml + '</div>' +
|
||||
'<footer class="actionRow">' +
|
||||
'<button type="button" class="secondaryBtn" data-action="reject">비동의</button>' +
|
||||
'<button type="button" class="primaryBtn" data-action="accept">동의</button>' +
|
||||
'<button type="button" class="secondaryBtn" data-action="reject">' + tt('common.reject') + '</button>' +
|
||||
'<button type="button" class="primaryBtn" data-action="accept">' + tt('common.agree') + '</button>' +
|
||||
'</footer>' +
|
||||
'</div>'
|
||||
document.body.appendChild(overlay)
|
||||
@@ -395,30 +543,24 @@ async function openEulaPopup(installPath) {
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text).replace(/[&<>"']/g, function (ch) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
|
||||
})
|
||||
}
|
||||
|
||||
function escapeAttr(text) {
|
||||
return String(text).replace(/&/g, '&').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function renderSubStep34(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-4. 서버 설정 편집</h3>' +
|
||||
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
|
||||
'<button class="secondaryBtn" id="open">편집기 열기</button>' +
|
||||
'<h3>' + tt('step3.sub34.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub34.description') + '</p>' +
|
||||
'<button class="secondaryBtn" id="open">' + tt('step3.sub34.open') + '</button>' +
|
||||
'<div class="formMessage" id="editorMsg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
|
||||
host.querySelector('#open').addEventListener('click', async function () {
|
||||
var msg = host.querySelector('#editorMsg')
|
||||
try {
|
||||
var result = await installerApi.startServerConfigEditor(state.serverInstall.path)
|
||||
msg.innerHTML = '편집기 주소: <a href="' + result.url + '" target="_blank">' + result.url + '</a>'
|
||||
msg.innerHTML = tt('step3.sub34.openedAt', { url: result.url })
|
||||
} catch (err) {
|
||||
msg.textContent = '편집기 실행 실패: ' + err.message
|
||||
msg.textContent = tt('step3.sub34.openFailed', { message: err.message })
|
||||
msg.classList.add('error')
|
||||
}
|
||||
})
|
||||
@@ -428,35 +570,58 @@ function renderSubStep34(host, back, done) {
|
||||
|
||||
function renderSubStep35(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>3-5. 포트포워딩 점검</h3>' +
|
||||
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
|
||||
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
|
||||
'<button class="secondaryBtn" id="run">검사 시작</button>' +
|
||||
'<h3>' + tt('step3.sub35.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step3.sub35.description') + '</p>' +
|
||||
'<div class="fieldset"><label>' + tt('step3.sub35.portLabel') + ' <input id="port" type="text" value="25565" /></label></div>' +
|
||||
'<button class="secondaryBtn" id="run">' + tt('step3.sub35.recheck') + '</button>' +
|
||||
'<div class="formMessage" id="resultMsg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
var resultMsg = host.querySelector('#resultMsg')
|
||||
var nextBtn = host.querySelector('#next')
|
||||
if (state.serverInstall.portStatus) nextBtn.disabled = false
|
||||
var runBtn = host.querySelector('#run')
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#run').addEventListener('click', async function () {
|
||||
|
||||
// 25565 는 마인크래프트 자바판 기본 포트라 클라이언트에서 생략 가능 →
|
||||
// 사용자에게도 ip 만 보여주는 게 깔끔하다.
|
||||
function formatServerAddress(ip, port) {
|
||||
var safeIp = ip || tt('step3.sub35.ipUnknown')
|
||||
if (Number(port) === 25565) return safeIp
|
||||
return safeIp + ':' + port
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
runBtn.disabled = true
|
||||
resultMsg.classList.remove('success', 'warn', 'error')
|
||||
resultMsg.textContent = tt('step3.sub35.checking')
|
||||
var port = Number(host.querySelector('#port').value) || 25565
|
||||
resultMsg.textContent = '확인 중...'
|
||||
try {
|
||||
var result = await installerApi.checkPortForward(port)
|
||||
state.serverInstall.portStatus = result
|
||||
var address = formatServerAddress(result.externalIp, result.port)
|
||||
if (result.status === 'preForwarded') {
|
||||
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else if (result.status === 'upnpOk') {
|
||||
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
|
||||
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
|
||||
resultMsg.classList.add('success')
|
||||
} else {
|
||||
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
|
||||
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
|
||||
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
|
||||
tt('step3.sub35.manualDetail', { address: address })
|
||||
resultMsg.classList.add('warn')
|
||||
}
|
||||
nextBtn.disabled = false
|
||||
})
|
||||
} catch (err) {
|
||||
resultMsg.textContent = tt('step3.sub35.checkFailed', { message: (err && err.message) ? err.message : String(err) })
|
||||
resultMsg.classList.add('error')
|
||||
} finally {
|
||||
runBtn.disabled = false
|
||||
}
|
||||
}
|
||||
|
||||
runBtn.addEventListener('click', runCheck)
|
||||
nextBtn.addEventListener('click', done)
|
||||
// 페이지 진입 즉시 자동 점검
|
||||
runCheck()
|
||||
}
|
||||
|
||||
function renderStep4() {
|
||||
@@ -466,114 +631,78 @@ function renderStep4() {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>4단계. 유저 클라이언트 설정</h2>' +
|
||||
'<h2>' + tt('step4.heading') + '</h2>' +
|
||||
'<div class="subStep" id="subHost"></div>'
|
||||
pageHost.appendChild(section)
|
||||
var subHost = section.querySelector('#subHost')
|
||||
function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
|
||||
|
||||
function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
|
||||
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) }
|
||||
function show43() {
|
||||
subHost.innerHTML = ''
|
||||
renderSubStep43(subHost, show42, function () {
|
||||
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
|
||||
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
state.client.installPlatform = platformType !== 'vanilla'
|
||||
|
||||
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
|
||||
// 멀티+참가자 는 직전 화면이 역할 선택 탭이므로 거기로, 싱글은 모드 탭으로.
|
||||
function backToPrevStep() {
|
||||
if (state.mode === 'multi' && state.role === 'host') renderStep3()
|
||||
else if (state.mode === 'multi') renderStep2Role()
|
||||
else renderStep2()
|
||||
}
|
||||
|
||||
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
|
||||
function goStep5() {
|
||||
state.stepDone[4] = true
|
||||
renderStep5()
|
||||
})
|
||||
}
|
||||
show41()
|
||||
}
|
||||
|
||||
function renderSubStep41(host, pack, back, done) {
|
||||
var platformType = pack ? pack.pack.platform.type : 'vanilla'
|
||||
if (platformType === 'vanilla') {
|
||||
state.client.installPlatform = false
|
||||
host.innerHTML =
|
||||
'<h3>4-1. 모드 플랫폼</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong></p>' +
|
||||
'<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
return
|
||||
}
|
||||
|
||||
host.innerHTML =
|
||||
'<h3>4-1. 모드 플랫폼</h3>' +
|
||||
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>' + platformType + '</strong></p>' +
|
||||
'<div class="cardChoice">' +
|
||||
'<button type="button" data-choice="install"><strong>권장 플랫폼 설치</strong><br><small>' + platformType + ' 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.</small></button>' +
|
||||
'<button type="button" data-choice="skip"><strong>기본 마인크래프트로 설치</strong><br><small>플랫폼은 설치하지 않고 바닐라로 진행합니다.</small></button>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
|
||||
var nextBtn = host.querySelector('#next')
|
||||
var choiceButtons = host.querySelectorAll('[data-choice]')
|
||||
|
||||
function applyChoice(choice) {
|
||||
state.client.installPlatform = choice === 'install'
|
||||
choiceButtons.forEach(function (btn) {
|
||||
if (btn.getAttribute('data-choice') === choice) btn.classList.add('selected')
|
||||
else btn.classList.remove('selected')
|
||||
})
|
||||
nextBtn.disabled = false
|
||||
}
|
||||
|
||||
choiceButtons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
applyChoice(btn.getAttribute('data-choice'))
|
||||
})
|
||||
})
|
||||
|
||||
if (typeof state.client.installPlatform === 'boolean') {
|
||||
applyChoice(state.client.installPlatform ? 'install' : 'skip')
|
||||
}
|
||||
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
nextBtn.addEventListener('click', done)
|
||||
show42()
|
||||
}
|
||||
|
||||
function renderSubStep42(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
|
||||
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
|
||||
'<button class="primaryBtn" id="run">설치 시작</button>' +
|
||||
'<div class="formMessage" id="msg"></div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
|
||||
var runBtn = host.querySelector('#run')
|
||||
'<h3>' + tt('step4.sub42.heading') + '</h3>' +
|
||||
'<p class="formMessage">' + tt('step4.sub42.description') + '</p>' +
|
||||
'<div class="formMessage" id="msg">' + tt('step4.sub42.installing') + '</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
|
||||
var msg = host.querySelector('#msg')
|
||||
var nextBtn = host.querySelector('#next')
|
||||
if (state.client.clientInstalled) nextBtn.disabled = false
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
runBtn.addEventListener('click', async function () {
|
||||
runBtn.disabled = true
|
||||
msg.textContent = '설치 중...'
|
||||
msg.classList.remove('error', 'success')
|
||||
try {
|
||||
await installerApi.installClient({
|
||||
nextBtn.addEventListener('click', done)
|
||||
|
||||
// 이번에 실제로 보내야 할 payload. 이전 진입에서 같은 payload 로 이미 끝났으면
|
||||
// 다시 돌리지 않지만, packKey / installPlatform / skipMap 중 하나라도 다르면
|
||||
// (예: 참가자 → 싱글 로 뒤로가서 변경) 재설치한다.
|
||||
var payload = {
|
||||
packKey: state.selectedPackKey,
|
||||
installPlatform: !!state.client.installPlatform
|
||||
})
|
||||
msg.textContent = '클라이언트 설치 완료.'
|
||||
installPlatform: !!state.client.installPlatform,
|
||||
// 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다.
|
||||
skipMap: state.mode === 'multi' && state.role === 'participant'
|
||||
}
|
||||
var last = state.client.lastInstall
|
||||
if (last
|
||||
&& last.packKey === payload.packKey
|
||||
&& last.installPlatform === payload.installPlatform
|
||||
&& last.skipMap === payload.skipMap) {
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
state.client.clientInstalled = true
|
||||
nextBtn.disabled = false
|
||||
return
|
||||
}
|
||||
|
||||
// 페이지 진입 즉시 자동 설치
|
||||
;(async function () {
|
||||
try {
|
||||
await installerApi.installClient(payload)
|
||||
msg.textContent = tt('step4.sub42.done')
|
||||
msg.classList.add('success')
|
||||
state.client.lastInstall = payload
|
||||
nextBtn.disabled = false
|
||||
} catch (err) {
|
||||
msg.textContent = '설치 실패: ' + err.message
|
||||
// 실패한 호출은 "마지막 성공" 기록에 남기지 않는다. 다음 진입 시 재시도.
|
||||
state.client.lastInstall = null
|
||||
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
|
||||
msg.classList.add('error')
|
||||
runBtn.disabled = false
|
||||
}
|
||||
})
|
||||
nextBtn.addEventListener('click', done)
|
||||
}
|
||||
|
||||
function renderSubStep43(host, back, done) {
|
||||
host.innerHTML =
|
||||
'<h3>4-3. 완료 확인</h3>' +
|
||||
'<p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">5단계로</button></div>'
|
||||
host.querySelector('#back').addEventListener('click', back)
|
||||
host.querySelector('#next').addEventListener('click', done)
|
||||
})()
|
||||
}
|
||||
|
||||
function renderStep5() {
|
||||
@@ -581,37 +710,53 @@ function renderStep5() {
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
var multi = state.mode === 'multi'
|
||||
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
|
||||
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
|
||||
var showServerActions = state.mode === 'multi' && state.role === 'host'
|
||||
section.innerHTML =
|
||||
'<h2>5단계. 설치 완료</h2>' +
|
||||
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</p>' +
|
||||
(multi ? '<div class="subStep">' +
|
||||
'<h3>서버</h3>' +
|
||||
'<button class="secondaryBtn" id="openFolder">서버 폴더 열기</button>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> 바탕화면에 서버 실행 바로가기 만들기</label>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> 서버 바로 실행</label>' +
|
||||
'<h2>' + tt('step5.heading') + '</h2>' +
|
||||
'<p>' + tt('step5.summary') + '</p>' +
|
||||
(showServerActions ? '<div class="subStep">' +
|
||||
'<h3>' + tt('step5.serverHeading') + '</h3>' +
|
||||
'<button class="secondaryBtn" id="openFolder">' + tt('step5.openServerFolder') + '</button>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> ' + tt('step5.shortcut') + '</label>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> ' + tt('step5.startServer') + '</label>' +
|
||||
'</div>' : '') +
|
||||
'<div class="subStep">' +
|
||||
'<h3>마인크래프트 런처</h3>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> 마인크래프트 런처 실행</label>' +
|
||||
'<h3>' + tt('step5.launcherHeading') + '</h3>' +
|
||||
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> ' + tt('step5.startLauncher') + '</label>' +
|
||||
'</div>' +
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="finish">완료</button></div>'
|
||||
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="finish">' + tt('step5.finish') + '</button></div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep4)
|
||||
if (multi) {
|
||||
if (showServerActions) {
|
||||
section.querySelector('#openFolder').addEventListener('click', function () {
|
||||
installerApi.openServerFolder()
|
||||
})
|
||||
}
|
||||
section.querySelector('#finish').addEventListener('click', async function () {
|
||||
if (multi) {
|
||||
var finishBtn = section.querySelector('#finish')
|
||||
finishBtn.disabled = true
|
||||
finishBtn.textContent = tt('step5.finishing')
|
||||
try {
|
||||
if (showServerActions) {
|
||||
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
|
||||
if (section.querySelector('#startServer').checked) await installerApi.startServer()
|
||||
}
|
||||
if (section.querySelector('#startLauncher').checked) await installerApi.startMinecraftLauncher()
|
||||
section.querySelector('#finish').disabled = true
|
||||
section.querySelector('#finish').textContent = '완료됨'
|
||||
} catch (err) {
|
||||
// 마무리 액션 실패는 무시하고 종료 진행
|
||||
}
|
||||
finishBtn.textContent = tt('step5.finished')
|
||||
if (installerApi.quitApp) installerApi.quitApp()
|
||||
})
|
||||
}
|
||||
|
||||
renderStep1()
|
||||
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
||||
;(async function () {
|
||||
try {
|
||||
I18N = (await installerApi.loadLocale()) || {}
|
||||
} catch (_) { I18N = {} }
|
||||
applyStaticI18n()
|
||||
renderStep1()
|
||||
})()
|
||||
|
||||
@@ -24,7 +24,8 @@ html, body {
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
/* header / main(스크롤) / logViewer(hidden 이면 0). */
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -67,8 +68,9 @@ body {
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 28px 32px 100px;
|
||||
padding: 28px 32px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.page { max-width: 720px; margin: 0 auto; }
|
||||
@@ -135,16 +137,14 @@ main {
|
||||
.subStep h3 { margin: 0 0 8px; font-size: 16px; }
|
||||
|
||||
.logViewer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* fixed 였으면 본문 하단 버튼이 가려져서 grid 행으로 자연 배치하도록 변경. */
|
||||
height: 200px;
|
||||
background: #0a0d11;
|
||||
border-top: 1px solid var(--border);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
.logViewer.collapsed { height: 36px; }
|
||||
|
||||
.logViewer header { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--bg-alt); }
|
||||
.logViewer header h2 { margin: 0; font-size: 13px; }
|
||||
@@ -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); }
|
||||
|
||||
129
locales/installer-rp/ko-kr.json
Normal file
129
locales/installer-rp/ko-kr.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "마인크래프트 음악퀴즈 리소스팩 간편설치기"
|
||||
},
|
||||
"stepIndicator": {
|
||||
"step1": "1. 음악퀴즈",
|
||||
"step2": "2. 설치",
|
||||
"step3": "3. 완료"
|
||||
},
|
||||
"logViewer": {
|
||||
"heading": "설치 로그",
|
||||
"collapse": "접기",
|
||||
"expand": "펼치기"
|
||||
},
|
||||
"common": {
|
||||
"next": "다음",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"openFolder": "리소스팩 폴더 열기",
|
||||
"loading": "목록을 불러오는 중...",
|
||||
"selectFailed": "선택 실패",
|
||||
"listLoadFailed": "목록 로드 실패: {{message}}",
|
||||
"installFailed": "설치 실패: {{message}}",
|
||||
"noPacks": "등록된 음악퀴즈가 없습니다.",
|
||||
"mcVersionLabel": "마인크래프트 {{version}} · ",
|
||||
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
|
||||
"requestTimeout": "요청 시간 초과",
|
||||
"tooManyRedirects": "너무 많은 요청."
|
||||
},
|
||||
"step1": {
|
||||
"heading": "음악퀴즈 선택"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "리소스팩 설치",
|
||||
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
|
||||
"chipYtdlp": "yt-dlp 준비",
|
||||
"chipFfmpeg": "ffmpeg 준비",
|
||||
"musicHeading": "음악 다운로드",
|
||||
"musicSub": "{{count}}곡",
|
||||
"imageHeading": "사진 다운로드",
|
||||
"imageSub": "{{count}}장",
|
||||
"packageHeading": "리소스팩 빌드",
|
||||
"packageWaiting": "대기 중…",
|
||||
"packageBuilding": "빌드 중…",
|
||||
"packageDone": "설치 완료",
|
||||
"cardWaiting": "대기",
|
||||
"cardDone": "완료",
|
||||
"cardError": "실패"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "완료",
|
||||
"message": "리소스팩 설치를 완료했습니다."
|
||||
},
|
||||
"log": {
|
||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
|
||||
"listLoadFailed": "목록 로드 실패 ({{file}}): {{message}}",
|
||||
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||
"packEntry": " - {{key}}: mc={{mc}} 베이스={{base}}",
|
||||
"packEntryUnknownVersion": "?",
|
||||
"packEntryNoBase": "(없음)",
|
||||
"selectedPack": "선택: {{key}}",
|
||||
"ytdlpPreparing": "yt-dlp 준비 중…",
|
||||
"ytdlpPath": "yt-dlp 경로: {{path}}",
|
||||
"ffmpegPreparing": "ffmpeg 준비 중…",
|
||||
"ffmpegPath": "ffmpeg 경로: {{path}}",
|
||||
"cpuDetected": "CPU 코어 {{cores}}개 감지 → 동시 다운로드 {{concurrency}}개",
|
||||
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
|
||||
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
|
||||
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
|
||||
"imageStart": "사진 다운로드 시작 ({{total}}장)",
|
||||
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
|
||||
"imageDone": "{{idx}}번 사진 완료: {{name}}",
|
||||
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
|
||||
"baseUrl": " URL: {{url}}",
|
||||
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
|
||||
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
|
||||
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
|
||||
"installComplete": "설치 완료: {{path}}",
|
||||
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",
|
||||
"ytdlpExists": "yt-dlp.exe 이미 있음: {{path}}",
|
||||
"ytdlpDownloading": "yt-dlp.exe 다운로드 중: {{url}}",
|
||||
"ytdlpReady": "yt-dlp.exe 준비 완료: {{path}}",
|
||||
"ffmpegExists": "ffmpeg.exe 이미 있음: {{path}}",
|
||||
"ffmpegDownloading": "ffmpeg.exe 다운로드 중: {{url}}",
|
||||
"ffmpegExtracting": "ffmpeg zip 압축 해제 중…",
|
||||
"ffmpegReady": "ffmpeg.exe 준비 완료: {{path}}",
|
||||
"baseExtract": "베이스 리소스팩 압축 해제: {{name}}",
|
||||
"baseShaderOverrideStripped": "베이스 리소스팩의 vanilla 셰이더 오버라이드 제거: assets/minecraft/shaders/{{path}} — mcVersion {{mc}} (pack_format {{format}}) 의 새 GLSL API 와 호환되지 않아 결과 zip 에서 제외했습니다.",
|
||||
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
|
||||
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
|
||||
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
|
||||
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
|
||||
"ytdlpLine": "yt-dlp> {{line}}"
|
||||
},
|
||||
"progress": {
|
||||
"ytdlpPreparing": "yt-dlp 준비 중",
|
||||
"ffmpegPreparing": "ffmpeg 준비 중",
|
||||
"ready": "준비 완료",
|
||||
"cancelled": "취소됨",
|
||||
"baseDownloading": "베이스 리소스팩 다운로드 중",
|
||||
"buildingWithBase": "베이스에 음악·사진 추가 중",
|
||||
"buildingZip": "zip 빌드 중",
|
||||
"installComplete": "설치 완료"
|
||||
},
|
||||
"pack": {
|
||||
"description": "음악퀴즈 리소스팩 - {{name}}"
|
||||
},
|
||||
"errors": {
|
||||
"selectedPackNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
|
||||
"selectPackFirst": "음악퀴즈를 먼저 선택해주세요.",
|
||||
"currentPackNotFound": "선택된 음악퀴즈를 찾을 수 없습니다.",
|
||||
"cancelledByUser": "사용자가 설치를 취소했습니다.",
|
||||
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
|
||||
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
|
||||
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
|
||||
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
|
||||
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",
|
||||
"ytdlpExit": "yt-dlp 종료 코드 {{code}}: {{stderr}}",
|
||||
"ytdlpNoStderr": "(stderr 없음)",
|
||||
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
|
||||
"imageMetaUnknown": "이미지 크기를 읽지 못함",
|
||||
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
|
||||
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
|
||||
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
|
||||
}
|
||||
}
|
||||
295
locales/installer/ko-kr.json
Normal file
295
locales/installer/ko-kr.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"common": {
|
||||
"back": "이전",
|
||||
"next": "다음",
|
||||
"ok": "확인",
|
||||
"cancel": "취소",
|
||||
"close": "닫기",
|
||||
"agree": "동의",
|
||||
"reject": "비동의",
|
||||
"apply": "적용",
|
||||
"save": "저장",
|
||||
"load": "불러오기",
|
||||
"expand": "펼치기",
|
||||
"collapse": "접기",
|
||||
"saved": "저장 완료",
|
||||
"saveFailed": "저장 실패",
|
||||
"unknownError": "알 수 없는 오류"
|
||||
},
|
||||
"app": {
|
||||
"browserTitle": "마인크래프트 음악퀴즈 간편설치기",
|
||||
"headerTitle": "마인크래프트 음악퀴즈 간편설치기"
|
||||
},
|
||||
"stepIndicator": {
|
||||
"step1": "1. 음악퀴즈",
|
||||
"step2": "2. 모드",
|
||||
"step3": "3. 서버",
|
||||
"step4": "4. 클라이언트",
|
||||
"step5": "5. 완료"
|
||||
},
|
||||
"logViewer": {
|
||||
"title": "설치 로그"
|
||||
},
|
||||
"step1": {
|
||||
"heading": "설치할 음악퀴즈 선택",
|
||||
"loading": "목록을 불러오는 중...",
|
||||
"empty": "등록된 음악퀴즈가 없습니다.",
|
||||
"fetchFailed": "목록을 가져오지 못했습니다: {{message}}",
|
||||
"subtitle": "마인크래프트 {{mc}} / {{platform}}"
|
||||
},
|
||||
"step2": {
|
||||
"heading": "싱글 / 멀티 선택",
|
||||
"singleTitle": "싱글",
|
||||
"singleHint": "싱글 맵으로 혼자 플레이할때",
|
||||
"multiTitle": "멀티",
|
||||
"multiHint": "버킷 서버로 친구들과 같이 플레이할때",
|
||||
"roleHeading": "호스트 / 참가자",
|
||||
"hostTitle": "호스트",
|
||||
"hostHint": "내가 서버를 직접 열고 친구들을 초대할 때",
|
||||
"participantTitle": "참가자",
|
||||
"participantHint": "친구가 연 서버에 접속만 할 때"
|
||||
},
|
||||
"step3": {
|
||||
"heading": "서버 관련 설정",
|
||||
"sub31": {
|
||||
"heading": "서버 설치 경로",
|
||||
"description": "서버를 생성할 폴더를 선택하세요.",
|
||||
"pickFolder": "폴더 선택",
|
||||
"invalidPath": "경로가 유효하지 않습니다.",
|
||||
"confirmed": "경로 확정: {{message}}"
|
||||
},
|
||||
"sub32": {
|
||||
"heading": "JDK 확인",
|
||||
"description": "JDK 자동탐색 or 설치",
|
||||
"pickFolder": "폴더 선택",
|
||||
"auto": "자동 탐색",
|
||||
"install": "자동 설치",
|
||||
"installCancel": "설치 취소",
|
||||
"found": "JDK 발견: {{path}}",
|
||||
"autoDetected": "JDK 자동 탐색됨: {{path}}",
|
||||
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.",
|
||||
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.",
|
||||
"cancelRequested": "JDK 설치 취소 요청 중...",
|
||||
"downloading": "JDK 다운로드 중...",
|
||||
"installComplete": "JDK 자동 설치 완료: {{path}}",
|
||||
"installCanceled": "JDK 설치 취소됨",
|
||||
"installFailed": "JDK 설치 실패: {{message}}",
|
||||
"installError": "JDK 설치 오류: {{message}}",
|
||||
"pathRequired": "JDK 경로를 입력해 주세요."
|
||||
},
|
||||
"sub33": {
|
||||
"heading": "서버 다운로드 및 설치",
|
||||
"description": "서버 파일 다운로드",
|
||||
"waiting": "대기 중",
|
||||
"downloading": "다운로드 중...",
|
||||
"ramHeading": "램 검사",
|
||||
"ramChecking": "검사 중...",
|
||||
"eulaPrompt": "EULA 동의가 필요합니다. 팝업을 확인해 주세요.",
|
||||
"eulaRejected": "EULA 동의 실패. 다운로드를 취소했습니다.",
|
||||
"eulaSaveFailed": "EULA 저장 실패: {{message}}",
|
||||
"doneSummary": "다운로드 및 EULA 동의 완료.",
|
||||
"downloadFailed": "다운로드 실패: {{message}}",
|
||||
"ramTooLow": "시스템 램({{system}}MB)이 음악퀴즈 최소 요구치({{min}}MB)에 미치지 못합니다. 설치를 중단합니다.",
|
||||
"ramMinOk": "시스템 램({{system}}MB)이 권장치보다 부족합니다. 최소치({{applied}}MB)로 진행합니다.",
|
||||
"ramMaxOk": "시스템 램({{system}}MB) 확인. {{applied}}MB로 설정."
|
||||
},
|
||||
"eulaModal": {
|
||||
"title": "Minecraft EULA 동의",
|
||||
"fromMojang": "마인크래프트 서버를 실행하려면 아래 EULA에 동의해야 합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
|
||||
"loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href=\"https://www.minecraft.net/en-us/eula\" target=\"_blank\">https://www.minecraft.net/en-us/eula</a>"
|
||||
},
|
||||
"sub34": {
|
||||
"heading": "서버 설정 편집",
|
||||
"description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.",
|
||||
"open": "편집기 열기",
|
||||
"openedAt": "편집기 주소: <a href=\"{{url}}\" target=\"_blank\">{{url}}</a>",
|
||||
"openFailed": "편집기 실행 실패: {{message}}"
|
||||
},
|
||||
"sub35": {
|
||||
"heading": "포트포워딩",
|
||||
"description": "UPNP를 개방해 외부 접속을 허용합니다.",
|
||||
"portLabel": "포트",
|
||||
"recheck": "재점검",
|
||||
"checking": "확인 중...",
|
||||
"preForwarded": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)",
|
||||
"upnpOk": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)",
|
||||
"manualHint": "직접 포트포워딩을 해주세요.",
|
||||
"manualDetail": "<br><small>외부 주소: {{address}}</small>",
|
||||
"checkFailed": "점검 실패: {{message}}",
|
||||
"ipUnknown": "확인 불가"
|
||||
}
|
||||
},
|
||||
"step4": {
|
||||
"heading": "클라이언트 설정",
|
||||
"sub42": {
|
||||
"heading": "다운로드 및 적용",
|
||||
"description": "클라이언트 설정",
|
||||
"installing": "설치 중...",
|
||||
"done": "클라이언트 설치 완료.",
|
||||
"failed": "설치 실패: {{message}}"
|
||||
}
|
||||
},
|
||||
"step5": {
|
||||
"heading": "설치 완료",
|
||||
"summary": "",
|
||||
"serverHeading": "서버",
|
||||
"openServerFolder": "서버 폴더 열기",
|
||||
"shortcut": "바탕화면에 서버 실행 바로가기 만들기",
|
||||
"startServer": "서버 바로 실행",
|
||||
"launcherHeading": "마인크래프트 런처",
|
||||
"startLauncher": "마인크래프트 런처 실행",
|
||||
"finish": "완료",
|
||||
"finishing": "마무리 중…",
|
||||
"finished": "완료됨"
|
||||
},
|
||||
"configEditor": {
|
||||
"pageTitle": "서버 설정 편집기",
|
||||
"heading": "서버 설정 편집기",
|
||||
"intro": "아래 파일을 직접 편집한 후 \"적용\" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.",
|
||||
"targetLabel": "대상 파일",
|
||||
"applyButton": "적용",
|
||||
"saved": "저장 완료",
|
||||
"saveFailed": "저장 실패",
|
||||
"unknownFile": "알 수 없는 파일",
|
||||
"serverError": "서버 오류: {{message}}"
|
||||
},
|
||||
"errors": {
|
||||
"requestTimeout": "요청 시간 초과",
|
||||
"requestTimeout15s": "요청 시간 초과(15s)",
|
||||
"canceled": "취소되었습니다.",
|
||||
"canceledShort": "취소됨",
|
||||
"packNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
|
||||
"packNotFound2": "음악퀴즈를 찾을 수 없습니다.",
|
||||
"installPathRequired": "서버 설치 경로를 입력해 주세요.",
|
||||
"installPathHangul": "경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.",
|
||||
"installPathHangulShort": "경로에 한글이 포함되면 안 됩니다.",
|
||||
"jdkBusy": "이미 JDK 설치가 진행 중입니다.",
|
||||
"javaExeMissing": "설치 후 java 실행 파일을 찾지 못했습니다: {{path}}",
|
||||
"javaSpawnFailed": "Java 실행 실패: {{message}}",
|
||||
"fabricInstallerExit": "fabric-installer 종료 코드 {{code}}{{detail}}",
|
||||
"fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.",
|
||||
"fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.",
|
||||
"portAllocFail": "포트를 할당할 수 없습니다.",
|
||||
"upnpTimeout": "UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.",
|
||||
"parseResponseFailed": "응답 파싱 실패: {{snippet}}"
|
||||
},
|
||||
"log": {
|
||||
"manifestDownload": "manifest 다운로드: {{url}}",
|
||||
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
|
||||
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
|
||||
"selectedPack": "선택: {{key}}",
|
||||
"jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...",
|
||||
"jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)",
|
||||
"jdkExtracting": "JDK 압축 해제 중...",
|
||||
"jdkDoneRoot": "JDK 자동 설치 완료: {{path}}",
|
||||
"jdkCanceled": "JDK 설치가 취소되었습니다.",
|
||||
"jdkInstallFailedLog": "JDK 설치 실패: {{message}}",
|
||||
"jdkCancelRequested": "JDK 설치 취소 요청을 보냈습니다.",
|
||||
"labelDownload": "{{label}} 다운로드: {{url}}",
|
||||
"labelExtract": "{{label}} 압축 해제: {{dir}}",
|
||||
"labelServerFile": "서버 파일",
|
||||
"labelMap": "맵",
|
||||
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
|
||||
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
|
||||
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
|
||||
"mapInstalledAs": "맵을 saves/{{name}} 으로 설치했습니다.",
|
||||
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
|
||||
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
|
||||
"modsIndexFetch": "모드 목록 조회: {{url}}",
|
||||
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",
|
||||
"modDownload": "모드 다운로드: {{file}}",
|
||||
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
|
||||
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
|
||||
"serverInstallPath": "서버 설치 경로: {{path}}",
|
||||
"runBatMissing": "run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.",
|
||||
"runBatAlreadyInjected": "run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.",
|
||||
"runBatNoJava": "run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.",
|
||||
"runBatInjected": "run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.",
|
||||
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
|
||||
"eulaAccepted": "EULA 동의 저장 완료.",
|
||||
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
|
||||
"portCheckStart": "포트포워딩 점검 시작: 포트 {{port}}",
|
||||
"upnpCleanup": "이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...",
|
||||
"externalIpHttp": "외부 IP 확인(HTTP): {{ip}}",
|
||||
"externalIpHttpFail": "외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...",
|
||||
"externalIpUpnp": "외부 IP 확인(UPnP): {{ip}}",
|
||||
"externalIpUpnpFail": "UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.",
|
||||
"probeStart": "외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...",
|
||||
"probeResult": "1차 점검 결과: {{verdict}} ({{detail}})",
|
||||
"probeVerdictSuccess": "성공",
|
||||
"probeVerdictFail": "실패",
|
||||
"probeVerdictUnknown": "확인 불가",
|
||||
"probePreForwarded": "외부에서 {{addr}}:{{port}} 접근 확인됨. 사용자 규칙으로 포워딩 됨.",
|
||||
"ipUnknown": "(IP 미상)",
|
||||
"upnpTryOpen": "UPnP로 포트 {{port}} 자동 개방 시도(TCP)...",
|
||||
"upnpReqOk": "UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.",
|
||||
"upnpTryFail": "UPnP 시도 실패: {{message}}",
|
||||
"upnpFailDetail": "UPnP 실패: {{message}}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.",
|
||||
"upnpRecheck": "UPnP 적용 후 재점검 {{attempt}}/3...",
|
||||
"upnpDone": "UPnP로 포트 {{port}} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).",
|
||||
"upnpCleanupTest": "테스트용 UPnP 매핑을 정리합니다.",
|
||||
"upnpFailReason1": "UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.",
|
||||
"upnpFailReason2": "외부 포트체크 결과를 받지 못했습니다({{detail}}). UPnP 매핑은 등록됐을 수 있습니다.",
|
||||
"upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}",
|
||||
"upnpExternalTimeout": "UPnP externalIp 조회 타임아웃(8s).",
|
||||
"upnpExternalErr": "UPnP externalIp 오류: {{message}}",
|
||||
"portInUse": "포트 {{port}}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.",
|
||||
"listenerBindFail": "임시 리스너 바인딩 실패: {{message}}",
|
||||
"detailListenerHit": "임시 리스너 도달={{value}}",
|
||||
"detailListenerSkip": "임시 리스너=skip(포트 사용중)",
|
||||
"detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}",
|
||||
"detailIfconfigFail": "ifconfig.co 실패={{error}}",
|
||||
"detailNone": "결과 없음",
|
||||
"upnpClientFailRemove": "UPnP 클라이언트 생성 실패(매핑 제거 단계): {{message}}",
|
||||
"upnpRemoveTimeout": "UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.",
|
||||
"upnpRemoveAttempt": "UPnP 매핑 제거 시도 결과: {{message}} (없으면 정상)",
|
||||
"upnpRemoveDone": "UPnP 매핑 제거 완료(포트 {{port}}).",
|
||||
"platformDownload": "플랫폼({{type}}) 다운로드: {{url}}",
|
||||
"platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)",
|
||||
"platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.",
|
||||
"fabricFetchInstallerList": "Fabric installer 최신 버전 조회 중...",
|
||||
"fabricInstallerDownload": "Fabric installer {{version}} 다운로드: {{url}}",
|
||||
"javaUsed": "Java 사용: {{path}}",
|
||||
"fabricInstallStart": "Fabric 자동 설치 시작: {{mc}} / loader {{loader}} → {{dir}}",
|
||||
"fabricInstallDone": "Fabric 자동 설치 완료.",
|
||||
"fabricAlreadyInstalled": "Fabric 이미 설치돼 있어 건너뜁니다: {{id}} ({{dir}})",
|
||||
"launcherProfilesMissing": "launcher_profiles.json을 찾을 수 없습니다: {{path}}",
|
||||
"javaArgsUpdated": "JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): \"{{before}}\" → \"{{after}}\"",
|
||||
"lastVersionId": "launcher_profiles 의 lastVersionId = {{id}}",
|
||||
"versionMissingWarn": "경고: .minecraft/versions/{{id}} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.",
|
||||
"launcherProfilesUpdated": "launcher_profiles.json 갱신: 프로필 \"{{profile}}\", gameDir={{dir}}",
|
||||
"minecraftRootMissing": ".minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.",
|
||||
"settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}",
|
||||
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{skipped}}개.",
|
||||
"settingCopyError": "기존 설정 복사 중 오류: {{message}}",
|
||||
"runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.",
|
||||
"runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.",
|
||||
"runtimeLinkCreated": "링크 생성: .mc_custom/{{dir}} → .minecraft/{{dir}}",
|
||||
"runtimeLinkFail": "링크 생성 실패 ({{dir}}): {{message}}",
|
||||
"shortcutCreated": "바로가기 생성: {{path}}",
|
||||
"shortcutFailed": "바로가기 생성 실패",
|
||||
"shortcutDescription": "음악퀴즈 서버 실행",
|
||||
"runBatMissingPath": "run.bat을 찾을 수 없습니다: {{path}}",
|
||||
"serverStartRequested": "서버 실행 요청 완료.",
|
||||
"launcherUrlSchemeNonWin": "마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).",
|
||||
"launcherFail": "런처 실행 실패: {{message}}",
|
||||
"launcherExecShell": "마인크래프트 런처 실행({{label}}, 셸 경유): {{path}}",
|
||||
"launcherExec": "마인크래프트 런처 실행({{label}}): {{path}}",
|
||||
"launcherCandFail": "{{path}} 실행 실패: {{message}}",
|
||||
"launcherAppsFolderTry": "AppsFolder 로 MS Store 런처 실행 시도: {{aumid}}",
|
||||
"launcherAppsFolderFail": "AppsFolder 실행 실패: {{message}}",
|
||||
"launcherUrlSchemeFallback": "마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).",
|
||||
"launcherUrlSchemeFail": "URL 스킴 실행 실패: {{message}}.",
|
||||
"launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요."
|
||||
},
|
||||
"candidates": {
|
||||
"winProgramFiles86": "Win32 설치(Program Files (x86))",
|
||||
"winProgramFiles": "Win32 설치(Program Files)",
|
||||
"winLegacy86": "Win32 설치(legacy Minecraft 폴더)",
|
||||
"winLegacy": "Win32 설치(legacy Minecraft 폴더)",
|
||||
"xboxGamePass": "Xbox / Game Pass",
|
||||
"npmPortable": "npm/portable",
|
||||
"appAliasMinecraft": "App Execution Alias(Minecraft.exe)",
|
||||
"appAliasLauncher": "App Execution Alias(MinecraftLauncher.exe)"
|
||||
}
|
||||
}
|
||||
170
locales/server/ko-kr.json
Normal file
170
locales/server/ko-kr.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"common": {
|
||||
"back": "← 돌아가기",
|
||||
"backToList": "목록으로",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"ok": "확인",
|
||||
"delete": "삭제",
|
||||
"edit": "수정",
|
||||
"close": "x",
|
||||
"loading": "불러오는 중..."
|
||||
},
|
||||
"site": {
|
||||
"indexTitle": "음악퀴즈 목록",
|
||||
"heroTitle": "마인크래프트 음악퀴즈",
|
||||
"heroSubtitle": "설치기에서 사용 가능한 음악퀴즈 목록입니다.",
|
||||
"empty": "등록된 음악퀴즈가 없습니다.",
|
||||
"fileLabel": "파일: {{file}}.json",
|
||||
"mcVersion": "마인크래프트",
|
||||
"platform": "플랫폼",
|
||||
"modsFolder": "모드 폴더",
|
||||
"resourcepack": "리소스팩",
|
||||
"noneFallback": "없음"
|
||||
},
|
||||
"nav": {
|
||||
"brand": "관리자 페이지",
|
||||
"logout": "로그아웃"
|
||||
},
|
||||
"login": {
|
||||
"title": "관리자 로그인",
|
||||
"password": "비밀번호",
|
||||
"submit": "로그인",
|
||||
"wrongPassword": "비밀번호가 올바르지 않습니다."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "음악퀴즈 목록",
|
||||
"browserTitle": "관리자 대시보드",
|
||||
"editList": "음악목록 수정",
|
||||
"editDatapack": "데이터팩 수정",
|
||||
"addPack": "음악퀴즈 추가",
|
||||
"deletePack": "음악퀴즈 삭제",
|
||||
"emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
|
||||
"select": "선택",
|
||||
"confirmDelete": "삭제 확인",
|
||||
"mcShort": "MC"
|
||||
},
|
||||
"list": {
|
||||
"browserTitle": "음악목록 수정",
|
||||
"title": "음악목록 수정"
|
||||
},
|
||||
"listEditor": {
|
||||
"browserTitle": "{{name}} — 음악/사진 목록",
|
||||
"dirtyTooltip": "저장되지 않은 변경사항이 있습니다",
|
||||
"tabMusic": "음악목록",
|
||||
"tabImage": "사진목록",
|
||||
"saveList": "목록 저장",
|
||||
"clearList": "목록 초기화",
|
||||
"playlistPlaceholder": "유튜브 플레이리스트 URL",
|
||||
"fetchPlaylist": "플레이리스트 불러오기",
|
||||
"imageFromMusic": "음악목록에서 가져오기",
|
||||
"modalConfirmTitle": "확인",
|
||||
"musicEditTitle": "음악 항목 수정",
|
||||
"musicEditUrl": "유튜브 영상 주소",
|
||||
"musicEditHint": "저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.",
|
||||
"imageEditTitle": "사진 항목 수정",
|
||||
"imageSegYt": "유튜브 주소",
|
||||
"imageSegImg": "이미지 주소",
|
||||
"imageEditUrl": "주소",
|
||||
"titleFallback": "(제목 없음)",
|
||||
"artistFallback": "(가수 미상)",
|
||||
"rowEditTooltip": "더블클릭해서 수정",
|
||||
"aliasBtn": "별칭",
|
||||
"aliasBtnWithCount": "별칭 ({{count}})",
|
||||
"aliasModalTitle": "별칭 - {{title}}",
|
||||
"aliasBack": "← 돌아가기",
|
||||
"aliasAdd": "별칭 추가",
|
||||
"aliasPlaceholder": "별칭 입력",
|
||||
"aliasRemove": "삭제",
|
||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||
"metaLoading": "메타데이터 가져오는 중…",
|
||||
"metaFailedShort": "메타 조회 실패",
|
||||
"metaFailedTitle": "메타데이터 조회 실패",
|
||||
"metaFailedAsk": "{{message}}\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?",
|
||||
"saving": "저장 중…",
|
||||
"saved": "저장 완료",
|
||||
"saveFailed": "저장 실패: {{message}}",
|
||||
"fetchEnterUrl": "플레이리스트 주소를 입력해 주세요.",
|
||||
"fetchTitle": "플레이리스트 불러오기",
|
||||
"fetchConfirm": "현재 {{type}}목록 순서가 모두 사라집니다. 진행할까요?",
|
||||
"fetchTypeMusic": "음악",
|
||||
"fetchTypeImage": "사진",
|
||||
"fetchLoading": "불러오는 중…",
|
||||
"fetchedCount": "{{count}}개 항목을 불러왔습니다.",
|
||||
"failed": "실패: {{message}}",
|
||||
"clearTitle": "목록 초기화",
|
||||
"clearConfirm": "\"{{type}}목록\"을 비웁니다. 진행할까요?",
|
||||
"imageFromMusicEmpty": "음악목록이 비어 있어 가져올 수 없습니다.",
|
||||
"imageFromMusicTitle": "사진목록 가져오기",
|
||||
"imageFromMusicConfirm": "저장된 음악목록의 영상 {{count}}개를 그대로 사진목록으로 가져옵니다.\n현재 사진목록은 모두 사라집니다. 진행할까요?",
|
||||
"leaveTitle": "저장되지 않은 변경사항",
|
||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
|
||||
},
|
||||
"editor": {
|
||||
"browserTitle": "{{name}} 편집",
|
||||
"eyebrow": "PACK EDITOR",
|
||||
"displayName": "음악퀴즈 이름",
|
||||
"fileName": "JSON 파일 이름 (확장자 제외)",
|
||||
"mcVersion": "마인크래프트 버전",
|
||||
"platformType": "모드 플랫폼",
|
||||
"platformDownloadUrl": "플랫폼 설치파일 URL",
|
||||
"platformDownloadHint": "도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/<파일명></code>으로 해석됩니다.",
|
||||
"platformLoaderVersion": "Fabric Loader 버전",
|
||||
"platformLoaderHint": "선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.",
|
||||
"platformLoaderEmpty": "호환 로더 없음",
|
||||
"platformLoaderPickMc": "마인크래프트 버전을 먼저 선택하세요",
|
||||
"platformLoaderLoadFailed": "로더 목록 로드 실패: {{message}}",
|
||||
"serverMinRam": "서버 최소 램 (MB)",
|
||||
"serverMaxRam": "서버 최대 램 (MB)",
|
||||
"clientMinRam": "클라이언트 최소 램 (MB)",
|
||||
"clientRecommendedRam": "클라이언트 권장 램 (MB)",
|
||||
"mapPath": "맵 파일 (.zip)",
|
||||
"mapPathHint": "/file/maps/ 아래 zip 파일 이름.",
|
||||
"serverPath": "서버 파일 (.zip)",
|
||||
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
|
||||
"modsFolder": "모드 폴더 이름",
|
||||
"modsFolderHint": "/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
|
||||
"resourcepackPath": "리소스팩 (.zip)",
|
||||
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
|
||||
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
|
||||
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
|
||||
},
|
||||
"datapack": {
|
||||
"browserTitle": "데이터팩 수정",
|
||||
"title": "데이터팩 수정",
|
||||
"pickPack": "음악퀴즈 선택",
|
||||
"pickedNone": "선택된 음악퀴즈 없음",
|
||||
"pickedLabel": "선택: {{name}}",
|
||||
"totalCount": "총 {{count}}개의 음악을 찾았습니다.",
|
||||
"hint": "music_quiz 데이터팩의 data/mq/function/init/songs.mcfunction 파일에 아래 코드를 그대로 덮어쓰세요.",
|
||||
"export": "코드 출력",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"exporting": "출력 중…",
|
||||
"exported": "출력 완료",
|
||||
"failed": "실패: {{message}}",
|
||||
"modalPickTitle": "음악퀴즈 선택",
|
||||
"imagesZip": "이미지.zip 출력",
|
||||
"imagesZipSizeLabel": "크기",
|
||||
"imagesZipDownloading": "이미지.zip 생성 중…",
|
||||
"imagesZipDone": "이미지.zip 다운로드 완료"
|
||||
},
|
||||
"errors": {
|
||||
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
|
||||
"packNotFoundJson": "음악퀴즈를 찾을 수 없습니다.",
|
||||
"videoUrlRequired": "영상 주소를 입력해 주세요.",
|
||||
"playlistUrlRequired": "플레이리스트 주소를 입력해 주세요.",
|
||||
"metaNotFound": "메타데이터를 찾을 수 없습니다.",
|
||||
"ramOrderInvalid": "clientMinRam은 clientRecommendedRam보다 클 수 없습니다.",
|
||||
"unknown": "알 수 없는 오류",
|
||||
"serverError": "서버 오류: {{message}}"
|
||||
},
|
||||
"youtube": {
|
||||
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
|
||||
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
|
||||
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
|
||||
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
|
||||
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
|
||||
"tooManyRedirects": "redirect 가 너무 많습니다."
|
||||
}
|
||||
}
|
||||
1206
package-lock.json
generated
1206
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
@@ -8,24 +8,31 @@
|
||||
"start": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
|
||||
"installer": "tsc -p tsconfig.installer.json && electron .",
|
||||
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
|
||||
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
|
||||
"preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5",
|
||||
"dist:win": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml",
|
||||
"dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
"ejs": "^3.1.10",
|
||||
"extract-zip": "^2.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nat-upnp": "^1.1.1",
|
||||
"extract-zip": "^2.0.1"
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4",
|
||||
"@types/node": "^22.5.0",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^22.5.0",
|
||||
"electron": "^31.4.0",
|
||||
"electron-builder": "^24.13.3"
|
||||
"electron-builder": "^24.13.3",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
688
public/listEditor.js
Normal file
688
public/listEditor.js
Normal file
@@ -0,0 +1,688 @@
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
// listEditor.ejs 에서 주입되는 사전 (locales/server/ko-kr.json 의 listEditor + common 섹션).
|
||||
// 키가 비어 있어도 lookup 함수가 키를 그대로 반환해 UI 가 깨지지는 않는다.
|
||||
function tt(key, params) {
|
||||
var parts = key.split('.')
|
||||
var cur = (typeof I18N !== 'undefined') ? I18N : {}
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
|
||||
else { cur = null; break }
|
||||
}
|
||||
var tpl = (typeof cur === 'string') ? cur : key
|
||||
if (!params) return tpl
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
|
||||
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
|
||||
})
|
||||
}
|
||||
|
||||
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 dirty = false
|
||||
var dirtyMarkEl = document.getElementById('dirty-mark')
|
||||
var baseTitle = document.title
|
||||
function updateDirtyIndicator() {
|
||||
if (dirtyMarkEl) dirtyMarkEl.hidden = !dirty
|
||||
document.title = dirty ? ('*' + baseTitle) : baseTitle
|
||||
}
|
||||
function markDirty() { dirty = true; updateDirtyIndicator() }
|
||||
function markClean() { dirty = false; updateDirtyIndicator() }
|
||||
|
||||
// ── 탭 ────────────────────────────────────────────
|
||||
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 escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]
|
||||
})
|
||||
}
|
||||
// 사진 항목에 어울리는 캡션. 동일한 URL 의 음악 항목이 있으면 그 제목/가수 를 빌려옴.
|
||||
function captionForImage(url) {
|
||||
var match = null
|
||||
for (var i = 0; i < state.music.length; i++) {
|
||||
if (state.music[i].url === url) { match = state.music[i]; break }
|
||||
}
|
||||
if (match && (match.title || match.artist)) {
|
||||
return { title: match.title || '', sub: match.artist || '' }
|
||||
}
|
||||
return { title: '', sub: '' }
|
||||
}
|
||||
|
||||
// ── 렌더 ──────────────────────────────────────────
|
||||
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)
|
||||
// 기본 상태에서는 contenteditable 을 켜지 않는다. 더블클릭 시에만 편집 모드 ON.
|
||||
// 이렇게 해야 어디를 눌러도 드래그가 시작될 수 있다.
|
||||
var aliasCount = Array.isArray(entry.aliases) ? entry.aliases.length : 0
|
||||
var aliasLabel = aliasCount > 0
|
||||
? tt('aliasBtnWithCount', { count: aliasCount })
|
||||
: tt('aliasBtn')
|
||||
li.innerHTML =
|
||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||
'<div class="rowMeta">' +
|
||||
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="' + escapeHtml(tt('titleFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
|
||||
escapeHtml(entry.title || '') +
|
||||
'</div>' +
|
||||
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="' + escapeHtml(tt('artistFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
|
||||
escapeHtml(entry.artist || '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
|
||||
escapeHtml(aliasLabel) +
|
||||
'</button>' +
|
||||
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||
attachDraggable(li, 'music', idx)
|
||||
attachInlineEdit(li, idx)
|
||||
attachAliasBtn(li, idx)
|
||||
ol.appendChild(li)
|
||||
})
|
||||
}
|
||||
|
||||
function renderImage() {
|
||||
var grid = document.getElementById('image-list')
|
||||
grid.innerHTML = ''
|
||||
state.images.forEach(function (entry, idx) {
|
||||
var cap = captionForImage(entry.url)
|
||||
var card = document.createElement('div')
|
||||
card.className = 'imageCard'
|
||||
card.draggable = true
|
||||
card.dataset.index = String(idx)
|
||||
card.innerHTML =
|
||||
'<div class="imgWrap">' +
|
||||
'<span class="cardNum">' + (idx + 1) + '</span>' +
|
||||
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
||||
'</div>' +
|
||||
'<div class="cardCaption">' +
|
||||
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || ('<span class="muted">' + escapeHtml(tt('titleFallback')) + '</span>')) + '</div>' +
|
||||
'<div class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
|
||||
'</div>'
|
||||
attachDraggable(card, 'image', idx)
|
||||
grid.appendChild(card)
|
||||
})
|
||||
}
|
||||
|
||||
// ── 인라인 편집 (제목/가수) ─────────────────────────
|
||||
// 기본 상태에서는 contenteditable 이 꺼져 있어서 row 어디를 클릭해도 드래그가 시작된다.
|
||||
// 더블클릭 시점에 해당 칸만 contenteditable 로 켜고, 드래그는 일시 비활성화.
|
||||
// blur 또는 Enter 키로 편집 종료 + 상태 저장 + 드래그 복원.
|
||||
function attachInlineEdit(li, idx) {
|
||||
li.querySelectorAll('[data-field]').forEach(function (el) {
|
||||
el.addEventListener('mousedown', function (e) {
|
||||
// 편집 모드일 때만 드래그를 막아서 텍스트 선택을 허용.
|
||||
if (el.getAttribute('contenteditable') === 'true') e.stopPropagation()
|
||||
})
|
||||
el.addEventListener('dblclick', function (e) {
|
||||
e.stopPropagation()
|
||||
if (el.getAttribute('contenteditable') === 'true') return
|
||||
el.setAttribute('contenteditable', 'true')
|
||||
li.draggable = false
|
||||
el.focus()
|
||||
try {
|
||||
var range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
var sel = window.getSelection()
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
} catch (_) {}
|
||||
})
|
||||
el.addEventListener('blur', function () {
|
||||
if (el.getAttribute('contenteditable') !== 'true') return
|
||||
el.removeAttribute('contenteditable')
|
||||
li.draggable = true
|
||||
var field = el.getAttribute('data-field')
|
||||
var value = (el.textContent || '').replace(/\r?\n/g, ' ').trim()
|
||||
if (!state.music[idx]) return
|
||||
var prev = field === 'title' ? state.music[idx].title : state.music[idx].artist
|
||||
if (value === prev) return
|
||||
if (field === 'title') state.music[idx].title = value
|
||||
else if (field === 'artist') state.music[idx].artist = value
|
||||
markDirty()
|
||||
})
|
||||
el.addEventListener('keydown', function (e) {
|
||||
if (el.getAttribute('contenteditable') !== 'true') return
|
||||
if (e.key === 'Enter') { e.preventDefault(); el.blur() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); el.blur() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── 드래그 시스템 ───────────────────────────────────
|
||||
// 원본 요소 자체를 dragover 동안 이동시켜서 "착지 자리에 반투명 고스트" 효과를 만든다.
|
||||
// placeholder/clone 방식은 source 를 display:none 으로 숨기는 순간 일부 브라우저에서
|
||||
// 드래그가 즉시 취소되는 문제가 있어 채택하지 않는다.
|
||||
var drag = null // { type, srcEl } | null
|
||||
|
||||
function attachDraggable(el, type, idx) {
|
||||
el.addEventListener('dragstart', function (e) {
|
||||
// 편집 중인 칸을 잡고 드래그하려는 경우는 드래그를 막음.
|
||||
var t = e.target
|
||||
if (t && t.getAttribute && t.getAttribute('contenteditable') === 'true') {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
drag = { type: type, srcEl: el }
|
||||
try {
|
||||
e.dataTransfer.setData('text/plain', String(idx))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
} catch (_) {}
|
||||
// 드래그 이미지 캡처 이후에 ghost 스타일을 적용 (이미지에 ghost 가 묻지 않도록).
|
||||
setTimeout(function () {
|
||||
if (drag && drag.srcEl) drag.srcEl.classList.add('dragGhost')
|
||||
}, 0)
|
||||
})
|
||||
el.addEventListener('dragend', cleanupDrag)
|
||||
el.addEventListener('contextmenu', function (e) {
|
||||
e.preventDefault()
|
||||
openCtxMenu(e.pageX, e.pageY, type, idx)
|
||||
})
|
||||
}
|
||||
|
||||
function bindContainerDnd(containerId, type, orientation) {
|
||||
var container = document.getElementById(containerId)
|
||||
container.addEventListener('dragover', function (e) {
|
||||
if (!drag || drag.type !== type) return
|
||||
e.preventDefault()
|
||||
try { e.dataTransfer.dropEffect = 'move' } catch (_) {}
|
||||
// 컨테이너 자식 중 source 를 제외한 나머지에서 삽입 지점을 찾는다.
|
||||
var target = null
|
||||
for (var i = 0; i < container.children.length; i++) {
|
||||
var c = container.children[i]
|
||||
if (c === drag.srcEl) continue
|
||||
var rect = c.getBoundingClientRect()
|
||||
if (orientation === 'grid') {
|
||||
// 2D 그리드: 위쪽 행에 있으면 그 행으로, 같은 행에서는 중앙 X 보다 왼쪽이면 이 카드 앞.
|
||||
if (e.clientY < rect.top) { target = c; break }
|
||||
if (e.clientY <= rect.bottom && e.clientX < rect.left + rect.width / 2) {
|
||||
target = c; break
|
||||
}
|
||||
} else if (orientation === 'horizontal') {
|
||||
if (e.clientX < rect.left + rect.width / 2) { target = c; break }
|
||||
} else {
|
||||
if (e.clientY < rect.top + rect.height / 2) { target = c; break }
|
||||
}
|
||||
}
|
||||
if (target) {
|
||||
if (drag.srcEl.nextSibling !== target) container.insertBefore(drag.srcEl, target)
|
||||
} else {
|
||||
if (drag.srcEl !== container.lastChild) container.appendChild(drag.srcEl)
|
||||
}
|
||||
})
|
||||
container.addEventListener('drop', function (e) {
|
||||
if (!drag || drag.type !== type) return
|
||||
e.preventDefault()
|
||||
// 새 인덱스 = source 의 현재 컨테이너 내 위치.
|
||||
var newIdx = 0
|
||||
for (var i = 0; i < container.children.length; i++) {
|
||||
if (container.children[i] === drag.srcEl) { newIdx = i; break }
|
||||
}
|
||||
var arr = (type === 'music') ? state.music : state.images
|
||||
// 원래 인덱스: state 에서 동일 url 을 찾는 대신 data-index 가 렌더 시점의 위치이므로 사용.
|
||||
var srcIdx = Number(drag.srcEl.dataset.index)
|
||||
if (srcIdx !== newIdx) {
|
||||
var moved = arr.splice(srcIdx, 1)[0]
|
||||
arr.splice(newIdx, 0, moved)
|
||||
markDirty()
|
||||
}
|
||||
cleanupDrag()
|
||||
if (type === 'music') renderMusic(); else renderImage()
|
||||
})
|
||||
}
|
||||
bindContainerDnd('music-list', 'music', 'vertical')
|
||||
bindContainerDnd('image-list', 'image', 'grid')
|
||||
|
||||
function cleanupDrag() {
|
||||
if (!drag) return
|
||||
if (drag.srcEl) drag.srcEl.classList.remove('dragGhost')
|
||||
drag = null
|
||||
}
|
||||
|
||||
// ── 컨텍스트 메뉴 ─────────────────────────────────
|
||||
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)
|
||||
markDirty()
|
||||
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 || ''
|
||||
setStatus('edit-music-status', '')
|
||||
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()
|
||||
})
|
||||
})
|
||||
// ESC 로 열린 모달 닫기. 별칭 모달은 "돌아가기" 와 같은 저장 후 닫기 의미.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') return
|
||||
var aliasOpen = aliasModal && !aliasModal.hidden
|
||||
var anyOpen = document.querySelector('.modalOverlay:not([hidden])')
|
||||
if (!anyOpen) return
|
||||
e.preventDefault()
|
||||
if (aliasOpen) {
|
||||
closeAliasModalSaving()
|
||||
return
|
||||
}
|
||||
closeAllModals()
|
||||
})
|
||||
|
||||
document.getElementById('edit-music-save').addEventListener('click', function () {
|
||||
var url = document.getElementById('edit-music-url').value.trim()
|
||||
if (!url) return
|
||||
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
|
||||
if (url === prev.url) { closeAllModals(); return }
|
||||
setStatus('edit-music-status', tt('metaLoading'))
|
||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
|
||||
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, status: r.status, body: body } })
|
||||
}).then(function (result) {
|
||||
if (!result.ok || !result.body || !result.body.ok) {
|
||||
var msg = (result.body && result.body.message) ? result.body.message : tt('metaFailedShort')
|
||||
ask(tt('metaFailedTitle'), tt('metaFailedAsk', { message: msg }), function () {
|
||||
state.music[editingIdx].url = url
|
||||
markDirty()
|
||||
closeAllModals()
|
||||
renderMusic()
|
||||
})
|
||||
setStatus('edit-music-status', msg, true)
|
||||
return
|
||||
}
|
||||
var meta = result.body.entry
|
||||
state.music[editingIdx] = {
|
||||
url: meta.url || url,
|
||||
title: meta.title || prev.title || '',
|
||||
artist: meta.channel || prev.artist || '',
|
||||
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
|
||||
}
|
||||
markDirty()
|
||||
closeAllModals()
|
||||
renderMusic()
|
||||
}).catch(function (err) {
|
||||
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
if (state.images[editingIdx].url !== url) {
|
||||
state.images[editingIdx].url = url
|
||||
markDirty()
|
||||
}
|
||||
closeAllModals()
|
||||
renderImage()
|
||||
})
|
||||
|
||||
// ── 별칭 모달 ─────────────────────────────────────
|
||||
// 음악 행의 "별칭" 버튼을 누르면 열린다. 헤더의 "← 돌아가기" 버튼 (또는 닫기 동작)이
|
||||
// 호출되면 현재 인풋박스들에 입력된 값을 정규화해 state.music[idx].aliases 에 저장.
|
||||
var aliasModal = document.getElementById('aliasModal')
|
||||
var aliasRowsHost = document.getElementById('alias-rows')
|
||||
var aliasModalTitleEl = document.getElementById('alias-modal-title')
|
||||
var aliasBackBtn = document.getElementById('alias-back')
|
||||
var aliasAddBtn = document.getElementById('alias-add')
|
||||
var aliasEditingIdx = -1
|
||||
|
||||
function attachAliasBtn(li, idx) {
|
||||
var btn = li.querySelector('[data-alias-open]')
|
||||
if (!btn) return
|
||||
// 버튼에서 시작하는 mousedown 은 행 드래그로 전파되지 않도록 차단.
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation()
|
||||
openAliasModal(idx)
|
||||
})
|
||||
}
|
||||
|
||||
function openAliasModal(idx) {
|
||||
if (!state.music[idx]) return
|
||||
aliasEditingIdx = idx
|
||||
var entry = state.music[idx]
|
||||
aliasModalTitleEl.textContent = tt('aliasModalTitle', { title: entry.title || tt('titleFallback') })
|
||||
aliasRowsHost.innerHTML = ''
|
||||
var existing = Array.isArray(entry.aliases) ? entry.aliases : []
|
||||
if (existing.length === 0) {
|
||||
// 빈 상태에서도 입력 시작을 쉽게 하려고 첫 줄 하나는 미리 만들어 둔다.
|
||||
appendAliasRow('')
|
||||
} else {
|
||||
existing.forEach(function (a) { appendAliasRow(a) })
|
||||
}
|
||||
aliasModal.hidden = false
|
||||
}
|
||||
|
||||
function appendAliasRow(value) {
|
||||
var row = document.createElement('div')
|
||||
row.className = 'aliasRow'
|
||||
var input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
input.className = 'textInput aliasInput'
|
||||
input.placeholder = tt('aliasPlaceholder')
|
||||
input.value = value || ''
|
||||
var removeBtn = document.createElement('button')
|
||||
removeBtn.type = 'button'
|
||||
removeBtn.className = 'aliasRowRemove'
|
||||
removeBtn.title = tt('aliasRemove')
|
||||
removeBtn.textContent = '−'
|
||||
removeBtn.addEventListener('click', function () { row.remove() })
|
||||
row.appendChild(input)
|
||||
row.appendChild(removeBtn)
|
||||
aliasRowsHost.appendChild(row)
|
||||
return input
|
||||
}
|
||||
|
||||
function readAliasInputs() {
|
||||
var seen = Object.create(null)
|
||||
var out = []
|
||||
var inputs = aliasRowsHost.querySelectorAll('.aliasInput')
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
var v = (inputs[i].value || '').trim()
|
||||
if (!v) continue
|
||||
if (seen[v]) continue
|
||||
seen[v] = true
|
||||
out.push(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function closeAliasModalSaving() {
|
||||
if (aliasEditingIdx < 0 || !state.music[aliasEditingIdx]) {
|
||||
aliasModal.hidden = true
|
||||
aliasEditingIdx = -1
|
||||
return
|
||||
}
|
||||
var nextAliases = readAliasInputs()
|
||||
var prev = state.music[aliasEditingIdx].aliases || []
|
||||
var changed = prev.length !== nextAliases.length
|
||||
if (!changed) {
|
||||
for (var i = 0; i < prev.length; i++) {
|
||||
if (prev[i] !== nextAliases[i]) { changed = true; break }
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
state.music[aliasEditingIdx].aliases = nextAliases
|
||||
markDirty()
|
||||
renderMusic()
|
||||
}
|
||||
aliasModal.hidden = true
|
||||
aliasEditingIdx = -1
|
||||
}
|
||||
|
||||
aliasAddBtn.addEventListener('click', function () {
|
||||
var input = appendAliasRow('')
|
||||
input.focus()
|
||||
})
|
||||
aliasBackBtn.addEventListener('click', closeAliasModalSaving)
|
||||
// 모달 바깥 클릭으로 닫혀도 입력값은 보존(저장)되도록 처리.
|
||||
aliasModal.addEventListener('click', function (e) {
|
||||
if (e.target === aliasModal) closeAliasModalSaving()
|
||||
})
|
||||
|
||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||
if (state.music.length === 0) {
|
||||
setStatus('status-image', tt('imageFromMusicEmpty'), true)
|
||||
return
|
||||
}
|
||||
ask(tt('imageFromMusicTitle'),
|
||||
tt('imageFromMusicConfirm', { count: state.music.length }),
|
||||
function () {
|
||||
state.images = state.music.map(function (m) { return { url: m.url } })
|
||||
markDirty()
|
||||
renderImage()
|
||||
setStatus('status-image', tt('fetchedCount', { count: state.images.length }))
|
||||
})
|
||||
})
|
||||
|
||||
// ── 액션 (save/clear/fetch) ───────────────────────
|
||||
var confirmModal = document.getElementById('confirmModal')
|
||||
var pendingOk = null
|
||||
function ask(title, message, onOk) {
|
||||
document.getElementById('confirm-title').textContent = title
|
||||
document.getElementById('confirm-message').textContent = message
|
||||
confirmModal.hidden = false
|
||||
pendingOk = onOk
|
||||
}
|
||||
document.getElementById('confirm-ok').addEventListener('click', function () {
|
||||
confirmModal.hidden = true
|
||||
var fn = pendingOk
|
||||
pendingOk = null
|
||||
if (fn) fn()
|
||||
})
|
||||
// 취소(×, 취소 버튼, 배경 클릭)로 닫히면 pending 콜백 폐기.
|
||||
confirmModal.querySelectorAll('[data-modal-close]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { pendingOk = null })
|
||||
})
|
||||
confirmModal.addEventListener('click', function (e) {
|
||||
if (e.target === confirmModal) pendingOk = null
|
||||
})
|
||||
|
||||
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') {
|
||||
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
|
||||
ask(tt('clearTitle'), tt('clearConfirm', { type: typeLabel }), function () {
|
||||
if (target === 'music') { state.music = []; renderMusic() }
|
||||
else { state.images = []; renderImage() }
|
||||
markDirty()
|
||||
})
|
||||
} 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()
|
||||
document.querySelectorAll('#music-list .trackRow').forEach(function (li) {
|
||||
var idx = Number(li.dataset.index)
|
||||
var t = li.querySelector('[data-field="title"]')
|
||||
var a = li.querySelector('[data-field="artist"]')
|
||||
if (state.music[idx]) {
|
||||
if (t) state.music[idx].title = (t.textContent || '').replace(/\r?\n/g, ' ').trim()
|
||||
if (a) state.music[idx].artist = (a.textContent || '').replace(/\r?\n/g, ' ').trim()
|
||||
}
|
||||
})
|
||||
var statusId = 'status-' + target
|
||||
setStatus(statusId, tt('saving'))
|
||||
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, tt('saved')); markClean() }
|
||||
else setStatus(statusId, tt('saveFailed', { message: result.body.message || '' }), true)
|
||||
}).catch(function (err) {
|
||||
setStatus(statusId, tt('saveFailed', { message: err.message }), true)
|
||||
})
|
||||
}
|
||||
|
||||
function doFetch(target) {
|
||||
var input = document.getElementById(target + '-playlist-url')
|
||||
var url = input.value.trim()
|
||||
if (!url) {
|
||||
setStatus('status-' + target, tt('fetchEnterUrl'), true)
|
||||
return
|
||||
}
|
||||
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
|
||||
ask(tt('fetchTitle'), tt('fetchConfirm', { type: typeLabel }), function () {
|
||||
setStatus('status-' + target, tt('fetchLoading'))
|
||||
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, tt('failed', { message: 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()
|
||||
}
|
||||
markDirty()
|
||||
setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
|
||||
}).catch(function (err) {
|
||||
setStatus('status-' + target, tt('failed', { message: err.message }), true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 플레이리스트 URL 입력 변경 추적
|
||||
;['music-playlist-url', 'image-playlist-url'].forEach(function (id) {
|
||||
var el = document.getElementById(id)
|
||||
if (!el) return
|
||||
var initialValue = el.value
|
||||
el.addEventListener('input', function () {
|
||||
if (el.value !== initialValue) markDirty()
|
||||
})
|
||||
})
|
||||
|
||||
// ── 페이지 이탈 가드 ───────────────────────────────
|
||||
// 1) 돌아가기 링크 : 커스텀 확인 팝업
|
||||
document.querySelectorAll('a.ghostLink').forEach(function (a) {
|
||||
a.addEventListener('click', function (e) {
|
||||
if (!dirty) return
|
||||
e.preventDefault()
|
||||
var href = a.getAttribute('href')
|
||||
ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
|
||||
markClean()
|
||||
window.location.href = href
|
||||
})
|
||||
})
|
||||
})
|
||||
// 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (!dirty) return
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
})
|
||||
|
||||
// 초기 렌더
|
||||
renderMusic()
|
||||
renderImage()
|
||||
})()
|
||||
@@ -171,9 +171,16 @@ body.siteBody.centerLayout {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboardHeader > div { min-width: 0; }
|
||||
.dashboardHeader h1 { margin: 0; font-size: 24px; }
|
||||
|
||||
.dashboardActions { display: flex; gap: 8px; }
|
||||
.dashboardActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inlineForm { margin: 0; }
|
||||
|
||||
@@ -193,6 +200,10 @@ body.siteBody.centerLayout {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.primaryButton:hover { background: var(--accent-hover); }
|
||||
@@ -205,6 +216,10 @@ body.siteBody.centerLayout {
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.secondaryButton:hover { border-color: var(--accent); }
|
||||
@@ -218,6 +233,10 @@ body.siteBody.centerLayout {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dangerButton:hover { background: #d73a48; }
|
||||
@@ -357,3 +376,214 @@ body.siteBody.centerLayout {
|
||||
font-size: 13px;
|
||||
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; }
|
||||
.tabPanel[hidden] { display: none !important; }
|
||||
|
||||
.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 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;
|
||||
}
|
||||
.aliasBtn {
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aliasBtn:hover { border-color: var(--accent); }
|
||||
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
/* 별칭 모달 */
|
||||
.aliasModalHeader {
|
||||
display: grid !important;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.aliasModalHeader h3 { text-align: center; }
|
||||
.aliasModalHeader .ghostLink {
|
||||
background: transparent; border: none; color: var(--accent); cursor: pointer;
|
||||
font-size: 13px; padding: 4px 8px;
|
||||
}
|
||||
.aliasModalHeader .ghostLink:hover { text-decoration: underline; }
|
||||
.aliasRowList { display: flex; flex-direction: column; gap: 8px; }
|
||||
.aliasRow { display: flex; gap: 8px; align-items: center; }
|
||||
.aliasRow .aliasInput { flex: 1; }
|
||||
.aliasRowRemove {
|
||||
background: var(--bg-card); border: 1px solid var(--border); color: var(--danger);
|
||||
width: 32px; height: 32px; border-radius: 6px; cursor: pointer;
|
||||
font-size: 16px; line-height: 1; flex-shrink: 0;
|
||||
}
|
||||
.aliasRowRemove:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.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;
|
||||
outline: none; border-radius: 4px; padding: 2px 4px; margin: -2px -4px;
|
||||
}
|
||||
.rowSub {
|
||||
font-size: 12px; color: var(--text-muted); margin-top: 2px;
|
||||
outline: none; border-radius: 4px; padding: 2px 4px;
|
||||
}
|
||||
.rowTitle[contenteditable="true"]:hover,
|
||||
.rowSub[contenteditable="true"]:hover { background: rgba(255,255,255,0.04); }
|
||||
.rowTitle[contenteditable="true"]:focus,
|
||||
.rowSub[contenteditable="true"]:focus {
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
white-space: normal; cursor: text;
|
||||
}
|
||||
.rowTitle[contenteditable="true"]:empty::before,
|
||||
.rowSub[contenteditable="true"]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.rowDur { color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
/* 드래그 시스템: 원본 요소가 그대로 새 위치로 이동하면서 반투명 ghost 로 보임 */
|
||||
.dragGhost {
|
||||
opacity: 0.45;
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -2px;
|
||||
background: rgba(47, 129, 247, 0.08);
|
||||
}
|
||||
.dragGhost * { pointer-events: none !important; }
|
||||
.trackRow:active { cursor: grabbing; }
|
||||
.imageCard:active { cursor: grabbing; }
|
||||
|
||||
/* 사진 그리드 */
|
||||
.imageGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.imageCard {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
overflow: hidden; cursor: grab; user-select: none;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.imageCard .imgWrap {
|
||||
position: relative; aspect-ratio: 1 / 1; overflow: hidden;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.cardCaption {
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.cardTitle {
|
||||
font-size: 13px; color: var(--text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.cardSub {
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.cardTitle .muted { color: var(--text-muted); }
|
||||
|
||||
/* 컨텍스트 메뉴 */
|
||||
.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); }
|
||||
|
||||
/* 저장 안 됨 표시 (목록 편집기) */
|
||||
.dirtyMark {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--danger, #f85149);
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
align-self: flex-start;
|
||||
padding: 4px 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
147
src/installer-rp/ffmpeg.ts
Normal file
147
src/installer-rp/ffmpeg.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
|
||||
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
|
||||
|
||||
/**
|
||||
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용.
|
||||
* 경로: %appdata%/.mc_custom/ffmpeg.exe
|
||||
*/
|
||||
export function getFfmpegExePath(): string {
|
||||
return path.join(getMcCustomDir(), 'ffmpeg.exe')
|
||||
}
|
||||
|
||||
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
|
||||
const FFMPEG_ZIP_URL =
|
||||
'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip'
|
||||
|
||||
let installPromise: Promise<string> | null = null
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/ffmpeg.exe 가 없거나 실행 불가하면 BtbN 빌드 zip 에서
|
||||
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
|
||||
*/
|
||||
export async function ensureFfmpegExe(
|
||||
log?: (line: string) => void
|
||||
): Promise<string> {
|
||||
const target = getFfmpegExePath()
|
||||
if (await canExecute(target)) {
|
||||
log?.(t('log.ffmpegExists', { path: target }))
|
||||
return target
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
|
||||
installPromise = (async () => {
|
||||
const dir = getMcCustomDir()
|
||||
const zipPath = path.join(dir, '.tmp_ffmpeg.zip')
|
||||
const extractDir = path.join(dir, '.tmp_ffmpeg')
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
// 이전 시도의 임시 파일/폴더 정리
|
||||
await fs.rm(zipPath, { force: true })
|
||||
await fs.rm(extractDir, { recursive: true, force: true })
|
||||
|
||||
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
|
||||
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
|
||||
log?.(t('log.ffmpegExtracting'))
|
||||
await extractZip(zipPath, { dir: extractDir })
|
||||
|
||||
const found = await findFile(extractDir, 'ffmpeg.exe')
|
||||
if (!found) {
|
||||
throw new Error(t('errors.ffmpegNotInZip'))
|
||||
}
|
||||
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
|
||||
try {
|
||||
await fs.rename(found, target)
|
||||
} catch {
|
||||
await fs.copyFile(found, target)
|
||||
}
|
||||
|
||||
const ok = await probeVersion(target)
|
||||
if (!ok) throw new Error(t('errors.ffmpegVerifyFailed'))
|
||||
log?.(t('log.ffmpegReady', { path: target }))
|
||||
return target
|
||||
} catch (err) {
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
throw new Error(
|
||||
t('errors.ffmpegInstallFailed', {
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
// 임시 파일/폴더 정리
|
||||
await fs.rm(zipPath, { force: true }).catch(() => {})
|
||||
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {})
|
||||
installPromise = null
|
||||
}
|
||||
})()
|
||||
return installPromise
|
||||
}
|
||||
|
||||
async function canExecute(filePath: string): Promise<boolean> {
|
||||
try { await fs.access(filePath, fsConst.F_OK) } catch { return false }
|
||||
return probeVersion(filePath)
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, ['-version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let ok = false
|
||||
child.stdout.on('data', () => { ok = true })
|
||||
child.on('error', () => resolve(false))
|
||||
child.on('close', (code) => resolve(ok && code === 0))
|
||||
})
|
||||
}
|
||||
|
||||
async function findFile(root: string, name: string): Promise<string | null> {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
const full = path.join(root, e.name)
|
||||
if (e.isFile() && e.name.toLowerCase() === name.toLowerCase()) return full
|
||||
if (e.isDirectory()) {
|
||||
const inner = await findFile(full, name)
|
||||
if (inner) return inner
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** GitHub Releases latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
|
||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const lib = url.startsWith('https://') ? https : http
|
||||
const req = lib.get(url, {
|
||||
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
|
||||
}, (res) => {
|
||||
const code = res.statusCode || 0
|
||||
if (code >= 300 && code < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.resume()
|
||||
reject(new Error(`HTTP ${code} (${url})`))
|
||||
return
|
||||
}
|
||||
const out = createWriteStream(dest)
|
||||
res.pipe(out)
|
||||
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
|
||||
out.on('error', reject)
|
||||
res.on('error', reject)
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
112
src/installer-rp/images.ts
Normal file
112
src/installer-rp/images.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import { URL } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
|
||||
const MAX_SIDE = 1024
|
||||
|
||||
/** 유튜브 URL 에서 영상 ID 만 뽑아낸다. 못 찾으면 빈 문자열. */
|
||||
export function ytIdFromUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
if (u.hostname === 'youtu.be') return u.pathname.replace(/^\//, '')
|
||||
if (/youtube\.com$/i.test(u.hostname) || /^(www\.|m\.)?youtube\.com$/i.test(u.hostname)) {
|
||||
const v = u.searchParams.get('v')
|
||||
if (v) return v
|
||||
// shorts/<id>, embed/<id> 형태도 대응
|
||||
const m = u.pathname.match(/\/(?:shorts|embed)\/([^/]+)/)
|
||||
if (m) return m[1]
|
||||
}
|
||||
return ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 단순 HTTP/HTTPS GET (302 따라감, 4xx/5xx 는 reject). */
|
||||
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const target = new URL(url)
|
||||
const lib = target.protocol === 'https:' ? https : http
|
||||
const req = lib.get(target, {
|
||||
timeout: 30000,
|
||||
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
|
||||
}, (res) => {
|
||||
const code = res.statusCode || 0
|
||||
if (code >= 300 && code < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1)
|
||||
.then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.resume()
|
||||
reject(new Error(`HTTP ${code}`))
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (c: Buffer) => chunks.push(c))
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 URL 을 다운로드해 Buffer 로 돌려준다.
|
||||
* - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 →
|
||||
* 실패하면 `hqdefault.jpg` 로 폴백.
|
||||
* - 그 외 URL 은 HTTP GET 으로 그대로 받음.
|
||||
*/
|
||||
export async function downloadImage(rawUrl: string): Promise<Buffer> {
|
||||
const ytId = ytIdFromUrl(rawUrl)
|
||||
if (ytId) {
|
||||
try {
|
||||
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`)
|
||||
} catch {
|
||||
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`)
|
||||
}
|
||||
}
|
||||
return fetchBuffer(rawUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* painting variant 슬롯 규격(정사각 1:1, ≤1024×1024)에 맞춰 정규화.
|
||||
* 알고리즘 (docs/add.md):
|
||||
* 1) s = min(가로, 세로) → 가운데 정사각 크롭 (s×s)
|
||||
* 2) s > 1024 이면 1024×1024 로 축소 (Lanczos)
|
||||
* 3) s ≤ 1024 이면 그대로 (업스케일 없음)
|
||||
* 결과를 PNG 로 outPath 에 저장.
|
||||
*/
|
||||
export async function normalizeToCover(buffer: Buffer, outPath: string): Promise<void> {
|
||||
const img = sharp(buffer)
|
||||
const meta = await img.metadata()
|
||||
const w = meta.width ?? 0
|
||||
const h = meta.height ?? 0
|
||||
if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown'))
|
||||
const s = Math.min(w, h)
|
||||
const left = Math.floor((w - s) / 2)
|
||||
const top = Math.floor((h - s) / 2)
|
||||
let pipeline = img.extract({ left, top, width: s, height: s })
|
||||
if (s > MAX_SIDE) {
|
||||
pipeline = pipeline.resize(MAX_SIDE, MAX_SIDE, { kernel: 'lanczos3' })
|
||||
}
|
||||
await fs.mkdir(path.dirname(outPath), { recursive: true })
|
||||
await pipeline.png().toFile(outPath)
|
||||
}
|
||||
|
||||
/** cover_NN.png 파일명을 만든다 (NN 2자리 0패딩). */
|
||||
export function coverFileName(index: number): string {
|
||||
return `cover_${String(index).padStart(2, '0')}.png`
|
||||
}
|
||||
451
src/installer-rp/main.ts
Normal file
451
src/installer-rp/main.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import { URL } from 'node:url'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
|
||||
import { normalizePackDefinition } from '../shared/store.js'
|
||||
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
|
||||
import { loadEnv, getManifestUrl } from '../shared/env.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
import type { RpFetchedPack } from './types.js'
|
||||
import { ensureYtDlpExe } from './ytdlp.js'
|
||||
import { ensureFfmpegExe } from './ffmpeg.js'
|
||||
import { downloadMusicTrack } from './music.js'
|
||||
import { downloadImage, normalizeToCover, coverFileName } from './images.js'
|
||||
import { buildResourcepackZip } from './pack.js'
|
||||
|
||||
loadEnv()
|
||||
const i18n = loadComponentI18n('installer-rp')
|
||||
const t = i18n.t
|
||||
export const localeDict = i18n.dict
|
||||
|
||||
interface RpInstallerState {
|
||||
manifestUrl: string
|
||||
baseUrl: string
|
||||
packs: Map<string, RpFetchedPack>
|
||||
selectedKey: string | null
|
||||
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
|
||||
cancelRequested: boolean
|
||||
/** 현재 실행 중인 외부 프로세스들(yt-dlp/ffmpeg). 취소 시 모두 kill. */
|
||||
activeChildren: Set<ChildProcess>
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정.
|
||||
* - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시.
|
||||
* - 유튜브가 IP 단위로 throttle 걸기 때문에 5 이상은 효과 없음 → 상한 5.
|
||||
* - 환경변수 MUSIC_CONCURRENCY 로 강제 오버라이드 가능.
|
||||
*/
|
||||
function pickMusicConcurrency(): number {
|
||||
const override = Number(process.env.MUSIC_CONCURRENCY)
|
||||
if (Number.isFinite(override) && override >= 1) {
|
||||
return Math.min(8, Math.floor(override))
|
||||
}
|
||||
const cores = os.cpus()?.length ?? 4
|
||||
if (cores <= 2) return 2
|
||||
if (cores <= 4) return 3
|
||||
if (cores <= 8) return 4
|
||||
return 5
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 다운로드 시작 사이의 최소 간격(ms).
|
||||
* - 동시 N개를 모두 t=0 에 시작하면 카드들이 0% 에서 같이 정지된 듯 보임.
|
||||
* - 시차를 두고 시작하면 "1번 끝남 → 4번 시작 → 2번 끝남 → 5번 시작" 식으로
|
||||
* 유저 입장에서 항상 뭔가 새로 시작/완료되는 흐름이 보임.
|
||||
* - 너무 길면 동시성 이득을 깎아먹음. 2s 가 체감/속도 균형점.
|
||||
*/
|
||||
const MUSIC_START_STAGGER_MS = 2000
|
||||
|
||||
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
|
||||
let musicStartChain: Promise<void> = Promise.resolve()
|
||||
let nextMusicStartAt = 0
|
||||
function acquireMusicStartSlot(): Promise<void> {
|
||||
const slot = musicStartChain.then(async () => {
|
||||
const wait = Math.max(0, nextMusicStartAt - Date.now())
|
||||
if (wait > 0) await new Promise<void>((r) => setTimeout(r, wait))
|
||||
nextMusicStartAt = Date.now() + MUSIC_START_STAGGER_MS
|
||||
})
|
||||
musicStartChain = slot.catch(() => {})
|
||||
return slot
|
||||
}
|
||||
|
||||
const DEFAULT_MANIFEST_URL = getManifestUrl()
|
||||
|
||||
const state: RpInstallerState = {
|
||||
manifestUrl: DEFAULT_MANIFEST_URL,
|
||||
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
|
||||
packs: new Map(),
|
||||
selectedKey: null,
|
||||
cancelRequested: false,
|
||||
activeChildren: new Set()
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function deriveBaseUrl(manifestUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(manifestUrl)
|
||||
return `${parsed.protocol}//${parsed.host}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function createMainWindow(): void {
|
||||
// 메인 설치기와 동일한 아이콘 사용. dev/prod, Windows/기타 분기까지 같은 규칙.
|
||||
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 680,
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
mainWindow.removeMenu()
|
||||
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-rp', 'index.html'))
|
||||
}
|
||||
|
||||
function sendLog(line: string): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
|
||||
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)
|
||||
const transport = target.protocol === 'https:' ? https : http
|
||||
const request = transport.get(target, { timeout: 30000 }, (response) => {
|
||||
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||
const redirect = response.headers.location
|
||||
if (redirect) {
|
||||
response.resume()
|
||||
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
}
|
||||
if ((response.statusCode ?? 0) >= 400) {
|
||||
response.resume()
|
||||
reject(new Error(`HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
response.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.on('timeout', () => request.destroy(new Error(t('common.requestTimeout'))))
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const buffer = await fetchBuffer(url)
|
||||
return JSON.parse(buffer.toString('utf8')) as T
|
||||
}
|
||||
|
||||
// ── IPC: 1단계 manifest 로드 ─────────────────────────
|
||||
ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promise<RpFetchedPack[]> => {
|
||||
if (typeof manifestUrlInput === 'string' && manifestUrlInput.length > 0) {
|
||||
state.manifestUrl = manifestUrlInput
|
||||
state.baseUrl = deriveBaseUrl(manifestUrlInput)
|
||||
}
|
||||
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
|
||||
const manifest = await fetchJson<Manifest>(state.manifestUrl)
|
||||
const results: RpFetchedPack[] = []
|
||||
for (const entry of manifest.packs ?? []) {
|
||||
if (typeof entry?.file !== 'string') continue
|
||||
const listUrl = `${state.baseUrl}/file/list/${encodeURIComponent(entry.file)}.json`
|
||||
const packUrl = `${state.baseUrl}/manifest/${encodeURIComponent(entry.file)}.json`
|
||||
try {
|
||||
// 목록(필수) + 팩 정의(mcVersion 용, 실패해도 폴백) 동시 로드.
|
||||
const [listRaw, packRaw] = await Promise.all([
|
||||
fetchJson<Partial<PackList>>(listUrl),
|
||||
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
|
||||
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
|
||||
return null
|
||||
})
|
||||
])
|
||||
const list: PackList = {
|
||||
musicPlaylistUrl: typeof listRaw.musicPlaylistUrl === 'string' ? listRaw.musicPlaylistUrl : '',
|
||||
imagePlaylistUrl: typeof listRaw.imagePlaylistUrl === 'string' ? listRaw.imagePlaylistUrl : '',
|
||||
music: Array.isArray(listRaw.music) ? listRaw.music : [],
|
||||
images: Array.isArray(listRaw.images) ? listRaw.images : []
|
||||
}
|
||||
const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null
|
||||
const mcVersion = normalized?.mcVersion ?? ''
|
||||
const resourcepackPath = normalized?.resourcepackPath ?? ''
|
||||
results.push({
|
||||
key: entry.file,
|
||||
name: entry.name || entry.file,
|
||||
mcVersion,
|
||||
resourcepackPath,
|
||||
list
|
||||
})
|
||||
} catch (error) {
|
||||
sendLog(t('log.listLoadFailed', { file: entry.file, message: (error as Error).message }))
|
||||
}
|
||||
}
|
||||
state.packs.clear()
|
||||
for (const item of results) state.packs.set(item.key, item)
|
||||
sendLog(t('log.packsLoaded', { count: results.length }))
|
||||
for (const item of results) {
|
||||
sendLog(t('log.packEntry', {
|
||||
key: item.key,
|
||||
mc: item.mcVersion || t('log.packEntryUnknownVersion'),
|
||||
base: item.resourcepackPath || t('log.packEntryNoBase')
|
||||
}))
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
||||
if (!state.packs.has(packKey)) {
|
||||
throw new Error(t('errors.selectedPackNotFound'))
|
||||
}
|
||||
state.selectedKey = packKey
|
||||
sendLog(t('log.selectedPack', { key: packKey }))
|
||||
})
|
||||
|
||||
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||
|
||||
// ── IPC: 2단계 설치 ──────────────────────────────────
|
||||
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
||||
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
||||
const pack = state.packs.get(state.selectedKey)
|
||||
if (!pack) throw new Error(t('errors.currentPackNotFound'))
|
||||
state.cancelRequested = false
|
||||
|
||||
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(t('log.ytdlpPreparing'))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
|
||||
const ytDlpBin = await ensureYtDlpExe(sendLog)
|
||||
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
|
||||
throwIfCancelled()
|
||||
sendLog(t('log.ffmpegPreparing'))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
|
||||
const ffmpegBin = await ensureFfmpegExe(sendLog)
|
||||
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
|
||||
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
|
||||
const musicDir = path.join(tempRoot, 'music')
|
||||
await fsp.mkdir(musicDir, { recursive: true })
|
||||
const concurrency = pickMusicConcurrency()
|
||||
const cpuCount = os.cpus()?.length ?? 0
|
||||
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
|
||||
nextMusicStartAt = Date.now()
|
||||
sendLog(t('log.cpuDetected', { cores: cpuCount, concurrency }))
|
||||
sendLog(t('log.musicStart', { total: musicTotal, concurrency, stagger: MUSIC_START_STAGGER_MS }))
|
||||
|
||||
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
|
||||
const musicList = pack.list.music
|
||||
let nextIndex = 0
|
||||
async function musicWorker(): Promise<void> {
|
||||
while (true) {
|
||||
if (state.cancelRequested) return
|
||||
const i = nextIndex++
|
||||
if (i >= musicTotal) return
|
||||
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
|
||||
await acquireMusicStartSlot()
|
||||
if (state.cancelRequested) return
|
||||
const entry = musicList[i]
|
||||
const idx = i + 1
|
||||
sendLog(t('log.musicTrackStart', { idx }))
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
|
||||
let child: ChildProcess | null = null
|
||||
try {
|
||||
const outPath = await downloadMusicTrack({
|
||||
ytdlpExe: ytDlpBin,
|
||||
ffmpegExe: ffmpegBin,
|
||||
tempDir: musicDir,
|
||||
index: idx,
|
||||
url: entry.url,
|
||||
log: sendLog,
|
||||
onChild: (c) => {
|
||||
child = c
|
||||
state.activeChildren.add(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'
|
||||
})
|
||||
}
|
||||
})
|
||||
if (child) state.activeChildren.delete(child)
|
||||
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
|
||||
} catch (err) {
|
||||
if (child) state.activeChildren.delete(child)
|
||||
if (state.cancelRequested) {
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
|
||||
return
|
||||
}
|
||||
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(concurrency, musicTotal)
|
||||
const workers: Promise<void>[] = []
|
||||
for (let w = 0; w < workerCount; w++) workers.push(musicWorker())
|
||||
await Promise.all(workers)
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-3. 사진 다운로드 + painting variant 정규화
|
||||
const paintingDir = path.join(tempRoot, 'painting')
|
||||
await fsp.mkdir(paintingDir, { recursive: true })
|
||||
sendLog(t('log.imageStart', { total: imageTotal }))
|
||||
for (let i = 0; i < imageTotal; i++) {
|
||||
throwIfCancelled()
|
||||
const entry = pack.list.images[i]
|
||||
const idx = i + 1
|
||||
sendLog(t('log.imageDownloading', { 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) {
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
throwIfCancelled()
|
||||
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) {
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
|
||||
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
|
||||
}
|
||||
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
|
||||
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
|
||||
}
|
||||
|
||||
// 2-4. 베이스 리소스팩 다운로드 (있을 때만)
|
||||
throwIfCancelled()
|
||||
let baseZipPath: string | undefined
|
||||
if (pack.resourcepackPath) {
|
||||
// 파일명에 공백·괄호가 있을 수 있어 encodeURIComponent 로 인코딩.
|
||||
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
|
||||
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
|
||||
baseZipPath = path.join(tempRoot, 'base.zip')
|
||||
sendLog(t('log.baseDownload', { path: cleaned }))
|
||||
sendLog(t('log.baseUrl', { url: baseUrl }))
|
||||
sendProgress({ phase: 'package', message: t('progress.baseDownloading') })
|
||||
try {
|
||||
const buf = await fetchBuffer(baseUrl)
|
||||
await fsp.writeFile(baseZipPath, buf)
|
||||
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
|
||||
} catch (err) {
|
||||
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
|
||||
}
|
||||
} else {
|
||||
sendLog(t('log.baseAbsent'))
|
||||
}
|
||||
|
||||
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
|
||||
throwIfCancelled()
|
||||
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
|
||||
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
|
||||
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
|
||||
sendLog(t('log.buildingZip', { name: resourcepackName }))
|
||||
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
|
||||
await buildResourcepackZip({
|
||||
musicDir,
|
||||
paintingDir,
|
||||
packName: pack.name,
|
||||
mcVersion: pack.mcVersion,
|
||||
workDir: tempRoot,
|
||||
outZipPath: resourcepackPath,
|
||||
baseZipPath,
|
||||
log: sendLog
|
||||
})
|
||||
|
||||
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||
sendLog(t('log.installComplete', { path: resourcepackPath }))
|
||||
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
|
||||
return { resourcepackPath }
|
||||
} finally {
|
||||
// 임시 파일 정리
|
||||
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('rp:install:cancel', async () => {
|
||||
state.cancelRequested = true
|
||||
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
|
||||
for (const child of state.activeChildren) {
|
||||
if (!child.killed) child.kill()
|
||||
}
|
||||
})
|
||||
|
||||
function throwIfCancelled(): void {
|
||||
if (state.cancelRequested) {
|
||||
throw new Error(t('errors.cancelledByUser'))
|
||||
}
|
||||
}
|
||||
|
||||
// ── IPC: 3단계 완료 ──────────────────────────────────
|
||||
ipcMain.handle('rp:finish:openFolder', async () => {
|
||||
const dir = path.join(getMcCustomDir(), 'resourcepacks')
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
}
|
||||
await shell.openPath(dir)
|
||||
})
|
||||
|
||||
ipcMain.handle('rp:quit', async () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
// ── 앱 라이프사이클 ───────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
createMainWindow()
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// 강제 종료 시에도 임시 파일은 정리.
|
||||
fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
103
src/installer-rp/music.ts
Normal file
103
src/installer-rp/music.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
export interface DownloadMusicOptions {
|
||||
ytdlpExe: string
|
||||
ffmpegExe: string
|
||||
/** %appdata%/.mc_custom/.temp/ 같은 작업 폴더. */
|
||||
tempDir: string
|
||||
/** 1부터 시작하는 곡 번호 (파일명 zero-pad 에 사용). */
|
||||
index: number
|
||||
/** 유튜브 영상 주소. */
|
||||
url: string
|
||||
log?: (line: string) => void
|
||||
/** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */
|
||||
onChild?: (child: ChildProcess) => void
|
||||
/** yt-dlp 의 다운로드 퍼센트 (0~100). 변환 단계는 별도. */
|
||||
onProgress?: (percent: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* yt-dlp 로 유튜브 영상에서 오디오만 추출해 vorbis(.ogg) 로 변환한다.
|
||||
* 결과 파일 경로를 돌려준다. 실패하면 reject.
|
||||
*
|
||||
* 호출자는 onChild 콜백으로 받은 ChildProcess 에 .kill() 을 호출해 취소할 수 있다.
|
||||
*/
|
||||
export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string> {
|
||||
const padded = String(opts.index).padStart(2, '0')
|
||||
const outBase = path.join(opts.tempDir, padded)
|
||||
const outPath = outBase + '.ogg'
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'--no-warnings',
|
||||
'--no-playlist',
|
||||
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
|
||||
// 일반 progressive 다운로드에는 영향 없음.
|
||||
'--concurrent-fragments', '5',
|
||||
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
|
||||
'--newline',
|
||||
'--extract-audio',
|
||||
'--audio-format', 'vorbis',
|
||||
'--audio-quality', '0',
|
||||
'--ffmpeg-location', opts.ffmpegExe,
|
||||
'-o', outBase + '.%(ext)s',
|
||||
opts.url
|
||||
]
|
||||
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) => {
|
||||
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?.(t('log.ytdlpLine', { 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')
|
||||
})
|
||||
child.on('error', (err) => reject(err))
|
||||
child.on('close', async (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
|
||||
return
|
||||
}
|
||||
if (code !== 0) {
|
||||
reject(new Error(
|
||||
t('errors.ytdlpExit', {
|
||||
code: code ?? '',
|
||||
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
|
||||
})
|
||||
))
|
||||
return
|
||||
}
|
||||
// .ogg 가 실제로 생성됐는지 확인
|
||||
try {
|
||||
await fs.access(outPath)
|
||||
resolve(outPath)
|
||||
} catch {
|
||||
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
171
src/installer-rp/pack.ts
Normal file
171
src/installer-rp/pack.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { promises as fs, createWriteStream } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import archiver from 'archiver'
|
||||
import extract from 'extract-zip'
|
||||
import { resolveResourcePackFormat, MIN_SUPPORTED_FORMAT, LATEST_KNOWN_FORMAT } from './packFormat.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
const NAMESPACE = 'musicquiz'
|
||||
|
||||
export interface BuildResourcepackOptions {
|
||||
/** ogg 음악 파일들이 들어 있는 폴더 (01.ogg, 02.ogg, …). */
|
||||
musicDir: string
|
||||
/** cover_NN.png 파일들이 들어 있는 폴더. */
|
||||
paintingDir: string
|
||||
/** pack.mcmeta 의 description 에 들어갈 표시 이름. */
|
||||
packName: string
|
||||
/** /manifest/<key>.json 의 mcVersion. pack_format 결정용. */
|
||||
mcVersion: string
|
||||
/** 작업 폴더(임시). 이 안에 트리를 펼친 뒤 zip 생성. */
|
||||
workDir: string
|
||||
/** 최종 zip 출력 경로. */
|
||||
outZipPath: string
|
||||
/**
|
||||
* 베이스 리소스팩 zip 경로 (선택). 지정하면 이 zip 의 내용을 먼저 풀고
|
||||
* 그 위에 음악·사진·sounds.json·pack.mcmeta 를 덮어/병합한다.
|
||||
*/
|
||||
baseZipPath?: string
|
||||
/** 진단용 로그 콜백 (선택). */
|
||||
log?: (line: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 폴더에 리소스팩 트리를 펼치고, archiver 로 zip 으로 묶어 outZipPath 에 저장.
|
||||
*
|
||||
* 트리 구조:
|
||||
* pack.mcmeta
|
||||
* assets/musicquiz/sounds.json
|
||||
* assets/musicquiz/sounds/track_NN.ogg ← musicDir/NN.ogg 에서 옮김
|
||||
* assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김
|
||||
*/
|
||||
export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> {
|
||||
const root = path.join(opts.workDir, 'resourcepack')
|
||||
// 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다.
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
|
||||
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
|
||||
if (opts.baseZipPath) {
|
||||
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
|
||||
await extract(opts.baseZipPath, { dir: root })
|
||||
}
|
||||
|
||||
const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds')
|
||||
const paintingOutDir = path.join(root, 'assets', NAMESPACE, 'textures', 'painting')
|
||||
await fs.mkdir(soundsDir, { recursive: true })
|
||||
await fs.mkdir(paintingOutDir, { recursive: true })
|
||||
|
||||
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
|
||||
const resolved = resolveResourcePackFormat(opts.mcVersion)
|
||||
if (resolved.matched) {
|
||||
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
|
||||
} else {
|
||||
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
|
||||
}
|
||||
|
||||
// 호환 범위는 1.21.6 (=MIN_SUPPORTED_FORMAT) 부터 알려진 최신까지 선언한다.
|
||||
// 빌드 타깃이 LATEST_KNOWN_FORMAT 보다 높으면(테이블 갱신 전 신버전) 그 값까지 확장.
|
||||
// (셰이더 제거 판정에도 maxFmt 를 쓰므로 mcmeta 작성보다 먼저 계산해 둔다.)
|
||||
const minFmt = Math.min(MIN_SUPPORTED_FORMAT, resolved.format)
|
||||
const maxFmt = Math.max(LATEST_KNOWN_FORMAT, resolved.format)
|
||||
|
||||
// 1-a) 선언 호환 범위의 max 가 64 를 넘으면(=1.21.9+ 클라이언트에서도 로드 가능)
|
||||
// 구버전 베이스팩의 assets/minecraft/shaders/* 가 새 GLSL API 와 충돌해 컴파일에
|
||||
// 실패한다. 결과적으로 "리소스 새로고침 실패" 가 다시 뜨므로, 이 경우엔 해당
|
||||
// 디렉터리를 결과 zip 에서 제거한다. 텍스처/모델 등 나머지 자산은 그대로 유지.
|
||||
if (opts.baseZipPath && maxFmt > 64) {
|
||||
const vanillaShaderDir = path.join(root, 'assets', 'minecraft', 'shaders')
|
||||
try {
|
||||
const stat = await fs.stat(vanillaShaderDir)
|
||||
if (stat.isDirectory()) {
|
||||
const entries = await fs.readdir(vanillaShaderDir)
|
||||
if (entries.length > 0) {
|
||||
await fs.rm(vanillaShaderDir, { recursive: true, force: true })
|
||||
opts.log?.(t('log.baseShaderOverrideStripped', {
|
||||
path: entries.join(', '),
|
||||
mc: opts.mcVersion,
|
||||
format: maxFmt
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 없으면 정상. 무시.
|
||||
}
|
||||
}
|
||||
// pack_format <= 64 인 MC 는 supported_formats 를, > 64 인 MC 는 min_format/max_format 을
|
||||
// 읽는다. 어느 한쪽만 두면 반대편 클라이언트에서 거부되므로 양쪽 모두 기록한다.
|
||||
const packMeta: Record<string, unknown> = {
|
||||
description: t('pack.description', { name: opts.packName }),
|
||||
pack_format: resolved.format,
|
||||
supported_formats: { min_inclusive: minFmt, max_inclusive: maxFmt },
|
||||
min_format: minFmt,
|
||||
max_format: maxFmt
|
||||
}
|
||||
const mcmeta = { pack: packMeta }
|
||||
await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n')
|
||||
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
|
||||
|
||||
// 2) 음악 파일 복사 + sounds.json 생성/병합
|
||||
const musicFiles = (await fs.readdir(opts.musicDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.ogg'))
|
||||
.sort()
|
||||
// 베이스의 sounds.json 이 있으면 읽어서 우리 트랙을 덧붙인다.
|
||||
const soundsJsonPath = path.join(root, 'assets', NAMESPACE, 'sounds.json')
|
||||
let soundsJson: Record<string, unknown> = {}
|
||||
try {
|
||||
const existing = await fs.readFile(soundsJsonPath, 'utf8')
|
||||
const parsed = JSON.parse(existing)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
soundsJson = parsed as Record<string, unknown>
|
||||
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
|
||||
}
|
||||
} catch {
|
||||
// 없으면 새로 생성.
|
||||
}
|
||||
for (const fname of musicFiles) {
|
||||
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
||||
const stem = path.basename(fname, path.extname(fname)) // "01"
|
||||
const trackId = `track_${stem}`
|
||||
await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`))
|
||||
soundsJson[trackId] = {
|
||||
sounds: [
|
||||
{ name: `${NAMESPACE}:${trackId}`, stream: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
|
||||
|
||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
|
||||
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.png'))
|
||||
.sort()
|
||||
for (const fname of paintingFiles) {
|
||||
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
|
||||
}
|
||||
|
||||
// 4) zip 으로 묶기
|
||||
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
|
||||
await zipDirectory(root, opts.outZipPath)
|
||||
|
||||
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
|
||||
}
|
||||
|
||||
function zipDirectory(srcDir: string, outZipPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(outZipPath)
|
||||
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||
output.on('close', () => resolve())
|
||||
output.on('error', reject)
|
||||
archive.on('warning', (err: Error & { code?: string }) => {
|
||||
// ENOENT 정도면 무시, 그 외는 reject.
|
||||
if (err.code === 'ENOENT') return
|
||||
reject(err)
|
||||
})
|
||||
archive.on('error', reject)
|
||||
archive.pipe(output)
|
||||
archive.directory(srcDir, false)
|
||||
archive.finalize().catch(reject)
|
||||
})
|
||||
}
|
||||
50
src/installer-rp/packFormat.ts
Normal file
50
src/installer-rp/packFormat.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Minecraft Java Edition 버전 → resource pack format 번호.
|
||||
// 출처: https://minecraft.wiki/w/Pack_format (수동 동기화).
|
||||
// 1.21.9 부터는 minor 버전(예: 69.0)이 도입됐지만 JSON Number 로 0 차이는
|
||||
// 표현되지 않으므로 정수만 사용한다.
|
||||
const TABLE: Array<readonly [string, number]> = [
|
||||
['1.21', 34],
|
||||
['1.21.1', 34],
|
||||
['1.21.2', 42],
|
||||
['1.21.3', 42],
|
||||
['1.21.4', 46],
|
||||
['1.21.5', 55],
|
||||
['1.21.6', 63],
|
||||
['1.21.7', 64],
|
||||
['1.21.8', 64],
|
||||
['1.21.9', 69],
|
||||
['1.21.10', 69],
|
||||
['1.21.11', 75],
|
||||
['26.1', 84],
|
||||
['26.1.1', 84],
|
||||
['26.1.2', 84],
|
||||
['26.2', 86]
|
||||
]
|
||||
|
||||
/** 테이블에서 마지막(=최신) 항목의 포맷. 알 수 없는 mcVersion 에 대한 폴백. */
|
||||
export const LATEST_KNOWN_FORMAT: number = TABLE[TABLE.length - 1][1]
|
||||
|
||||
/**
|
||||
* 리소스팩이 호환된다고 선언할 최소 pack_format.
|
||||
* 1.21.6 (=63) 부터를 지원 범위 하한으로 둔다.
|
||||
*/
|
||||
export const MIN_SUPPORTED_FORMAT = 63
|
||||
|
||||
export interface ResolvedFormat {
|
||||
/** 매칭된 mcVersion 키 (없으면 null). */
|
||||
matched: string | null
|
||||
/** pack.mcmeta 에 들어갈 pack_format 값. */
|
||||
format: number
|
||||
}
|
||||
|
||||
/**
|
||||
* mcVersion 문자열 ("1.21.6", "26.1.2", …) 에서 pack_format 을 찾는다.
|
||||
* 정확히 일치하는 게 있으면 그 값, 없으면 가장 최근 알려진 포맷을 폴백.
|
||||
*/
|
||||
export function resolveResourcePackFormat(mcVersion: string): ResolvedFormat {
|
||||
const key = (mcVersion || '').trim()
|
||||
for (const [v, f] of TABLE) {
|
||||
if (v === key) return { matched: v, format: f }
|
||||
}
|
||||
return { matched: null, format: LATEST_KNOWN_FORMAT }
|
||||
}
|
||||
49
src/installer-rp/preload.ts
Normal file
49
src/installer-rp/preload.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { RpFetchedPack } from './types.js'
|
||||
|
||||
const api = {
|
||||
/** i18n 사전을 렌더러에 전달. */
|
||||
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('rp:i18n:dict'),
|
||||
|
||||
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
|
||||
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
|
||||
ipcRenderer.invoke('rp:packs:load', manifestUrl),
|
||||
/** 음악퀴즈 키를 선택. */
|
||||
selectPack: (packKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke('rp:packs:select', packKey),
|
||||
|
||||
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
|
||||
startInstall: (): Promise<{ resourcepackPath: string }> =>
|
||||
ipcRenderer.invoke('rp:install:start'),
|
||||
/** 진행 중인 설치 취소. 임시 파일 정리 후 종료. */
|
||||
cancelInstall: (): Promise<void> =>
|
||||
ipcRenderer.invoke('rp:install:cancel'),
|
||||
|
||||
/** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
|
||||
openResourcepackFolder: (): Promise<void> =>
|
||||
ipcRenderer.invoke('rp:finish:openFolder'),
|
||||
/** 프로그램 종료. */
|
||||
quit: (): Promise<void> => ipcRenderer.invoke('rp:quit'),
|
||||
|
||||
/** 로그 스트림 구독. */
|
||||
onLog: (handler: (line: string) => void): (() => void) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('rpInstaller', api)
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rpInstaller: typeof api
|
||||
}
|
||||
}
|
||||
22
src/installer-rp/types.ts
Normal file
22
src/installer-rp/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { PackList } from '../shared/types.js'
|
||||
|
||||
export interface RpFetchedPack {
|
||||
key: string
|
||||
name: string
|
||||
/** /manifest/<key>.json 의 mcVersion (예: "1.21.6", "26.1.2"). */
|
||||
mcVersion: string
|
||||
/**
|
||||
* /manifest/<key>.json 의 resourcepackPath. 비어있지 않으면 베이스 zip 으로 사용.
|
||||
* 빈 문자열이면 새 리소스팩을 처음부터 생성.
|
||||
*/
|
||||
resourcepackPath: string
|
||||
/** /file/list/<key>.json 의 음악·사진 목록. */
|
||||
list: PackList
|
||||
}
|
||||
|
||||
export interface RpInstallProgress {
|
||||
step: 'yt-dlp' | 'music' | 'image' | 'package' | 'place'
|
||||
index?: number
|
||||
total?: number
|
||||
message?: string
|
||||
}
|
||||
113
src/installer-rp/ytdlp.ts
Normal file
113
src/installer-rp/ytdlp.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
/**
|
||||
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
|
||||
* 경로: %appdata%/.mc_custom/yt-dlp.exe
|
||||
*/
|
||||
export function getYtDlpExePath(): string {
|
||||
return path.join(getMcCustomDir(), 'yt-dlp.exe')
|
||||
}
|
||||
|
||||
const YT_DLP_DOWNLOAD_URL =
|
||||
'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe'
|
||||
|
||||
let installPromise: Promise<string> | null = null
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/yt-dlp.exe 가 없거나 실행 불가능하면 GitHub Releases
|
||||
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
|
||||
*/
|
||||
export async function ensureYtDlpExe(
|
||||
log?: (line: string) => void
|
||||
): Promise<string> {
|
||||
const target = getYtDlpExePath()
|
||||
if (await canExecute(target)) {
|
||||
log?.(t('log.ytdlpExists', { path: target }))
|
||||
return target
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
|
||||
installPromise = (async () => {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
log?.(t('log.ytdlpDownloading', { url: YT_DLP_DOWNLOAD_URL }))
|
||||
await downloadToFile(YT_DLP_DOWNLOAD_URL, target)
|
||||
const okVersion = await probeVersion(target)
|
||||
if (!okVersion) {
|
||||
throw new Error(t('errors.ytdlpVerifyFailed'))
|
||||
}
|
||||
log?.(t('log.ytdlpReady', { path: target }))
|
||||
return target
|
||||
} catch (err) {
|
||||
// 부분 다운로드 흔적 정리
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
throw new Error(
|
||||
t('errors.ytdlpInstallFailed', {
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
installPromise = null
|
||||
}
|
||||
})()
|
||||
return installPromise
|
||||
}
|
||||
|
||||
async function canExecute(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.F_OK)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return probeVersion(filePath)
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let ok = false
|
||||
child.stdout.on('data', () => { ok = true })
|
||||
child.on('error', () => resolve(false))
|
||||
child.on('close', (code) => resolve(ok && code === 0))
|
||||
})
|
||||
}
|
||||
|
||||
/** GitHub Releases 의 latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
|
||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error(t('common.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const lib = url.startsWith('https://') ? https : http
|
||||
const req = lib.get(url, {
|
||||
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
|
||||
}, (res) => {
|
||||
const code = res.statusCode || 0
|
||||
if (code >= 300 && code < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.resume()
|
||||
reject(new Error(`HTTP ${code} (${url})`))
|
||||
return
|
||||
}
|
||||
const out = createWriteStream(dest)
|
||||
res.pipe(out)
|
||||
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
|
||||
out.on('error', reject)
|
||||
res.on('error', reject)
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types'
|
||||
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types.js'
|
||||
|
||||
const api = {
|
||||
// i18n
|
||||
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('i18n:dict'),
|
||||
|
||||
// 1단계
|
||||
loadPacks: (manifestUrl?: string): Promise<FetchedPack[]> =>
|
||||
ipcRenderer.invoke('packs:load', manifestUrl),
|
||||
@@ -15,6 +18,8 @@ const api = {
|
||||
|
||||
// 3-2
|
||||
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'),
|
||||
installJdk: (): Promise<{ ok: boolean; path?: string; message?: string }> => ipcRenderer.invoke('jdk:install'),
|
||||
cancelJdkInstall: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('jdk:cancelInstall'),
|
||||
|
||||
// 3-3
|
||||
startServerInstall: (payload: ServerInstallPayload): Promise<void> =>
|
||||
@@ -45,6 +50,7 @@ const api = {
|
||||
createDesktopShortcut: (): Promise<void> => ipcRenderer.invoke('finish:desktopShortcut'),
|
||||
startServer: (): Promise<void> => ipcRenderer.invoke('finish:startServer'),
|
||||
startMinecraftLauncher: (): Promise<void> => ipcRenderer.invoke('finish:startLauncher'),
|
||||
quitApp: (): Promise<void> => ipcRenderer.invoke('app:quit'),
|
||||
|
||||
// log stream
|
||||
onLog: (handler: (line: string) => void): (() => void) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Manifest, PackDefinition } from '../shared/types'
|
||||
import type { Manifest, PackDefinition } from '../shared/types.js'
|
||||
|
||||
export interface InstallerConfig {
|
||||
manifestUrl: string
|
||||
@@ -25,6 +25,8 @@ export interface ServerInstallPayload {
|
||||
export interface ClientInstallPayload {
|
||||
packKey: string
|
||||
installPlatform: boolean
|
||||
/** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */
|
||||
skipMap?: boolean
|
||||
}
|
||||
|
||||
export interface RamCheckResult {
|
||||
|
||||
@@ -2,9 +2,13 @@ import express from 'express'
|
||||
import session from 'express-session'
|
||||
import path from 'node:path'
|
||||
import fsp from 'node:fs/promises'
|
||||
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
|
||||
import { indexRouter } from './routes/index'
|
||||
import { opRouter } from './routes/op'
|
||||
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
|
||||
import { loadEnv } from '../shared/env.js'
|
||||
import { t, localeDict } from './i18n.js'
|
||||
import { indexRouter } from './routes/index.js'
|
||||
import { opRouter } from './routes/op.js'
|
||||
|
||||
loadEnv()
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000)
|
||||
// 터미널에서 Ctrl+클릭으로 바로 열 수 있도록 기본값은 127.0.0.1.
|
||||
@@ -20,6 +24,14 @@ app.set('trust proxy', 1)
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
app.use(express.json())
|
||||
|
||||
// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
|
||||
// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
|
||||
app.use((_req, res, next) => {
|
||||
res.locals.t = t
|
||||
res.locals.localeDict = localeDict
|
||||
next()
|
||||
})
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
||||
resave: false,
|
||||
@@ -101,8 +113,8 @@ app.use('/', opRouter)
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err)
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
res.status(500).send(`서버 오류: ${message}`)
|
||||
const message = err instanceof Error ? err.message : t('errors.unknown')
|
||||
res.status(500).send(t('errors.serverError', { message }))
|
||||
})
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
|
||||
45
src/server/datapack.ts
Normal file
45
src/server/datapack.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { MusicListEntry, PackList } from '../shared/types.js'
|
||||
|
||||
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */
|
||||
function escapeSnbtString(input: string): string {
|
||||
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
|
||||
function aliasListSnbt(aliases: string[]): string {
|
||||
if (!Array.isArray(aliases) || aliases.length === 0) return '[]'
|
||||
const parts = aliases.map((a) => `"${escapeSnbtString(a)}"`)
|
||||
return `[${parts.join(',')}]`
|
||||
}
|
||||
|
||||
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */
|
||||
function entrySnbt(entry: MusicListEntry): string {
|
||||
const title = escapeSnbtString(entry.title ?? '')
|
||||
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
|
||||
const author = escapeSnbtString(entry.artist ?? '')
|
||||
const alias = aliasListSnbt(entry.aliases ?? [])
|
||||
return `{title:"${title}", author:"${author}", alias:${alias}}`
|
||||
}
|
||||
|
||||
/**
|
||||
* list.music 으로부터 `data/mq/function/init/songs.mcfunction` 본문을 생성.
|
||||
* 운영자는 mc_datapack 의 music_quiz 데이터팩에서 이 파일만 이 내용으로
|
||||
* 덮어쓰면 된다 — 나머지 파일은 launcher 가 관여하지 않는다.
|
||||
*/
|
||||
export function buildSongsMcfunction(list: PackList): string {
|
||||
const lines: string[] = []
|
||||
lines.push('# 곡 한 개 = 한 줄.')
|
||||
lines.push('# 필수 — title, author, alias')
|
||||
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
|
||||
lines.push('# 의 audio.volume 사용)')
|
||||
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
|
||||
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}')
|
||||
lines.push('data modify storage mq:main songs set value []')
|
||||
for (const entry of list.music) {
|
||||
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('# 곡 개수는 songs 배열 길이에서 자동 계산됨')
|
||||
lines.push('execute store result storage mq:main max_index int 1 run data get storage mq:main songs')
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
6
src/server/i18n.ts
Normal file
6
src/server/i18n.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
// 서버 진입 시 한 번 로드. routes/views 어디서든 동일한 사전을 공유.
|
||||
const i18n = loadComponentI18n('server')
|
||||
export const t = i18n.t
|
||||
export const localeDict = i18n.dict
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import { listPackKeys, loadPackDefinition, readManifest } from '../../shared/store'
|
||||
import { listPackKeys, loadPackDefinition, readManifest } from '../../shared/store.js'
|
||||
|
||||
export const indexRouter = Router()
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { Router } from 'express'
|
||||
import archiver from 'archiver'
|
||||
import {
|
||||
createPack,
|
||||
deletePackKeys,
|
||||
listPackKeys,
|
||||
loadPackDefinition,
|
||||
loadPackList,
|
||||
normalizePackDefinition,
|
||||
normalizePackList,
|
||||
readAccounts,
|
||||
renamePack,
|
||||
sanitizePackKey
|
||||
} from '../../shared/store'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { PackDefinition } from '../../shared/types'
|
||||
sanitizePackKey,
|
||||
savePackList
|
||||
} from '../../shared/store.js'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
import type { PackDefinition, PackList } from '../../shared/types.js'
|
||||
import { t } from '../i18n.js'
|
||||
import { buildSongsMcfunction } from '../datapack.js'
|
||||
|
||||
export const opRouter = Router()
|
||||
|
||||
@@ -42,7 +49,7 @@ opRouter.post('/op', async (req, res, next) => {
|
||||
const accounts = await readAccounts()
|
||||
const matched = accounts.find((entry) => entry.password === password)
|
||||
if (!matched) {
|
||||
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
|
||||
res.status(401).render('op/login', { error: t('login.wrongPassword') })
|
||||
return
|
||||
}
|
||||
req.session.userId = matched.id
|
||||
@@ -102,7 +109,7 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
const definition = await loadPackDefinition(packKey)
|
||||
if (!definition) {
|
||||
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
|
||||
res.status(404).send(t('errors.packNotFound'))
|
||||
return
|
||||
}
|
||||
const releases = await fetchReleaseVersions()
|
||||
@@ -117,6 +124,177 @@ 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(t('errors.packNotFound'))
|
||||
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: t('errors.packNotFoundJson') })
|
||||
return
|
||||
}
|
||||
const normalized = normalizePackList(req.body)
|
||||
await savePackList(packKey, normalized)
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 단일 영상 메타데이터 조회 (음악 항목 수정에서 URL 변경 시 자동 갱신용).
|
||||
// body: { url: string }
|
||||
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
|
||||
const url = pickFirstValue(req.body?.url).trim()
|
||||
if (!url) {
|
||||
res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const entry = await fetchVideoMeta(url)
|
||||
if (!entry) {
|
||||
res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
|
||||
return
|
||||
}
|
||||
res.json({ ok: true, entry })
|
||||
} 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 })
|
||||
}
|
||||
})
|
||||
|
||||
// 플레이리스트 주소를 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: t('errors.playlistUrlRequired') })
|
||||
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) => {
|
||||
const definition = await loadPackDefinition(key)
|
||||
const list = await loadPackList(key)
|
||||
return { key, definition, musicCount: list.music.length }
|
||||
}))
|
||||
res.render('op/datapack', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 데이터팩 출력: list.music 으로부터 init/songs.mcfunction 본문만 만들어
|
||||
// text/plain 으로 반환한다. 운영자가 mc_datapack 의 해당 파일에 붙여넣는다.
|
||||
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(t('errors.packNotFoundJson'))
|
||||
return
|
||||
}
|
||||
const list = await loadPackList(packKey)
|
||||
res.type('text/plain; charset=utf-8').send(buildSongsMcfunction(list))
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 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) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
@@ -124,14 +302,16 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
|
||||
const platformType = pickFirstValue(req.body.platformType)
|
||||
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
|
||||
const platformLoaderVersion = pickFirstValue(req.body.platformLoaderVersion).trim()
|
||||
|
||||
const partial: Partial<PackDefinition> & Record<string, unknown> = {
|
||||
name: pickFirstValue(req.body.displayName),
|
||||
mcVersion: pickFirstValue(req.body.mcVersion),
|
||||
platform: {
|
||||
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
|
||||
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
|
||||
},
|
||||
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined,
|
||||
loaderVersion: platformLoaderVersion.length > 0 ? platformLoaderVersion : undefined
|
||||
} as PackDefinition['platform'] & { loaderVersion?: string },
|
||||
modsFolder: pickFirstValue(req.body.modsFolder),
|
||||
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
|
||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||
@@ -144,7 +324,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
||||
|
||||
const normalized = normalizePackDefinition(partial)
|
||||
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
|
||||
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
|
||||
res.status(400).send(t('errors.ramOrderInvalid'))
|
||||
return
|
||||
}
|
||||
const finalKey = await renamePack(packKey, requestedKey, normalized)
|
||||
|
||||
240
src/server/youtube.ts
Normal file
240
src/server/youtube.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import https from 'node:https'
|
||||
import http from 'node:http'
|
||||
import { getMcCustomDir } from '../shared/paths.js'
|
||||
import { t } from './i18n.js'
|
||||
|
||||
export interface YtPlaylistEntry {
|
||||
id: string
|
||||
title: string
|
||||
channel: string
|
||||
durationSec: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export class YtDlpUnavailableError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message || t('youtube.ytdlpUnavailable'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 현재 OS/아키텍처에서 GitHub Releases 가 제공하는 yt-dlp 파일 이름. */
|
||||
function getYtDlpAssetName(): string {
|
||||
if (process.platform === 'win32') return 'yt-dlp.exe'
|
||||
if (process.platform === 'darwin') return 'yt-dlp_macos'
|
||||
if (process.platform === 'linux') {
|
||||
if (process.arch === 'arm64') return 'yt-dlp_linux_aarch64'
|
||||
if (process.arch === 'arm') return 'yt-dlp_linux_armv7l'
|
||||
return 'yt-dlp_linux'
|
||||
}
|
||||
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
|
||||
}
|
||||
|
||||
/** 로컬 설치 경로: %appdata%/.mc_custom/<asset> */
|
||||
export function getYtDlpInstallPath(): string {
|
||||
return path.join(getMcCustomDir(), getYtDlpAssetName())
|
||||
}
|
||||
|
||||
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
|
||||
let installPromise: Promise<string> | null = null
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
|
||||
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
|
||||
*/
|
||||
export async function ensureYtDlp(): Promise<string> {
|
||||
const target = getYtDlpInstallPath()
|
||||
// 이미 설치돼 있고 실행 가능하면 그대로 사용
|
||||
if (await canExecute(target)) return target
|
||||
if (installPromise) return installPromise
|
||||
installPromise = (async () => {
|
||||
try {
|
||||
const dir = getMcCustomDir()
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const asset = getYtDlpAssetName()
|
||||
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
|
||||
await downloadToFile(url, target)
|
||||
// POSIX 계열은 실행 권한 부여
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(target, 0o755)
|
||||
}
|
||||
// 검증
|
||||
const okVersion = await probeVersion(target)
|
||||
if (!okVersion) {
|
||||
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
|
||||
}
|
||||
return target
|
||||
} catch (err) {
|
||||
// 실패 흔적(부분 다운로드) 삭제
|
||||
try { await fs.unlink(target) } catch { /* noop */ }
|
||||
throw err instanceof YtDlpUnavailableError
|
||||
? err
|
||||
: new YtDlpUnavailableError(
|
||||
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
|
||||
)
|
||||
} finally {
|
||||
installPromise = null
|
||||
}
|
||||
})()
|
||||
return installPromise
|
||||
}
|
||||
|
||||
async function canExecute(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.F_OK)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
// POSIX 면 X 비트도 확인
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await fs.access(filePath, fsConst.X_OK)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 실제로 --version 으로 한 번 더 확인
|
||||
return probeVersion(filePath)
|
||||
}
|
||||
|
||||
function probeVersion(bin: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let ok = false
|
||||
child.stdout.on('data', () => { ok = true })
|
||||
child.on('error', () => resolve(false))
|
||||
child.on('close', (code) => resolve(ok && code === 0))
|
||||
})
|
||||
}
|
||||
|
||||
/** GitHub Releases 의 latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
|
||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (redirects > 8) {
|
||||
reject(new Error(t('youtube.tooManyRedirects')))
|
||||
return
|
||||
}
|
||||
const lib = url.startsWith('https://') ? https : http
|
||||
const req = lib.get(url, {
|
||||
headers: { 'user-agent': 'mc-music-quiz-launcher' }
|
||||
}, (res) => {
|
||||
const code = res.statusCode || 0
|
||||
if (code >= 300 && code < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
|
||||
return
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.resume()
|
||||
reject(new Error(`HTTP ${code} (${url})`))
|
||||
return
|
||||
}
|
||||
const out = createWriteStream(dest)
|
||||
res.pipe(out)
|
||||
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
|
||||
out.on('error', reject)
|
||||
res.on('error', reject)
|
||||
})
|
||||
req.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 영상 URL 의 메타데이터를 가져온다.
|
||||
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
|
||||
*/
|
||||
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
|
||||
const bin = await ensureYtDlp()
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, [
|
||||
'--dump-json',
|
||||
'--no-warnings',
|
||||
'--no-playlist',
|
||||
'--skip-download',
|
||||
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(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
|
||||
return
|
||||
}
|
||||
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
||||
if (!line) { resolve(null); return }
|
||||
try {
|
||||
const obj = JSON.parse(line) as Record<string, unknown>
|
||||
const id = typeof obj.id === 'string' ? obj.id : ''
|
||||
if (!id) { resolve(null); return }
|
||||
resolve({
|
||||
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.webpage_url === 'string' && obj.webpage_url.length > 0
|
||||
? obj.webpage_url
|
||||
: `https://www.youtube.com/watch?v=${id}`
|
||||
})
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다.
|
||||
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
|
||||
*/
|
||||
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
|
||||
const bin = await ensureYtDlp()
|
||||
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(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
61
src/shared/env.ts
Normal file
61
src/shared/env.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import dotenv from 'dotenv'
|
||||
import { projectRoot } from './paths.js'
|
||||
|
||||
/**
|
||||
* `.env` / `.env.build` 를 읽어 `process.env` 에 주입.
|
||||
*
|
||||
* 여러 파일을 순서대로 읽되 `override:false` 로 병합하므로 **먼저 로드된 값이
|
||||
* 우선**. 두 도메인(패키지 빌드용 vs 개발/서버용) 이 한 함수에서 자연스럽게
|
||||
* 분리됨:
|
||||
*
|
||||
* 1. 패키징된 Electron 앱: `process.resourcesPath/.env.build`
|
||||
* — electron-builder 가 빌드 시점 `.env.build` 를 함께 배포. 패키지된 exe
|
||||
* 에서 가장 먼저 적용되는 값.
|
||||
* 2. 패키징된 Electron 앱: `process.resourcesPath/.env`
|
||||
* — 운영자가 패키징 후 직접 `.env` 를 옆에 두고 덮어쓰는 경우 폴백.
|
||||
* 3. `<프로젝트 루트>/.env`
|
||||
* — 개발 실행(npm start / npm run installer*) 및 서버 운영용. 서버의
|
||||
* `PORT/HOST/SESSION_SECRET` 처럼 dev 에서 반드시 살아 있어야 하는 값들이
|
||||
* 있어, `.env.build` 보다 먼저 로드해 우선권을 줌.
|
||||
* 4. `<프로젝트 루트>/.env.build`
|
||||
* — dev 환경에서 빌드용 값(예: 운영 도메인 SITE_BASE_URL)을 테스트하고
|
||||
* 싶을 때 사용. `.env` 에 없는 키만 채움.
|
||||
*
|
||||
* - 이미 설정된 환경변수는 덮어쓰지 않음(쉘/systemd 에서 넘긴 값이 최우선).
|
||||
* - 존재하지 않는 후보는 조용히 건너뜀.
|
||||
* - 서버/설치기/리소스팩설치기 진입점에서 한 번씩 호출.
|
||||
*/
|
||||
export function loadEnv(): void {
|
||||
const candidates: string[] = []
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
|
||||
candidates.push(path.join(resourcesPath, '.env.build'))
|
||||
candidates.push(path.join(resourcesPath, '.env'))
|
||||
}
|
||||
candidates.push(path.join(projectRoot, '.env'))
|
||||
candidates.push(path.join(projectRoot, '.env.build'))
|
||||
|
||||
for (const envPath of candidates) {
|
||||
if (fs.existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath, override: false, quiet: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 베이스 URL. 관리 사이트가 호스팅되는 외부 주소(설치기가 manifest 를
|
||||
* 받아가는 도메인). 기본값은 로컬 개발용 `http://127.0.0.1:3000`.
|
||||
*/
|
||||
export function getSiteBaseUrl(): string {
|
||||
const raw = (process.env.SITE_BASE_URL ?? 'http://127.0.0.1:3000').trim()
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/** 사이트 베이스 URL + `/manifest.json`. `MANIFEST_URL` 가 따로 지정되면 그 값을 우선. */
|
||||
export function getManifestUrl(): string {
|
||||
const explicit = process.env.MANIFEST_URL?.trim()
|
||||
if (explicit) return explicit
|
||||
return `${getSiteBaseUrl()}/manifest.json`
|
||||
}
|
||||
93
src/shared/i18n.ts
Normal file
93
src/shared/i18n.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* 단순 키-문자열 사전. 중첩 객체도 허용해서 그룹화 가능.
|
||||
* { step1: { title: '1단계. 음악퀴즈 선택' } }
|
||||
* t('step1.title') → '1단계. 음악퀴즈 선택'
|
||||
*/
|
||||
export type Locale = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* 자유 형식 ko-kr.json 을 로드하고 `t(key, params)` 헬퍼를 만들어 반환.
|
||||
*
|
||||
* 사용 패턴:
|
||||
* const { t, dict } = createI18n(path.join(__dirname, 'locales', 'ko-kr.json'))
|
||||
* t('step1.title')
|
||||
* t('install.downloading', { idx: 3 }) // → '3번 노래 다운로드 중…'
|
||||
*
|
||||
* 키가 사전에 없으면 키 자체를 반환(개발 중 누락 빨리 찾도록).
|
||||
* 사전이 비어 있어도 빌드는 깨지지 않고 키만 노출.
|
||||
*/
|
||||
export interface I18n {
|
||||
/** 키로 문자열 lookup. 누락 시 키 그대로 반환. */
|
||||
t(key: string, params?: Record<string, string | number>): string
|
||||
/** 렌더러로 전달하기 위한 원본 사전(JSON 그대로). */
|
||||
dict: Locale
|
||||
}
|
||||
|
||||
export function createI18n(filePath: string): I18n {
|
||||
let dict: Locale = {}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
dict = JSON.parse(raw) as Locale
|
||||
} catch {
|
||||
// 파일이 없거나 깨진 경우 빈 사전. t() 가 키 자체를 돌려주므로 UI 가 깨지진 않음.
|
||||
dict = {}
|
||||
}
|
||||
|
||||
function lookup(key: string): string | undefined {
|
||||
const parts = key.split('.')
|
||||
let cur: unknown = dict
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === 'object' && p in (cur as Record<string, unknown>)) {
|
||||
cur = (cur as Record<string, unknown>)[p]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return typeof cur === 'string' ? cur : undefined
|
||||
}
|
||||
|
||||
function interpolate(tpl: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return tpl
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name: string) => {
|
||||
return name in params ? String(params[name]) : `{{${name}}}`
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
t(key, params) {
|
||||
const found = lookup(key)
|
||||
return interpolate(found ?? key, params)
|
||||
},
|
||||
dict
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 진입점에서 호출할 표준 로더. 컴포넌트 이름과 `__dirname`(컴파일 후) 만 주면
|
||||
* `locales/<component>/ko-kr.json` 을 찾아 로드.
|
||||
*
|
||||
* 탐색 순서(처음 발견된 것만 사용):
|
||||
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
|
||||
* 2. `<프로젝트 루트>/locales/<component>/ko-kr.json`
|
||||
*/
|
||||
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n {
|
||||
// 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
|
||||
const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
|
||||
const candidates: string[] = []
|
||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
|
||||
candidates.push(path.join(resourcesPath, 'locales', component, 'ko-kr.json'))
|
||||
}
|
||||
candidates.push(path.join(projectRoot, 'locales', component, 'ko-kr.json'))
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
return createI18n(p)
|
||||
}
|
||||
}
|
||||
return createI18n(candidates[candidates.length - 1] ?? '')
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
|
||||
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
|
||||
export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
@@ -6,5 +7,28 @@ 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 fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
|
||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||
export const publicDirPath = path.join(projectRoot, 'public')
|
||||
|
||||
/**
|
||||
* 사용자 환경의 "%appdata%" 디렉터리(OS별 표준 사용자 데이터 경로)를 반환.
|
||||
* - Windows : %APPDATA% (보통 C:\Users\<user>\AppData\Roaming)
|
||||
* - macOS : ~/Library/Application Support
|
||||
* - Linux 등 : $XDG_CONFIG_HOME 또는 ~/.config
|
||||
*/
|
||||
export function getAppDataDir(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support')
|
||||
}
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
|
||||
}
|
||||
|
||||
/** %appdata%/.mc_custom — 음악퀴즈 관련 외부 도구/캐시 보관 디렉터리. */
|
||||
export function getMcCustomDir(): string {
|
||||
return path.join(getAppDataDir(), '.mc_custom')
|
||||
}
|
||||
|
||||
@@ -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.js'
|
||||
import type {
|
||||
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
|
||||
PackList, MusicListEntry, ImageListEntry
|
||||
} from './types.js'
|
||||
|
||||
export async function readManifest(): Promise<Manifest> {
|
||||
try {
|
||||
@@ -78,8 +81,16 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
||||
: fallback.mcVersion,
|
||||
platform: {
|
||||
type: platformType,
|
||||
downloadUrl: typeof platform.downloadUrl === 'string' && platform.downloadUrl.trim().length > 0
|
||||
// vanilla 외에는 fabric/forge/neoforge 모두 downloadUrl 을 보관한다.
|
||||
downloadUrl: platformType !== 'vanilla'
|
||||
&& typeof platform.downloadUrl === 'string'
|
||||
&& platform.downloadUrl.trim().length > 0
|
||||
? platform.downloadUrl.trim()
|
||||
: undefined,
|
||||
loaderVersion: platformType === 'fabric'
|
||||
&& typeof (platform as { loaderVersion?: unknown }).loaderVersion === 'string'
|
||||
&& ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim().length > 0
|
||||
? ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim()
|
||||
: undefined
|
||||
},
|
||||
modsFolder: sanitizeFolderName(input.modsFolder),
|
||||
@@ -204,6 +215,79 @@ 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)
|
||||
}
|
||||
|
||||
/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */
|
||||
function sanitizeAliases(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const item of value) {
|
||||
const s = sanitizeStr(item)
|
||||
if (!s) continue
|
||||
if (seen.has(s)) continue
|
||||
seen.add(s)
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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),
|
||||
aliases: sanitizeAliases(entry.aliases)
|
||||
}))
|
||||
.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')
|
||||
|
||||
@@ -2,7 +2,10 @@ export type LoaderType = 'vanilla' | 'forge' | 'fabric' | 'neoforge'
|
||||
|
||||
export interface PackPlatform {
|
||||
type: LoaderType
|
||||
/** forge / neoforge 처럼 사용자가 직접 업로드한 installer jar 의 URL. */
|
||||
downloadUrl?: string
|
||||
/** fabric 의 경우 Fabric Meta 에서 선택한 로더 버전(예: "0.16.0"). 설치 시 최신 fabric-installer 를 받아 CLI 로 자동 설치. */
|
||||
loaderVersion?: string
|
||||
}
|
||||
|
||||
export interface PackDefinition {
|
||||
@@ -36,3 +39,28 @@ export interface AccountEntry {
|
||||
id: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MusicListEntry {
|
||||
/** 유튜브 영상 주소. */
|
||||
url: string
|
||||
title: string
|
||||
artist: string
|
||||
/** 노래 길이 (초). */
|
||||
durationSec: number
|
||||
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||
aliases: string[]
|
||||
}
|
||||
|
||||
export interface ImageListEntry {
|
||||
/** 유튜브 영상 주소 또는 일반 이미지 URL. */
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface PackList {
|
||||
/** 음악 플레이리스트 원본 주소 (저장 시 기억해서 재사용). */
|
||||
musicPlaylistUrl: string
|
||||
/** 사진 플레이리스트 원본 주소. */
|
||||
imagePlaylistUrl: string
|
||||
music: MusicListEntry[]
|
||||
images: ImageListEntry[]
|
||||
}
|
||||
|
||||
4
tsconfig.installer-rp.json
Normal file
4
tsconfig.installer-rp.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/installer-rp/**/*.ts", "src/shared/**/*.ts"]
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -3,29 +3,29 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>음악퀴즈 목록</title>
|
||||
<title><%= t('site.indexTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<main class="pageWrap">
|
||||
<section class="hero">
|
||||
<h1>마인크래프트 음악퀴즈</h1>
|
||||
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p>
|
||||
<h1><%= t('site.heroTitle') %></h1>
|
||||
<p><%= t('site.heroSubtitle') %></p>
|
||||
</section>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (packs.length === 0) { %>
|
||||
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
||||
<p class="muted"><%= t('site.empty') %></p>
|
||||
<% } %>
|
||||
<% packs.forEach(function (entry) { %>
|
||||
<article class="packCard">
|
||||
<h2><%= entry.name %></h2>
|
||||
<p class="muted">파일: <%= entry.file %>.json</p>
|
||||
<p class="muted"><%= t('site.fileLabel', { file: entry.file }) %></p>
|
||||
<% if (entry.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
|
||||
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
|
||||
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li>
|
||||
<li><%= t('site.mcVersion') %> <strong><%= entry.definition.mcVersion %></strong></li>
|
||||
<li><%= t('site.platform') %> <strong><%= entry.definition.platform.type %></strong></li>
|
||||
<li><%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</article>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 대시보드</title>
|
||||
<title><%= t('dashboard.browserTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -11,34 +11,36 @@
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<h1>음악퀴즈 목록</h1>
|
||||
<h1><%= t('dashboard.title') %></h1>
|
||||
<div class="dashboardActions">
|
||||
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
|
||||
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
|
||||
<form method="post" action="/op/dashboard/create" class="inlineForm">
|
||||
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
|
||||
<button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button>
|
||||
</form>
|
||||
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button>
|
||||
<button type="button" class="secondaryButton" id="deleteToggle"><%= t('dashboard.deletePack') %></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (items.length === 0) { %>
|
||||
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p>
|
||||
<p class="muted"><%= t('dashboard.emptyHint') %></p>
|
||||
<% } %>
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard editableCard" data-key="<%= item.key %>">
|
||||
<label class="cardCheckbox" hidden>
|
||||
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
|
||||
<span>선택</span>
|
||||
<span><%= t('dashboard.select') %></span>
|
||||
</label>
|
||||
<a class="cardLink" href="/op/dashboard/<%= 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>
|
||||
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</a>
|
||||
@@ -46,8 +48,8 @@
|
||||
<% }) %>
|
||||
</section>
|
||||
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
|
||||
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
|
||||
<button type="submit" class="dangerButton">삭제 확인</button>
|
||||
<button type="button" class="secondaryButton" id="deleteCancel"><%= t('common.cancel') %></button>
|
||||
<button type="submit" class="dangerButton"><%= t('dashboard.confirmDelete') %></button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
152
views/op/datapack.ejs
Normal file
152
views/op/datapack.ejs
Normal file
@@ -0,0 +1,152 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('datapack.browserTitle') %></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"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('datapack.title') %></h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('datapack.hint') %></p>
|
||||
|
||||
<section class="dpControls">
|
||||
<button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
|
||||
<span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
|
||||
</section>
|
||||
|
||||
<p class="muted" id="countLabel"></p>
|
||||
|
||||
<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="copyBtn"><%= t('datapack.copy') %></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><%= t('datapack.modalPickTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.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 %>"
|
||||
data-music-count="<%= item.musicCount %>">
|
||||
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||
<p class="muted"><%= item.key %>.json</p>
|
||||
<% if (item.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</article>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
|
||||
</script>
|
||||
<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
|
||||
})
|
||||
// ESC 로 닫기.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape' && !pickModal.hidden) {
|
||||
pickModal.hidden = true
|
||||
e.preventDefault()
|
||||
}
|
||||
})
|
||||
document.querySelectorAll('#pickList .pickable').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
pickedKey = card.getAttribute('data-key')
|
||||
var name = card.getAttribute('data-name')
|
||||
var count = card.getAttribute('data-music-count') || '0'
|
||||
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
|
||||
document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', count)
|
||||
pickModal.hidden = true
|
||||
document.getElementById('dpActions').hidden = false
|
||||
document.getElementById('dp-status').textContent = ''
|
||||
document.getElementById('dp-status').classList.remove('error')
|
||||
document.getElementById('codeOut').hidden = true
|
||||
document.getElementById('codeOut').textContent = ''
|
||||
})
|
||||
})
|
||||
document.getElementById('exportBtn').addEventListener('click', function () {
|
||||
if (!pickedKey) return
|
||||
var s = document.getElementById('dp-status')
|
||||
s.textContent = I18N.exporting; 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 = I18N.failed.replace('{{message}}', res.text); s.classList.add('error')
|
||||
return
|
||||
}
|
||||
var out = document.getElementById('codeOut')
|
||||
out.textContent = res.text
|
||||
out.hidden = false
|
||||
s.textContent = I18N.exported
|
||||
})
|
||||
.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 () {
|
||||
var out = document.getElementById('codeOut')
|
||||
if (out.hidden) return
|
||||
navigator.clipboard.writeText(out.textContent).then(function () {
|
||||
var s = document.getElementById('dp-status')
|
||||
s.textContent = I18N.copied
|
||||
s.classList.remove('error')
|
||||
})
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= pack.name %> 편집</title>
|
||||
<title><%= t('editor.browserTitle', { name: pack.name }) %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -12,27 +12,27 @@
|
||||
<main class="pageWrap">
|
||||
<section class="editorHeader">
|
||||
<div>
|
||||
<p class="eyebrow">PACK EDITOR</p>
|
||||
<p class="eyebrow"><%= t('editor.eyebrow') %></p>
|
||||
<h1><%= pack.name %></h1>
|
||||
</div>
|
||||
<a class="ghostLink" href="/op/dashboard">목록으로</a>
|
||||
<a class="ghostLink" href="/op/dashboard"><%= t('common.backToList') %></a>
|
||||
</section>
|
||||
|
||||
<form method="post" class="editorForm" id="editorForm">
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>음악퀴즈 이름</span>
|
||||
<span><%= t('editor.displayName') %></span>
|
||||
<input name="displayName" value="<%= pack.name %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>JSON 파일 이름 (확장자 제외)</span>
|
||||
<span><%= t('editor.fileName') %></span>
|
||||
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>마인크래프트 버전</span>
|
||||
<span><%= t('editor.mcVersion') %></span>
|
||||
<select name="mcVersion" required>
|
||||
<% releases.forEach(function (release) { %>
|
||||
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
|
||||
@@ -40,78 +40,168 @@
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>모드 플랫폼</span>
|
||||
<span><%= t('editor.platformType') %></span>
|
||||
<select name="platformType" id="platformType">
|
||||
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
|
||||
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformDownloadField">
|
||||
<span>플랫폼 설치파일 URL</span>
|
||||
<label class="fullSpan" id="platformDownloadField"<%= pack.platform.type === 'vanilla' ? ' hidden' : '' %>>
|
||||
<span><%= t('editor.platformDownloadUrl') %></span>
|
||||
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
|
||||
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/<파일명></code>으로 해석됩니다.</small>
|
||||
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformLoaderField"<%= pack.platform.type === 'fabric' ? '' : ' hidden' %>>
|
||||
<span><%= t('editor.platformLoaderVersion') %></span>
|
||||
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
|
||||
<option value=""><%= t('common.loading') %></option>
|
||||
</select>
|
||||
<small class="muted"><%= t('editor.platformLoaderHint') %></small>
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최소 램 (MB)</span>
|
||||
<span><%= t('editor.serverMinRam') %></span>
|
||||
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최대 램 (MB)</span>
|
||||
<span><%= t('editor.serverMaxRam') %></span>
|
||||
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 최소 램 (MB)</span>
|
||||
<span><%= t('editor.clientMinRam') %></span>
|
||||
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 권장 램 (MB)</span>
|
||||
<span><%= t('editor.clientRecommendedRam') %></span>
|
||||
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>맵 파일 (.zip)</span>
|
||||
<span><%= t('editor.mapPath') %></span>
|
||||
<input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" />
|
||||
<small class="muted">/file/maps/ 아래 zip 파일 이름.</small>
|
||||
<small class="muted"><%= t('editor.mapPathHint') %></small>
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 파일 (.zip)</span>
|
||||
<span><%= t('editor.serverPath') %></span>
|
||||
<input name="serverPath" value="<%= pack.serverPath %>" placeholder="my-server.zip" pattern=".+\.zip" />
|
||||
<small class="muted">/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.</small>
|
||||
<small class="muted"><%= t('editor.serverPathHint') %></small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>모드 폴더 이름</span>
|
||||
<span><%= t('editor.modsFolder') %></span>
|
||||
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
|
||||
<small class="muted">/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
|
||||
<small class="muted"><%- t('editor.modsFolderHint') %></small>
|
||||
</label>
|
||||
<label>
|
||||
<span>리소스팩 (.zip)</span>
|
||||
<span><%= t('editor.resourcepackPath') %></span>
|
||||
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
|
||||
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small>
|
||||
<small class="muted"><%= t('editor.resourcepackHint') %></small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="primaryButton" type="submit">저장</button>
|
||||
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var I18N = {
|
||||
ramOrderInvalid: <%- JSON.stringify(t('editor.ramOrderInvalid')) %>,
|
||||
fabricLoaderRequired: <%- JSON.stringify(t('editor.fabricLoaderRequired')) %>,
|
||||
loaderEmpty: <%- JSON.stringify(t('editor.platformLoaderEmpty')) %>,
|
||||
loaderPickMc: <%- JSON.stringify(t('editor.platformLoaderPickMc')) %>,
|
||||
loaderLoadFailedPrefix: <%- JSON.stringify(t('editor.platformLoaderLoadFailed', { message: '__M__' })) %>,
|
||||
loading: <%- JSON.stringify(t('common.loading')) %>
|
||||
}
|
||||
function formatLoaderLoadFailed(message) {
|
||||
return I18N.loaderLoadFailedPrefix.replace('__M__', message)
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var platformSelect = document.getElementById('platformType')
|
||||
var mcVersionSelect = document.querySelector('select[name="mcVersion"]')
|
||||
var downloadField = document.getElementById('platformDownloadField')
|
||||
var loaderField = document.getElementById('platformLoaderField')
|
||||
var loaderSelect = document.getElementById('platformLoaderVersion')
|
||||
var currentLoader = loaderSelect.getAttribute('data-current') || ''
|
||||
var loaderCache = {} // mcVersion -> [loader versions]
|
||||
var loaderFetchSeq = 0
|
||||
|
||||
function syncPlatformVisibility() {
|
||||
if (platformSelect.value === 'vanilla') {
|
||||
var type = platformSelect.value
|
||||
if (type === 'fabric') {
|
||||
downloadField.removeAttribute('hidden')
|
||||
loaderField.removeAttribute('hidden')
|
||||
loadFabricLoaders()
|
||||
} else if (type === 'vanilla') {
|
||||
downloadField.setAttribute('hidden', '')
|
||||
loaderField.setAttribute('hidden', '')
|
||||
downloadField.querySelector('input').value = ''
|
||||
loaderSelect.innerHTML = '<option value=""></option>'
|
||||
} else {
|
||||
downloadField.removeAttribute('hidden')
|
||||
loaderField.setAttribute('hidden', '')
|
||||
loaderSelect.innerHTML = '<option value=""></option>'
|
||||
}
|
||||
}
|
||||
|
||||
function populateLoaderOptions(versions, preselect) {
|
||||
if (!versions || versions.length === 0) {
|
||||
loaderSelect.innerHTML = '<option value="">' + I18N.loaderEmpty + '</option>'
|
||||
return
|
||||
}
|
||||
var html = ''
|
||||
for (var i = 0; i < versions.length; i++) {
|
||||
var v = versions[i]
|
||||
var sel = v.version === preselect ? ' selected' : ''
|
||||
var label = v.version + (v.stable ? '' : ' (beta)')
|
||||
html += '<option value="' + v.version + '"' + sel + '>' + label + '</option>'
|
||||
}
|
||||
loaderSelect.innerHTML = html
|
||||
// 사용자가 저장해둔 값이 목록에 없으면 첫 번째(최신) 자동 선택.
|
||||
if (preselect && !versions.some(function (v) { return v.version === preselect })) {
|
||||
loaderSelect.value = versions[0].version
|
||||
}
|
||||
}
|
||||
|
||||
function loadFabricLoaders() {
|
||||
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
|
||||
if (!mc) {
|
||||
loaderSelect.innerHTML = '<option value="">' + I18N.loaderPickMc + '</option>'
|
||||
return
|
||||
}
|
||||
if (loaderCache[mc]) {
|
||||
populateLoaderOptions(loaderCache[mc], currentLoader)
|
||||
return
|
||||
}
|
||||
var seq = ++loaderFetchSeq
|
||||
loaderSelect.innerHTML = '<option value="">' + I18N.loading + '</option>'
|
||||
fetch('https://meta.fabricmc.net/v2/versions/loader/' + encodeURIComponent(mc))
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
return res.json()
|
||||
})
|
||||
.then(function (list) {
|
||||
if (seq !== loaderFetchSeq) return // 더 새로운 요청이 들어왔으면 무시
|
||||
// 응답: [{ loader: { version, stable, ... }, intermediary: {...} }, ...]
|
||||
var versions = (list || []).map(function (item) {
|
||||
return { version: item.loader.version, stable: !!item.loader.stable }
|
||||
})
|
||||
loaderCache[mc] = versions
|
||||
populateLoaderOptions(versions, currentLoader)
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (seq !== loaderFetchSeq) return
|
||||
var msg = (err && err.message) ? err.message : String(err)
|
||||
loaderSelect.innerHTML = '<option value="">' + formatLoaderLoadFailed(msg) + '</option>'
|
||||
})
|
||||
}
|
||||
|
||||
platformSelect.addEventListener('change', syncPlatformVisibility)
|
||||
if (mcVersionSelect) mcVersionSelect.addEventListener('change', function () {
|
||||
if (platformSelect.value === 'fabric') loadFabricLoaders()
|
||||
})
|
||||
syncPlatformVisibility()
|
||||
|
||||
var form = document.getElementById('editorForm')
|
||||
@@ -120,7 +210,12 @@
|
||||
var clientReco = Number(form.clientRecommendedRam.value)
|
||||
if (clientMin > clientReco) {
|
||||
event.preventDefault()
|
||||
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
|
||||
alert(I18N.ramOrderInvalid)
|
||||
return
|
||||
}
|
||||
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
|
||||
event.preventDefault()
|
||||
alert(I18N.fabricLoaderRequired)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
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><%= t('list.browserTitle') %></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"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('list.title') %></h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (items.length === 0) { %>
|
||||
<p class="muted"><%= t('site.empty') %></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><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</a>
|
||||
</article>
|
||||
<% }) %>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
157
views/op/listEditor.ejs
Normal file
157
views/op/listEditor.ejs
Normal file
@@ -0,0 +1,157 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('listEditor.browserTitle', { name: 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"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= pack.name %></h1>
|
||||
<p class="muted"><%= packKey %>.json</p>
|
||||
</div>
|
||||
<div class="dirtyMark" id="dirty-mark" hidden title="<%= t('listEditor.dirtyTooltip') %>">*</div>
|
||||
</section>
|
||||
|
||||
<div class="tabBar">
|
||||
<button type="button" class="tabBtn active" data-tab="music"><%= t('listEditor.tabMusic') %></button>
|
||||
<button type="button" class="tabBtn" data-tab="image"><%= t('listEditor.tabImage') %></button>
|
||||
</div>
|
||||
|
||||
<!-- 음악 탭 -->
|
||||
<section class="tabPanel" id="tab-music">
|
||||
<div class="listActionsRow">
|
||||
<button type="button" class="primaryButton" data-action="save" data-target="music"><%= t('listEditor.saveList') %></button>
|
||||
<button type="button" class="dangerButton" data-action="clear" data-target="music"><%= t('listEditor.clearList') %></button>
|
||||
<span class="statusText" id="status-music"></span>
|
||||
</div>
|
||||
|
||||
<div class="playlistRow">
|
||||
<input type="url" class="textInput" id="music-playlist-url"
|
||||
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
|
||||
value="<%= list.musicPlaylistUrl %>" />
|
||||
<button type="button" class="secondaryButton" data-action="fetch" data-target="music"><%= t('listEditor.fetchPlaylist') %></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"><%= t('listEditor.saveList') %></button>
|
||||
<button type="button" class="dangerButton" data-action="clear" data-target="image"><%= t('listEditor.clearList') %></button>
|
||||
<button type="button" class="secondaryButton" id="image-from-music"><%= t('listEditor.imageFromMusic') %></button>
|
||||
<span class="statusText" id="status-image"></span>
|
||||
</div>
|
||||
|
||||
<div class="playlistRow">
|
||||
<input type="url" class="textInput" id="image-playlist-url"
|
||||
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
|
||||
value="<%= list.imagePlaylistUrl %>" />
|
||||
<button type="button" class="secondaryButton" data-action="fetch" data-target="image"><%= t('listEditor.fetchPlaylist') %></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"><%= t('common.edit') %></button>
|
||||
<button type="button" data-ctx="delete"><%= t('common.delete') %></button>
|
||||
</div>
|
||||
|
||||
<!-- Confirm modal -->
|
||||
<div class="modalOverlay" id="confirmModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header><h3 id="confirm-title"><%= t('listEditor.modalConfirmTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.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><%= t('common.cancel') %></button>
|
||||
<button type="button" class="primaryButton" id="confirm-ok"><%= t('common.ok') %></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (music) -->
|
||||
<div class="modalOverlay" id="editMusicModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header><h3><%= t('listEditor.musicEditTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<label><%= t('listEditor.musicEditUrl') %>
|
||||
<input type="url" id="edit-music-url" class="textInput" />
|
||||
</label>
|
||||
<p class="muted" style="margin-top:6px;font-size:12px;">
|
||||
<%= t('listEditor.musicEditHint') %>
|
||||
</p>
|
||||
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
|
||||
</div>
|
||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
|
||||
<button type="button" class="primaryButton" id="edit-music-save"><%= t('common.save') %></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alias modal (music) -->
|
||||
<div class="modalOverlay" id="aliasModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header class="aliasModalHeader">
|
||||
<button type="button" class="ghostLink" id="alias-back"><%= t('listEditor.aliasBack') %></button>
|
||||
<h3 id="alias-modal-title"></h3>
|
||||
<span></span>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.aliasHint') %></p>
|
||||
<div id="alias-rows" class="aliasRowList"></div>
|
||||
<div>
|
||||
<button type="button" class="secondaryButton" id="alias-add"><%= t('listEditor.aliasAdd') %></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (image) -->
|
||||
<div class="modalOverlay" id="editImageModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header><h3><%= t('listEditor.imageEditTitle') %></h3>
|
||||
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<div class="segmentedRow">
|
||||
<button type="button" class="segBtn active" data-seg="yt"><%= t('listEditor.imageSegYt') %></button>
|
||||
<button type="button" class="segBtn" data-seg="img"><%= t('listEditor.imageSegImg') %></button>
|
||||
</div>
|
||||
<label><%= t('listEditor.imageEditUrl') %>
|
||||
<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><%= t('common.cancel') %></button>
|
||||
<button type="button" class="primaryButton" id="edit-image-save"><%= t('common.save') %></button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||
var INITIAL = <%- JSON.stringify(list) %>;
|
||||
var I18N = <%- JSON.stringify(localeDict.listEditor) %>;
|
||||
I18N.common = <%- JSON.stringify(localeDict.common) %>;
|
||||
</script>
|
||||
<script src="/static/listEditor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 로그인</title>
|
||||
<title><%= t('login.title') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody centerLayout">
|
||||
<main class="loginCard">
|
||||
<h1>관리자 로그인</h1>
|
||||
<h1><%= t('login.title') %></h1>
|
||||
<% if (error) { %>
|
||||
<p class="errorBanner"><%= error %></p>
|
||||
<% } %>
|
||||
<form method="post" action="/op" class="loginForm">
|
||||
<label>
|
||||
<span>비밀번호</span>
|
||||
<span><%= t('login.password') %></span>
|
||||
<input name="password" type="password" autocomplete="current-password" required autofocus />
|
||||
</label>
|
||||
<button class="primaryButton" type="submit">로그인</button>
|
||||
<button class="primaryButton" type="submit"><%= t('login.submit') %></button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<header class="topNav">
|
||||
<a class="navBrand" href="/op/dashboard">
|
||||
<span class="navLogo">🎵</span>
|
||||
<span class="navTitle">관리자 페이지</span>
|
||||
<span class="navTitle"><%= t('nav.brand') %></span>
|
||||
</a>
|
||||
<div class="navUser">
|
||||
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
|
||||
<div class="navUserMenu" id="userMenu" hidden>
|
||||
<form method="post" action="/op/logout">
|
||||
<button type="submit" class="dangerLink">로그아웃</button>
|
||||
<button type="submit" class="dangerLink"><%= t('nav.logout') %></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user