Compare commits
101 Commits
01a34e08aa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c13648f63 | |||
| 9efd4a696a | |||
| c580a50fd4 | |||
| 38df72e4f6 | |||
| 6447b1cb78 | |||
| 9ba5dc6b7b | |||
| 05dc9d7166 | |||
| 25977d894b | |||
| c14b0507c7 | |||
| ffb2048627 | |||
| bc3841147f | |||
| 40986bee11 | |||
| bf225f51e1 | |||
| 2371af4411 | |||
| 1f59f6a98b | |||
| 794ad9b778 | |||
| f810719d92 | |||
| 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 | |||
| 678e886a52 | |||
|
|
4e7a7023cd |
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>
|
||||
530
installer-rp/renderer.js
Normal file
530
installer-rp/renderer.js
Normal file
@@ -0,0 +1,530 @@
|
||||
'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 () {
|
||||
renderAgreement()
|
||||
}).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>'
|
||||
})
|
||||
}
|
||||
|
||||
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
||||
// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 명시적으로 빈 목록(terms:[])
|
||||
// 정상 응답일 때만 단계를 건너뛰고, 네트워크/서버 오류는 차단 후 다시 시도 UI를 보여준다.
|
||||
function renderAgreement() {
|
||||
setActiveStep(1)
|
||||
clearPage()
|
||||
var loadingSection = document.createElement('section')
|
||||
loadingSection.className = 'page'
|
||||
loadingSection.innerHTML = '<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + escapeHtml(tt('agreement.loading')) + '</p>'
|
||||
pageHost.appendChild(loadingSection)
|
||||
|
||||
api.getTermsList().then(function (res) {
|
||||
if (!res || !res.ok) {
|
||||
showAgreementError((res && res.message) || 'unknown')
|
||||
return
|
||||
}
|
||||
var terms = (res.terms || []).map(function (t) {
|
||||
return { id: t.kind, tab: t.label }
|
||||
})
|
||||
if (terms.length === 0) {
|
||||
renderStep2()
|
||||
return
|
||||
}
|
||||
clearPage()
|
||||
renderAgreementWithKinds(terms)
|
||||
}).catch(function (err) {
|
||||
showAgreementError(err && err.message ? err.message : 'unknown')
|
||||
})
|
||||
}
|
||||
|
||||
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도 옵션. 동의 없이 설치 단계로
|
||||
// 자동 진입하지 않도록 next 버튼을 두지 않는다.
|
||||
function showAgreementError(message) {
|
||||
clearPage()
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||
'<p class="formMessage error">' + escapeHtml(tt('agreement.listLoadFailed', { message: message })) + '</p>' +
|
||||
'<div class="actionRow">' +
|
||||
'<button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
|
||||
'<button class="primaryBtn" id="retry">' + escapeHtml(tt('agreement.retry')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
||||
}
|
||||
|
||||
function renderAgreementWithKinds(KINDS) {
|
||||
var section = document.createElement('section')
|
||||
section.className = 'page'
|
||||
section.innerHTML =
|
||||
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||
'<p class="formMessage">' + escapeHtml(tt('agreement.intro')) + '</p>' +
|
||||
'<div class="tabBar" id="agTabs">' +
|
||||
KINDS.map(function (k, i) {
|
||||
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + escapeHtml(k.tab) + '</button>'
|
||||
}).join('') +
|
||||
'</div>' +
|
||||
'<div class="agreementBody" id="agBody">' + escapeHtml(tt('agreement.loading')) + '</div>' +
|
||||
'<label class="toggleRow" style="margin-top:12px;"><input type="checkbox" id="agAccept" /> ' +
|
||||
escapeHtml(tt('agreement.agreeAll')) + '</label>' +
|
||||
'<div class="formMessage" id="agMsg"></div>' +
|
||||
'<div class="actionRow">' +
|
||||
' <button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
|
||||
' <button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button>' +
|
||||
'</div>'
|
||||
pageHost.appendChild(section)
|
||||
|
||||
var body = section.querySelector('#agBody')
|
||||
var tabs = section.querySelectorAll('[data-ag]')
|
||||
var nextBtn = section.querySelector('#next')
|
||||
var accept = section.querySelector('#agAccept')
|
||||
var msg = section.querySelector('#agMsg')
|
||||
|
||||
// 본문 캐시. 탭 전환 시 재요청하지 않음.
|
||||
var cache = {}
|
||||
|
||||
function showKind(kind) {
|
||||
if (cache[kind]) { body.innerHTML = cache[kind]; return }
|
||||
body.textContent = tt('agreement.loading')
|
||||
api.getTerm(kind).then(function (res) {
|
||||
if (!res.ok) {
|
||||
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: res.message || '' })) + '</p>'
|
||||
return
|
||||
}
|
||||
var html = renderTermsMarkdown(res.content || '')
|
||||
cache[kind] = html
|
||||
body.innerHTML = html
|
||||
}).catch(function (err) {
|
||||
body.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('agreement.loadFailed', { message: err.message })) + '</p>'
|
||||
})
|
||||
}
|
||||
|
||||
tabs.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
tabs.forEach(function (x) { x.classList.remove('active') })
|
||||
b.classList.add('active')
|
||||
showKind(b.getAttribute('data-ag'))
|
||||
})
|
||||
})
|
||||
|
||||
accept.addEventListener('change', function () {
|
||||
nextBtn.disabled = !accept.checked
|
||||
if (accept.checked) msg.textContent = ''
|
||||
})
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (!accept.checked) {
|
||||
msg.textContent = tt('agreement.agreeRequired')
|
||||
msg.classList.add('error')
|
||||
return
|
||||
}
|
||||
renderStep2()
|
||||
})
|
||||
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||
|
||||
showKind(KINDS[0].id)
|
||||
}
|
||||
|
||||
// 인스톨러용 미니 markdown 렌더러. 사이트 termsEditor 와 같은 규칙을 처리한다.
|
||||
function renderTermsMarkdown(src) {
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function inline(s) {
|
||||
s = escHtml(s)
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
|
||||
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
var lines = src.replace(/\r\n/g, '\n').split('\n')
|
||||
var out = []
|
||||
var i = 0
|
||||
var stack = null
|
||||
function closeList() { if (stack) { out.push('</' + stack + '>'); stack = null } }
|
||||
while (i < lines.length) {
|
||||
var line = lines[i]
|
||||
var fence = /^```(\w*)\s*$/.exec(line)
|
||||
if (fence) {
|
||||
closeList()
|
||||
var code = []; i += 1
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) { code.push(lines[i]); i += 1 }
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
|
||||
continue
|
||||
}
|
||||
var togStart = /^:::toggle\s+(.+)$/.exec(line)
|
||||
if (togStart) {
|
||||
closeList()
|
||||
var summary = togStart[1]; var body2 = []; i += 1
|
||||
while (i < lines.length && !/^:::\s*$/.test(lines[i])) { body2.push(lines[i]); i += 1 }
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<details><summary>' + inline(summary) + '</summary>' + renderTermsMarkdown(body2.join('\n')) + '</details>')
|
||||
continue
|
||||
}
|
||||
var h = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (h) {
|
||||
closeList()
|
||||
out.push('<h' + h[1].length + '>' + inline(h[2]) + '</h' + h[1].length + '>')
|
||||
i += 1; continue
|
||||
}
|
||||
if (/^---+\s*$/.test(line)) { closeList(); out.push('<hr/>'); i += 1; continue }
|
||||
if (/^>\s?/.test(line)) {
|
||||
closeList()
|
||||
var q = []
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i += 1 }
|
||||
out.push('<blockquote>' + renderTermsMarkdown(q.join('\n')) + '</blockquote>')
|
||||
continue
|
||||
}
|
||||
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
|
||||
if (ol) {
|
||||
if (stack !== 'ol') { closeList(); out.push('<ol>'); stack = 'ol' }
|
||||
out.push('<li>' + inline(ol[1]) + '</li>'); i += 1; continue
|
||||
}
|
||||
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
|
||||
if (ul) {
|
||||
if (stack !== 'ul') { closeList(); out.push('<ul>'); stack = 'ul' }
|
||||
out.push('<li>' + inline(ul[1]) + '</li>'); i += 1; continue
|
||||
}
|
||||
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
|
||||
closeList()
|
||||
var para = [line]; i += 1
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i])
|
||||
&& !/^(#{1,6})\s+/.test(lines[i])
|
||||
&& !/^\s*[-*]\s+/.test(lines[i])
|
||||
&& !/^\s*\d+\.\s+/.test(lines[i])
|
||||
&& !/^>/.test(lines[i])
|
||||
&& !/^---+\s*$/.test(lines[i])
|
||||
&& !/^```/.test(lines[i])
|
||||
&& !/^:::/.test(lines[i])) {
|
||||
para.push(lines[i]); i += 1
|
||||
}
|
||||
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
|
||||
}
|
||||
closeList()
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
})
|
||||
|
||||
// 사용자가 취소를 눌렀는지 추적. 취소 흐름에서는 installFailed 알림을 띄우지 않고
|
||||
// 조용히 step1 로 돌아간다.
|
||||
var cancelInitiated = false
|
||||
cancelBtn.addEventListener('click', function () {
|
||||
if (!state.installing || cancelInitiated) return
|
||||
cancelInitiated = true
|
||||
cancelBtn.disabled = true
|
||||
cancelBtn.textContent = tt('agreement.cancelling')
|
||||
// 사용자에게 어느 단계든 즉시 "취소 중" 신호가 보이도록 패키지 섹션 상태 갱신.
|
||||
pkgSub.textContent = tt('agreement.cancelling')
|
||||
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()
|
||||
if (!cancelInitiated) {
|
||||
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()
|
||||
})()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||
@@ -155,6 +155,49 @@ main {
|
||||
|
||||
.toggleRow { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
|
||||
|
||||
/* 약관 동의 페이지 — 탭 + 약관 본문 박스. */
|
||||
.tabBar { display: flex; gap: 6px; margin: 12px 0 0; flex-wrap: wrap; }
|
||||
.tabBtn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tabBtn.active {
|
||||
background: var(--bg-card);
|
||||
border-bottom-color: var(--bg-card);
|
||||
color: var(--accent, #6cf);
|
||||
font-weight: 600;
|
||||
}
|
||||
.agreementBody {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px 18px;
|
||||
border-radius: 0 10px 10px 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.agreementBody h1, .agreementBody h2, .agreementBody h3 { margin: 12px 0 6px; }
|
||||
.agreementBody h1 { font-size: 17px; }
|
||||
.agreementBody h2 { font-size: 15px; }
|
||||
.agreementBody h3 { font-size: 14px; }
|
||||
.agreementBody p { margin: 6px 0; }
|
||||
.agreementBody ul, .agreementBody ol { margin: 6px 0; padding-left: 22px; }
|
||||
.agreementBody li { margin: 2px 0; }
|
||||
.agreementBody code { background: rgba(255,255,255,0.08); padding: 1px 4px; border-radius: 3px; font-family: 'Consolas', monospace; }
|
||||
.agreementBody pre { background: rgba(0,0,0,0.3); padding: 8px 10px; border-radius: 6px; overflow-x: auto; }
|
||||
.agreementBody pre code { background: none; padding: 0; }
|
||||
.agreementBody blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid var(--border); color: #aab; }
|
||||
.agreementBody details { margin: 6px 0; }
|
||||
.agreementBody details > summary { cursor: pointer; padding: 4px 0; }
|
||||
.agreementBody hr { border: none; border-top: 1px solid var(--border); margin: 10px 0; }
|
||||
.agreementBody a { color: var(--accent, #6cf); }
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -221,3 +264,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); }
|
||||
|
||||
148
locales/installer-rp/ko-kr.json
Normal file
148
locales/installer-rp/ko-kr.json
Normal file
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "마인크래프트 음악퀴즈 리소스팩 간편설치기"
|
||||
},
|
||||
"stepIndicator": {
|
||||
"step1": "1. 음악퀴즈",
|
||||
"step2": "2. 설치",
|
||||
"step3": "3. 완료"
|
||||
},
|
||||
"logViewer": {
|
||||
"heading": "설치 로그",
|
||||
"collapse": "접기",
|
||||
"expand": "펼치기"
|
||||
},
|
||||
"common": {
|
||||
"next": "다음",
|
||||
"back": "이전",
|
||||
"cancel": "취소",
|
||||
"confirm": "확인",
|
||||
"openFolder": "리소스팩 폴더 열기",
|
||||
"loading": "목록을 불러오는 중...",
|
||||
"selectFailed": "선택 실패",
|
||||
"listLoadFailed": "목록 로드 실패: {{message}}",
|
||||
"installFailed": "설치 실패: {{message}}",
|
||||
"noPacks": "등록된 음악퀴즈가 없습니다.",
|
||||
"mcVersionLabel": "마인크래프트 {{version}} · ",
|
||||
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
|
||||
"requestTimeout": "요청 시간 초과",
|
||||
"tooManyRedirects": "너무 많은 요청."
|
||||
},
|
||||
"step1": {
|
||||
"heading": "음악퀴즈 선택"
|
||||
},
|
||||
"agreement": {
|
||||
"heading": "약관 동의",
|
||||
"intro": "리소스팩을 설치하기 전에 아래 약관을 모두 확인하고 동의해 주세요.",
|
||||
"tabResourcepack": "리소스팩 약관",
|
||||
"tabInstaller": "리소스팩 설치기 약관",
|
||||
"loading": "약관을 불러오는 중...",
|
||||
"loadFailed": "약관 로드 실패: {{message}}",
|
||||
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||
"retry": "다시 시도",
|
||||
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
|
||||
"cancelling": "취소 중…"
|
||||
},
|
||||
"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 빈 값) — 새 리소스팩으로 생성",
|
||||
"baseRemoved": "베이스 리소스팩 삭제: {{path}}",
|
||||
"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}}개 항목)",
|
||||
"tracksAdded": "음악 트랙 추가됨: {{count}}곡",
|
||||
"paintingsAdded": "사진 텍스처 추가됨: {{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}}",
|
||||
"baseTrackCollision": "베이스 리소스팩에 같은 트랙 ID 가 이미 있어 설치를 중단합니다: {{trackId}}\n베이스 자산을 보존하면서 새 트랙을 같은 ID 로 추가할 수 없습니다. 베이스의 sounds.json 엔트리/sounds 폴더에서 충돌하는 항목을 제거하거나 다른 베이스를 사용하세요.",
|
||||
"basePaintingCollision": "베이스 리소스팩에 같은 사진 파일이 이미 있어 설치를 중단합니다: {{name}}\n베이스의 painting 텍스처를 보존하면서 같은 파일명을 추가할 수 없습니다. 베이스에서 충돌하는 파일을 제거하거나 다른 베이스를 사용하세요."
|
||||
}
|
||||
}
|
||||
308
locales/installer/ko-kr.json
Normal file
308
locales/installer/ko-kr.json
Normal file
@@ -0,0 +1,308 @@
|
||||
{
|
||||
"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": "설치 로그"
|
||||
},
|
||||
"agreement": {
|
||||
"heading": "약관 동의",
|
||||
"intro": "설치 전에 아래 약관을 모두 확인하고 동의해 주세요.",
|
||||
"tabMap": "맵 약관",
|
||||
"tabMod": "모드 약관",
|
||||
"tabInstaller": "설치기 약관",
|
||||
"loading": "약관을 불러오는 중...",
|
||||
"loadFailed": "약관 로드 실패: {{message}}",
|
||||
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||
"retry": "다시 시도",
|
||||
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
|
||||
},
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
228
locales/server/ko-kr.json
Normal file
228
locales/server/ko-kr.json
Normal file
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"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": "데이터팩 수정",
|
||||
"editTerms": "약관 수정",
|
||||
"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 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.",
|
||||
"outputPackName": "생성되는 리소스팩 이름",
|
||||
"outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩",
|
||||
"outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" < > |)는 자동으로 _ 로 바뀝니다.",
|
||||
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
|
||||
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
|
||||
},
|
||||
"terms": {
|
||||
"browserTitle": "약관 수정",
|
||||
"title": "약관 수정",
|
||||
"pickPackHint": "약관을 수정할 음악퀴즈를 선택하세요. 각 음악퀴즈마다 약관을 따로 보관합니다.",
|
||||
"packBrowserTitle": "{{name}} — 약관 수정",
|
||||
"packTitle": "{{name}} 약관 수정",
|
||||
"hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.",
|
||||
"editorBrowserTitle": "{{label}} 편집",
|
||||
"editorTitle": "{{label}}",
|
||||
"save": "약관 저장",
|
||||
"saving": "저장 중…",
|
||||
"saved": "저장 완료",
|
||||
"saveFailed": "저장 실패: {{message}}",
|
||||
"preview": "미리보기",
|
||||
"edit": "편집",
|
||||
"slashHint": "/ 를 입력해 블록 종류를 선택하거나 #, - 를 직접 입력할 수 있습니다.",
|
||||
"slashHeading1": "큰 제목",
|
||||
"slashHeading2": "중간 제목",
|
||||
"slashHeading3": "작은 제목",
|
||||
"slashText": "내용",
|
||||
"slashBullet": "글머리 기호",
|
||||
"slashNumbered": "번호 매기기",
|
||||
"slashToggle": "토글",
|
||||
"slashDivider": "구분선",
|
||||
"slashQuote": "인용",
|
||||
"slashCode": "코드",
|
||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
|
||||
"visibilityHeading": "표시 대상 (중복 선택 가능)",
|
||||
"visibilityInstaller": "설치기에 표시",
|
||||
"visibilityInstallerRp": "리소스팩 설치기에 표시",
|
||||
"visibilityInstallerShort": "설치기",
|
||||
"visibilityInstallerRpShort": "리소스팩",
|
||||
"addHeading": "약관 추가",
|
||||
"kindLabel": "식별자",
|
||||
"kindPlaceholder": "예: privacy",
|
||||
"kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.",
|
||||
"labelLabel": "표시 이름",
|
||||
"labelPlaceholder": "예: 개인정보 처리방침",
|
||||
"addButton": "추가",
|
||||
"deleteButton": "삭제",
|
||||
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
|
||||
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
|
||||
"createFailed": "약관 추가 실패",
|
||||
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.",
|
||||
"importHeading": "다른 음악퀴즈에서 불러오기",
|
||||
"importSourceLabel": "가져올 음악퀴즈",
|
||||
"importSourcePlaceholder": "음악퀴즈를 선택하세요",
|
||||
"importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.",
|
||||
"importButton": "불러오기",
|
||||
"importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.",
|
||||
"importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?",
|
||||
"importFailed": "약관 불러오기 실패",
|
||||
"invalidImportSource": "올바르지 않은 음악퀴즈입니다."
|
||||
},
|
||||
"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 가 너무 많습니다."
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"packs": [
|
||||
{
|
||||
"name": "음악퀴즈 v1",
|
||||
"name": "음악퀴즈_test",
|
||||
"file": "music-quiz"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "음악퀴즈 v1",
|
||||
"mcVersion": "1.20.1",
|
||||
"name": "음악퀴즈_test",
|
||||
"mcVersion": "26.1.2",
|
||||
"platform": {
|
||||
"type": "forge",
|
||||
"downloadUrl": "/forge-installer.jar"
|
||||
"type": "fabric",
|
||||
"downloadUrl": "/fabric-installer.jar"
|
||||
},
|
||||
"modsFolder": "music-quiz",
|
||||
"resourcepackPath": "music-quiz.zip",
|
||||
|
||||
27
manifest/terms/installer-rp.md
Normal file
27
manifest/terms/installer-rp.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 리소스팩 설치기(exe) 안내 및 약관
|
||||
|
||||
**1.** 이 설치기는 리소스팩(음악·사진)의 간편한 빌드 및 설치를 위한 프로그램입니다.
|
||||
- 설치기를 통해 설치되는 리소스팩은 리소스팩 약관을 따릅니다.
|
||||
- 설치기 사용 전 리소스팩 약관을 반드시 확인하세요.
|
||||
|
||||
**2.** 이 설치기는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
|
||||
|
||||
**3.** 설치기에 대한 2차 창작 및 2차 배포는 금지됩니다.
|
||||
- 소스코드의 무단 복제, 수정, 재배포는 엄격히 금지됩니다.
|
||||
- 설치기를 타 플랫폼 또는 제3자에게 재배포하는 행위는 금지됩니다.
|
||||
|
||||
**4.** 설치기의 소스코드는 GitHub를 통해 공개되어 있습니다.
|
||||
- 소스코드 열람은 허용되나, 이를 기반으로 한 파생 프로그램 제작 및 배포는 금지됩니다.
|
||||
- 버그 제보 및 기여는 허용됩니다.
|
||||
|
||||
**5.** 설치기는 음악·이미지 다운로드를 위해 외부 도구(yt-dlp, ffmpeg)를 자동으로 받아 사용합니다. 각 도구는 해당 프로젝트의 라이선스를 따릅니다.
|
||||
- yt-dlp: https://github.com/yt-dlp/yt-dlp
|
||||
- ffmpeg: https://ffmpeg.org/
|
||||
|
||||
**6.** 이 설치기의 저작권은 제작자에게 있으며, 무단 사용 시 저작권법에 의해 처벌받을 수 있습니다.
|
||||
|
||||
Copyright (c) 2026. All rights reserved.
|
||||
|
||||
This software is protected under a Custom License.
|
||||
Unauthorized reproduction, modification, or distribution of this software is strictly prohibited and may result in legal action under applicable copyright laws.
|
||||
29
manifest/terms/installer.md
Normal file
29
manifest/terms/installer.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 설치기(exe) 안내 및 약관
|
||||
|
||||
**1.** 이 설치기는 맵, 모드, 리소스팩의 간편한 설치를 위한 프로그램입니다.
|
||||
- 설치기를 통해 설치되는 각 콘텐츠(맵, 모드, 리소스팩)는 각각의 약관을 따릅니다.
|
||||
- 설치기 사용 전 각 콘텐츠의 약관을 반드시 확인하세요.
|
||||
|
||||
**2.** 이 설치기는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
|
||||
|
||||
**3.** 설치기에 대한 2차 창작 및 2차 배포는 금지됩니다.
|
||||
- 소스코드의 무단 복제, 수정, 재배포는 엄격히 금지됩니다.
|
||||
- 설치기를 타 플랫폼 또는 제3자에게 재배포하는 행위는 금지됩니다.
|
||||
|
||||
**4.** 설치기의 소스코드는 GitHub를 통해 공개되어 있습니다.
|
||||
- 소스코드 열람은 허용되나, 이를 기반으로 한 파생 프로그램 제작 및 배포는 금지됩니다.
|
||||
- 버그 제보 및 기여는 허용됩니다.
|
||||
|
||||
**5.** 설치기에 포함된 외부 모드(Fabric API, Modmenu)는 각 모드의 라이선스를 따르며, 설치기는 해당 모드들을 공식 배포처에서 다운로드합니다.
|
||||
- Fabric API: https://www.curseforge.com/minecraft/mc-mods/fabric-api
|
||||
- Modmenu: https://www.curseforge.com/minecraft/mc-mods/modmenu
|
||||
|
||||
**6.** 이 설치기의 저작권은 제작자에게 있으며, 무단 사용 시 저작권법에 의해 처벌받을 수 있습니다.
|
||||
|
||||
Copyright (c) 2026. All rights reserved.
|
||||
|
||||
This software is protected under a Custom License.
|
||||
Unauthorized reproduction, modification, or distribution of this software is strictly prohibited and may result in legal action under applicable copyright laws.
|
||||
|
||||
All rights reserved (ARR). No part of this software may be reproduced, distributed, or transmitted in any form or by any means without the prior written permission of the copyright holder.
|
||||
22
manifest/terms/map.md
Normal file
22
manifest/terms/map.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 맵(Map) 안내 및 약관
|
||||
|
||||
**1.** 이 맵은 마인크래프트 인게임에서 시스템에 따라 재생되는 노래를 듣고 제목을 맞추는 Windows PC 기반 JE 26.1.2 전용 맵입니다.
|
||||
- 이번 노래퀴즈 주제는 "게임"이며, 임의의 게임 OST/BGM을 듣고 게임의 이름을 맞추어야 합니다.
|
||||
- JE 버전이 다를 경우 플레이가 불가능할 수도 있습니다. 버전을 반드시 확인하세요.
|
||||
|
||||
**2.** 이 맵은 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없으며, 허가를 받을 수도 없습니다.
|
||||
|
||||
**3.** 맵에 대한 2차 창작은 금지합니다. 2차 배포는 이 글을 통하여 배포하되 허가가 필요합니다.
|
||||
- 맵에는 배경용 타 맵 제작자의 라이선스도 포함되어 있습니다. 무단 배포는 엄격히 금지합니다.
|
||||
|
||||
**4.** 맵 플레이에는 50Mbps 이상의 기본적인 인터넷 속도를 요구합니다.
|
||||
- 또한 8코어 이상의 CPU와 16GB 이상의 램 용량을 권장합니다.
|
||||
- 위 사양을 충족하지 못할 경우 원활한 플레이가 어려울 수 있습니다.
|
||||
|
||||
**5.** 맵에는 배경용 제3자의 맵이 사용되었습니다.
|
||||
- 출처: https://www.planetminecraft.com/project/liyue-harbour-from-genshin-impact-in-minecraft-1-1-scale/
|
||||
- 저작자: SkyBlock Squad
|
||||
- 해당 맵은 저작자의 허가를 받아 사용하였습니다.
|
||||
|
||||
This work is licensed under CC BY-NC-ND 4.0
|
||||
19
manifest/terms/mod.md
Normal file
19
manifest/terms/mod.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 모드(Mod) 안내 및 약관
|
||||
|
||||
**1.** 더 향상된 플레이를 위하여 모드가 포함되어 있습니다.
|
||||
- 모드는 설치기를 통하여 자동 설치됩니다.
|
||||
- 자동 설치가 제대로 되지 않을 경우 수동 설치를 권장합니다.
|
||||
|
||||
**2.** Fabric 기반 26.1.2 모드를 사용하였습니다.
|
||||
- 저희가 제작한 chat_answer, video_player 모드는 제작자의 소유입니다.
|
||||
- 두 모드에 대한 2차 창작 및 2차 배포는 금지됩니다.
|
||||
|
||||
**3.** 모드는 반드시 개인이 사용하여야 하며 비영리적인 목적으로 사용하여야 합니다.
|
||||
- 어떠한 경우에도 영리적인 목적으로 사용할 수 없습니다.
|
||||
|
||||
**4.** 원활한 플레이를 위해 Sodium, Iris Shaders 모드를 함께 사용하는 것을 권장합니다.
|
||||
- 최적화 및 쉐이더 적용을 하기 위한 의도이며, 필수 사항은 아닙니다.
|
||||
- Sodium: https://www.curseforge.com/minecraft/mc-mods/sodium
|
||||
- Iris Shaders: https://www.curseforge.com/minecraft/mc-mods/irisshaders
|
||||
|
||||
This work is licensed under CC BY-NC-ND 4.0
|
||||
13
manifest/terms/resourcepack.md
Normal file
13
manifest/terms/resourcepack.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 리소스팩(ResourcePack) 안내 및 약관
|
||||
|
||||
**1.** 리소스팩은 맵 플레이를 위한 필수 요소입니다.
|
||||
- 노래가 나오지 않는 경우 리소스팩의 적용 여부를 반드시 확인하세요.
|
||||
|
||||
**2.** 리소스팩은 절대 2차 창작하거나 2차 배포해서는 안 되며, 어느 누구에게도 전달해서는 안 됩니다.
|
||||
- 영리적인 목적으로 사용할 수 없습니다.
|
||||
- 리소스팩에 포함된 음악의 저작권은 각 원저작자에게 있습니다.
|
||||
- 리소스팩은 이 맵 플레이 전용으로만 사용하여야 합니다.
|
||||
|
||||
Copyright (c) 2026. All rights reserved.
|
||||
All music and audio files included in this resource pack are excluded from this license.
|
||||
The copyright of all such content belongs to their respective original copyright holders.
|
||||
1206
package-lock.json
generated
1206
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.5",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
@@ -8,24 +8,32 @@
|
||||
"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",
|
||||
"build:launcher-icon": "node scripts/build-launcher-icon.cjs",
|
||||
"dist:win": "npm run preinstall:sharp-win32 && npm run build:launcher-icon && 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;
|
||||
}
|
||||
|
||||
106
public/termsEditor.css
Normal file
106
public/termsEditor.css
Normal file
@@ -0,0 +1,106 @@
|
||||
/* Notion 스타일 약관 편집기 전용 스타일.
|
||||
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
|
||||
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
|
||||
* 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */
|
||||
|
||||
.termsEditorWrap {
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.termsEditor {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card, #1f242c);
|
||||
color: var(--text, #e6edf3);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
caret-color: var(--accent, #58a6ff);
|
||||
}
|
||||
|
||||
.termsEditor:focus {
|
||||
border-color: var(--accent, #58a6ff);
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
.termsPreview {
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.termsPreview h1 { font-size: 22px; margin: 12px 0 8px; }
|
||||
.termsPreview h2 { font-size: 18px; margin: 10px 0 6px; }
|
||||
.termsPreview h3 { font-size: 15px; margin: 8px 0 4px; }
|
||||
.termsPreview p { margin: 6px 0; }
|
||||
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
|
||||
.termsPreview li { margin: 2px 0; }
|
||||
.termsPreview hr { border: none; border-top: 1px solid var(--border, #30363d); margin: 12px 0; }
|
||||
.termsPreview blockquote {
|
||||
margin: 8px 0; padding: 4px 12px;
|
||||
border-left: 3px solid var(--border, #30363d);
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
.termsPreview code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 5px; border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.termsPreview pre {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 10px 12px; border-radius: 6px; overflow: auto;
|
||||
}
|
||||
.termsPreview pre code { background: transparent; padding: 0; }
|
||||
.termsPreview a { color: var(--accent, #58a6ff); text-decoration: underline; word-break: break-all; }
|
||||
.termsPreview details {
|
||||
margin: 6px 0;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card, #1f242c);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
|
||||
|
||||
/* 슬래시 자동완성 메뉴 — 노션 느낌으로 caret 좌표 위에 띄움. */
|
||||
.slashMenu {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
min-width: 220px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.slashMenu .slashItem {
|
||||
display: flex; flex-direction: column;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slashMenu .slashItem:hover,
|
||||
.slashMenu .slashItem.active {
|
||||
background: var(--bg-card, #1f242c);
|
||||
}
|
||||
.slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); }
|
||||
.slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; }
|
||||
394
public/termsEditor.js
Normal file
394
public/termsEditor.js
Normal file
@@ -0,0 +1,394 @@
|
||||
/* 약관(Markdown) 편집기.
|
||||
* - 기본은 textarea: 사용자가 직접 #, - 등을 입력할 수 있다.
|
||||
* - "/" 를 줄 맨 앞 또는 빈 공간 다음에 입력하면 슬래시 메뉴를 띄워
|
||||
* 제목/내용/글머리/번호/토글/구분선/인용/코드 블록을 선택해 자동 삽입한다.
|
||||
* (사용자가 #, - 같은 기호를 외울 필요 없이 명령어로 입력 가능)
|
||||
* - 미리보기 탭에서 작은 markdown → HTML 렌더러로 결과를 보여 준다.
|
||||
*/
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
var editor = document.getElementById('editor')
|
||||
var preview = document.getElementById('preview')
|
||||
var slashMenu = document.getElementById('slashMenu')
|
||||
var status = document.getElementById('status')
|
||||
var dirtyMark = document.getElementById('dirty-mark')
|
||||
var saveBtn = document.getElementById('saveBtn')
|
||||
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
||||
var visInstaller = document.getElementById('visInstaller')
|
||||
var visInstallerRp = document.getElementById('visInstallerRp')
|
||||
|
||||
editor.value = INITIAL || ''
|
||||
var dirty = false
|
||||
function setDirty(v) {
|
||||
dirty = v
|
||||
dirtyMark.hidden = !v
|
||||
}
|
||||
|
||||
// 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다.
|
||||
if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) })
|
||||
if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) })
|
||||
|
||||
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
||||
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
||||
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function inline(s) {
|
||||
s = escHtml(s)
|
||||
// code `x`
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// bold **x**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
// italic *x*
|
||||
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
||||
// links [text](url) — also auto-link bare http(s)
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
|
||||
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
function renderMd(src) {
|
||||
var lines = src.replace(/\r\n/g, '\n').split('\n')
|
||||
var out = []
|
||||
var i = 0
|
||||
var stackList = null // 'ul' | 'ol' | null
|
||||
function closeList() { if (stackList) { out.push('</' + stackList + '>'); stackList = null } }
|
||||
while (i < lines.length) {
|
||||
var line = lines[i]
|
||||
// 코드 블록 ```lang
|
||||
var fence = /^```(\w*)\s*$/.exec(line)
|
||||
if (fence) {
|
||||
closeList()
|
||||
var code = []
|
||||
i += 1
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
||||
code.push(lines[i]); i += 1
|
||||
}
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
|
||||
continue
|
||||
}
|
||||
// 토글 (자체 구문) :::toggle 제목 ... :::
|
||||
var togStart = /^:::toggle\s+(.+)$/.exec(line)
|
||||
if (togStart) {
|
||||
closeList()
|
||||
var summary = togStart[1]
|
||||
var body = []
|
||||
i += 1
|
||||
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
|
||||
body.push(lines[i]); i += 1
|
||||
}
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<details><summary>' + inline(summary) + '</summary>' + renderMd(body.join('\n')) + '</details>')
|
||||
continue
|
||||
}
|
||||
// 헤딩
|
||||
var h = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (h) {
|
||||
closeList()
|
||||
var level = h[1].length
|
||||
out.push('<h' + level + '>' + inline(h[2]) + '</h' + level + '>')
|
||||
i += 1; continue
|
||||
}
|
||||
// hr
|
||||
if (/^---+\s*$/.test(line)) {
|
||||
closeList()
|
||||
out.push('<hr />'); i += 1; continue
|
||||
}
|
||||
// 인용 >
|
||||
if (/^>\s?/.test(line)) {
|
||||
closeList()
|
||||
var q = []
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
||||
q.push(lines[i].replace(/^>\s?/, '')); i += 1
|
||||
}
|
||||
out.push('<blockquote>' + renderMd(q.join('\n')) + '</blockquote>')
|
||||
continue
|
||||
}
|
||||
// 번호 목록
|
||||
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
|
||||
if (ol) {
|
||||
if (stackList !== 'ol') { closeList(); out.push('<ol>'); stackList = 'ol' }
|
||||
out.push('<li>' + inline(ol[1]) + '</li>')
|
||||
i += 1; continue
|
||||
}
|
||||
// 불릿
|
||||
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
|
||||
if (ul) {
|
||||
if (stackList !== 'ul') { closeList(); out.push('<ul>'); stackList = 'ul' }
|
||||
out.push('<li>' + inline(ul[1]) + '</li>')
|
||||
i += 1; continue
|
||||
}
|
||||
// 빈 줄
|
||||
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
|
||||
// 일반 문단
|
||||
closeList()
|
||||
var para = [line]; i += 1
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i])
|
||||
&& !/^(#{1,6})\s+/.test(lines[i])
|
||||
&& !/^\s*[-*]\s+/.test(lines[i])
|
||||
&& !/^\s*\d+\.\s+/.test(lines[i])
|
||||
&& !/^>/.test(lines[i])
|
||||
&& !/^---+\s*$/.test(lines[i])
|
||||
&& !/^```/.test(lines[i])
|
||||
&& !/^:::/.test(lines[i])) {
|
||||
para.push(lines[i]); i += 1
|
||||
}
|
||||
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
|
||||
}
|
||||
closeList()
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
function refreshPreview() {
|
||||
preview.innerHTML = renderMd(editor.value)
|
||||
}
|
||||
|
||||
// ─── 탭 전환 (edit / preview) ────────────────────────────────────────
|
||||
tabBtns.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
tabBtns.forEach(function (x) { x.classList.remove('active') })
|
||||
b.classList.add('active')
|
||||
var mode = b.getAttribute('data-mode')
|
||||
if (mode === 'preview') {
|
||||
refreshPreview()
|
||||
editor.hidden = true
|
||||
preview.hidden = false
|
||||
} else {
|
||||
editor.hidden = false
|
||||
preview.hidden = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 저장 ───────────────────────────────────────────────────────────
|
||||
function save() {
|
||||
status.classList.remove('error')
|
||||
status.textContent = I18N.saving
|
||||
var payload = { content: editor.value }
|
||||
if (visInstaller) payload.showInInstaller = !!visInstaller.checked
|
||||
if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked
|
||||
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error((res.body && res.body.message) || 'failed')
|
||||
setDirty(false)
|
||||
status.textContent = I18N.saved
|
||||
}).catch(function (err) {
|
||||
status.classList.add('error')
|
||||
status.textContent = I18N.saveFailed.replace('{{message}}', err.message)
|
||||
})
|
||||
}
|
||||
saveBtn.addEventListener('click', save)
|
||||
|
||||
// Ctrl+S 저장
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault(); save()
|
||||
}
|
||||
})
|
||||
|
||||
// 페이지 떠나기 가드
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (!dirty) return
|
||||
e.preventDefault()
|
||||
e.returnValue = I18N.leaveConfirm
|
||||
return I18N.leaveConfirm
|
||||
})
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
setDirty(true)
|
||||
})
|
||||
|
||||
// ─── 슬래시 자동완성 ─────────────────────────────────────────────────
|
||||
// 정의: { label, hint, insert: 줄 시작에 들어갈 텍스트 (커서 위치는 |로 표시) }
|
||||
var SLASH_ITEMS = [
|
||||
{ label: I18N.slashHeading1, hint: '# ', insert: '# |' },
|
||||
{ label: I18N.slashHeading2, hint: '## ', insert: '## |' },
|
||||
{ label: I18N.slashHeading3, hint: '### ', insert: '### |' },
|
||||
{ label: I18N.slashText, hint: '', insert: '|' },
|
||||
{ label: I18N.slashBullet, hint: '- ', insert: '- |' },
|
||||
{ label: I18N.slashNumbered, hint: '1. ', insert: '1. |' },
|
||||
{ label: I18N.slashToggle, hint: ':::toggle 제목 ... :::', insert: ':::toggle 제목\n|\n:::' },
|
||||
{ label: I18N.slashDivider, hint: '---', insert: '---\n|' },
|
||||
{ label: I18N.slashQuote, hint: '> ', insert: '> |' },
|
||||
{ label: I18N.slashCode, hint: '```', insert: '```\n|\n```' }
|
||||
]
|
||||
|
||||
var slashState = null // { startPos: number, query: string, activeIndex: number, filtered: [] }
|
||||
|
||||
function renderSlashItems(filtered) {
|
||||
slashMenu.innerHTML = ''
|
||||
filtered.forEach(function (item, idx) {
|
||||
var el = document.createElement('div')
|
||||
el.className = 'slashItem' + (idx === slashState.activeIndex ? ' active' : '')
|
||||
var strong = document.createElement('strong')
|
||||
strong.textContent = item.label
|
||||
var span = document.createElement('span')
|
||||
span.textContent = item.hint || ''
|
||||
el.appendChild(strong); el.appendChild(span)
|
||||
el.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault()
|
||||
applySlash(item)
|
||||
})
|
||||
slashMenu.appendChild(el)
|
||||
})
|
||||
}
|
||||
|
||||
function positionSlash() {
|
||||
// textarea caret 좌표 근사: 보이지 않는 mirror div 를 만들어 caret 위치를 추정한다.
|
||||
var rect = editor.getBoundingClientRect()
|
||||
var wrapRect = editor.parentElement.getBoundingClientRect()
|
||||
var caret = getCaretCoords(editor)
|
||||
var top = caret.top + 22 + (rect.top - wrapRect.top) - editor.scrollTop
|
||||
var left = caret.left + (rect.left - wrapRect.left)
|
||||
slashMenu.style.top = top + 'px'
|
||||
slashMenu.style.left = left + 'px'
|
||||
}
|
||||
|
||||
function openSlash() {
|
||||
slashState = {
|
||||
startPos: editor.selectionStart - 1, // '/' 위치
|
||||
query: '',
|
||||
activeIndex: 0,
|
||||
filtered: SLASH_ITEMS.slice()
|
||||
}
|
||||
renderSlashItems(slashState.filtered)
|
||||
slashMenu.hidden = false
|
||||
positionSlash()
|
||||
}
|
||||
function closeSlash() {
|
||||
slashState = null
|
||||
slashMenu.hidden = true
|
||||
}
|
||||
|
||||
function applySlash(item) {
|
||||
if (!slashState) return
|
||||
var value = editor.value
|
||||
var start = slashState.startPos
|
||||
var end = editor.selectionStart
|
||||
// 줄의 시작 위치 계산 (이미 '#', '- ' 같은 prefix 가 있어도 무시하고 새 prefix 로 교체)
|
||||
var lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
var lineEnd = value.indexOf('\n', end)
|
||||
if (lineEnd === -1) lineEnd = value.length
|
||||
var beforeLine = value.slice(0, lineStart)
|
||||
var afterLine = value.slice(lineEnd)
|
||||
var currentLine = value.slice(lineStart, lineEnd)
|
||||
// 줄 안에서 '/검색어' 부분을 제거하고, 나머지 텍스트를 prefix 뒤에 이어 붙인다.
|
||||
var rest = currentLine.slice(0, start - lineStart) + currentLine.slice(end - lineStart)
|
||||
var insert = item.insert
|
||||
var caretMarker = insert.indexOf('|')
|
||||
var inserted = insert.replace('|', rest)
|
||||
editor.value = beforeLine + inserted + afterLine
|
||||
var caretPos = (beforeLine + insert.slice(0, caretMarker)).length
|
||||
editor.selectionStart = editor.selectionEnd = caretPos
|
||||
closeSlash()
|
||||
setDirty(true)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
editor.addEventListener('keydown', function (e) {
|
||||
if (slashState) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
slashState.activeIndex = (slashState.activeIndex + 1) % slashState.filtered.length
|
||||
renderSlashItems(slashState.filtered)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
slashState.activeIndex = (slashState.activeIndex - 1 + slashState.filtered.length) % slashState.filtered.length
|
||||
renderSlashItems(slashState.filtered)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (slashState.filtered.length > 0) {
|
||||
e.preventDefault()
|
||||
applySlash(slashState.filtered[slashState.activeIndex])
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeSlash()
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
editor.addEventListener('input', function (e) {
|
||||
var pos = editor.selectionStart
|
||||
var ch = editor.value.slice(pos - 1, pos)
|
||||
if (!slashState && ch === '/') {
|
||||
// 줄 시작 또는 공백 다음에서만 슬래시 메뉴 활성화
|
||||
var prev = pos >= 2 ? editor.value.slice(pos - 2, pos - 1) : '\n'
|
||||
if (prev === '\n' || prev === ' ' || pos === 1) {
|
||||
openSlash()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (slashState) {
|
||||
var startPos = slashState.startPos
|
||||
if (pos < startPos || editor.value[startPos] !== '/') {
|
||||
closeSlash()
|
||||
return
|
||||
}
|
||||
var q = editor.value.slice(startPos + 1, pos).toLowerCase()
|
||||
slashState.query = q
|
||||
slashState.filtered = SLASH_ITEMS.filter(function (it) {
|
||||
if (!q) return true
|
||||
return it.label.toLowerCase().indexOf(q) !== -1
|
||||
|| (it.hint && it.hint.toLowerCase().indexOf(q) !== -1)
|
||||
})
|
||||
slashState.activeIndex = 0
|
||||
renderSlashItems(slashState.filtered)
|
||||
positionSlash()
|
||||
}
|
||||
})
|
||||
|
||||
editor.addEventListener('blur', function () {
|
||||
// mousedown on menu uses e.preventDefault → blur 시에도 안전하게 닫는다.
|
||||
setTimeout(closeSlash, 100)
|
||||
})
|
||||
|
||||
// ─── caret 좌표 계산 (mirror div 기법) ───────────────────────────────
|
||||
function getCaretCoords(el) {
|
||||
var div = document.createElement('div')
|
||||
var s = getComputedStyle(el)
|
||||
var props = [
|
||||
'boxSizing','width','height','overflowX','overflowY',
|
||||
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
|
||||
'paddingTop','paddingRight','paddingBottom','paddingLeft',
|
||||
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust',
|
||||
'lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration',
|
||||
'letterSpacing','wordSpacing','tabSize','MozTabSize','whiteSpace'
|
||||
]
|
||||
div.style.position = 'absolute'
|
||||
div.style.visibility = 'hidden'
|
||||
div.style.whiteSpace = 'pre-wrap'
|
||||
div.style.wordWrap = 'break-word'
|
||||
props.forEach(function (p) { div.style[p] = s[p] })
|
||||
div.style.position = 'absolute'
|
||||
div.style.top = '0'
|
||||
div.style.left = '0'
|
||||
var rect = el.getBoundingClientRect()
|
||||
document.body.appendChild(div)
|
||||
var pos = el.selectionStart
|
||||
var before = el.value.substring(0, pos)
|
||||
div.textContent = before
|
||||
var span = document.createElement('span')
|
||||
span.textContent = el.value.substring(pos) || '.'
|
||||
div.appendChild(span)
|
||||
var top = span.offsetTop + parseInt(s.borderTopWidth, 10)
|
||||
var left = span.offsetLeft + parseInt(s.borderLeftWidth, 10)
|
||||
document.body.removeChild(div)
|
||||
return { top: top, left: left }
|
||||
}
|
||||
})()
|
||||
33
scripts/build-launcher-icon.cjs
Normal file
33
scripts/build-launcher-icon.cjs
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
// build/icon.png 을 읽어 base64 data URL 로 변환해
|
||||
// src/installer/launcherIcon.ts 에 상수로 박는다.
|
||||
//
|
||||
// 마인크래프트 런처의 "설치 설정" 화면 프로필 아이콘은
|
||||
// launcher_profiles.json 의 profile.icon 필드에서 오는데,
|
||||
// `data:image/png;base64,...` 형태의 data URL 을 받는다.
|
||||
// build/ 폴더는 electron-builder 가 exe 아이콘으로만 쓰고 asar 에
|
||||
// 포함되지 않아서, 런타임에 그 파일을 읽을 수 없다. 대신 빌드(개발) 시점에
|
||||
// 이 스크립트를 돌려 PNG 를 소스 코드에 인라인한다.
|
||||
|
||||
'use strict'
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..')
|
||||
const pngPath = path.join(repoRoot, 'build', 'icon.png')
|
||||
const tsPath = path.join(repoRoot, 'src', 'installer', 'launcherIcon.ts')
|
||||
|
||||
const buf = fs.readFileSync(pngPath)
|
||||
const b64 = buf.toString('base64')
|
||||
|
||||
const ts = `// AUTO-GENERATED by scripts/build-launcher-icon.cjs from build/icon.png.
|
||||
// 마인크래프트 런처의 "설치 설정" 화면에서 보이는 프로필 아이콘. exe 와 같은
|
||||
// 이미지를 쓰기 위해 빌드 시점에 PNG 를 data URL 로 인라인한다. 변경하려면
|
||||
// build/icon.png 교체 후 \`node scripts/build-launcher-icon.cjs\` 재실행.
|
||||
export const LAUNCHER_PROFILE_ICON =
|
||||
'data:image/png;base64,${b64}'
|
||||
`
|
||||
|
||||
fs.writeFileSync(tsPath, ts, 'utf8')
|
||||
console.log(`wrote ${tsPath} (${buf.length} bytes PNG → ${b64.length} chars base64)`)
|
||||
168
src/installer-rp/ffmpeg.ts
Normal file
168
src/installer-rp/ffmpeg.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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, getMcCustomInstallerDir } 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/installer/ffmpeg.exe
|
||||
*/
|
||||
export function getFfmpegExePath(): string {
|
||||
return path.join(getMcCustomInstallerDir(), 'ffmpeg.exe')
|
||||
}
|
||||
|
||||
/**
|
||||
* 0.2.1 이전 버전이 `.mc_custom/ffmpeg.exe` 에 받아둔 파일이 있으면 새 위치로
|
||||
* 옮긴다.
|
||||
*/
|
||||
async function migrateLegacyExe(target: string): Promise<void> {
|
||||
const legacy = path.join(getMcCustomDir(), 'ffmpeg.exe')
|
||||
if (legacy === target) return
|
||||
try {
|
||||
await fs.access(legacy, fsConst.F_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.rename(legacy, target)
|
||||
} catch {
|
||||
try { await fs.unlink(legacy) } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
await migrateLegacyExe(target)
|
||||
if (await canExecute(target)) {
|
||||
log?.(t('log.ffmpegExists', { path: target }))
|
||||
return target
|
||||
}
|
||||
if (installPromise) return installPromise
|
||||
|
||||
installPromise = (async () => {
|
||||
const dir = getMcCustomInstallerDir()
|
||||
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`
|
||||
}
|
||||
533
src/installer-rp/main.ts
Normal file
533
src/installer-rp/main.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
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>
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수
|
||||
* 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는
|
||||
* 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 →
|
||||
* 호출 측에서 폴백을 결정한다.
|
||||
*/
|
||||
function sanitizeOutputPackName(name: string): string {
|
||||
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||
cleaned = cleaned.replace(/[ .]+$/, '')
|
||||
if (!cleaned) return ''
|
||||
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* 동시 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 ?? ''
|
||||
const outputPackName = normalized?.outputPackName ?? ''
|
||||
results.push({
|
||||
key: entry.file,
|
||||
name: entry.name || entry.file,
|
||||
mcVersion,
|
||||
resourcepackPath,
|
||||
outputPackName,
|
||||
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: 약관 다운로드 ──────────────────────────────
|
||||
// v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신
|
||||
// kind 형식만 검증한다. 어떤 약관을 rp 인스톨러에 보여줄지는 사이트의 visibility 토글이 결정.
|
||||
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
||||
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
|
||||
return { ok: false, message: 'invalid term kind' }
|
||||
}
|
||||
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||
try {
|
||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
|
||||
const buf = await fetchBuffer(url)
|
||||
return { ok: true, content: buf.toString('utf8') }
|
||||
} catch (error) {
|
||||
return { ok: false, message: (error as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// rp 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
|
||||
// showInInstallerRp=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
|
||||
ipcMain.handle('rp:terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
|
||||
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||
try {
|
||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
|
||||
const buf = await fetchBuffer(url)
|
||||
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
|
||||
const items = Array.isArray(parsed.terms) ? parsed.terms : []
|
||||
const terms: Array<{ kind: string; label: string }> = []
|
||||
for (const it of items) {
|
||||
if (!it || typeof it !== 'object') continue
|
||||
const entry = it as Record<string, unknown>
|
||||
if (entry.showInInstallerRp !== true) continue
|
||||
const kind = typeof entry.kind === 'string' ? entry.kind : ''
|
||||
const label = typeof entry.label === 'string' ? entry.label : ''
|
||||
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
|
||||
terms.push({ kind, label })
|
||||
}
|
||||
return { ok: true, terms }
|
||||
} catch (error) {
|
||||
return { ok: false, message: (error as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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()
|
||||
// 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize
|
||||
// 결과가 빈 문자열이면 `<packKey>_resourcepack` 로 폴백.
|
||||
const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName)
|
||||
const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack`
|
||||
const resourcepackName = `${resourcepackBaseName}.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,
|
||||
// build 내부에서도 단계 사이/zip 도중에 폴링해서 취소를 빠르게 반영한다.
|
||||
cancelChecker: () => state.cancelRequested
|
||||
})
|
||||
throwIfCancelled()
|
||||
|
||||
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
|
||||
sendLog(t('log.installComplete', { path: resourcepackPath }))
|
||||
|
||||
// 2-7. 베이스 리소스팩은 우리가 임시폴더에 받아서 빌드에 이미 얹었으므로,
|
||||
// 메인 설치기가 `.mc_custom/resourcepacks/<resourcepackPath>` 에 받아둔
|
||||
// 원본 zip 은 MC 리소스팩 목록에 굳이 남길 필요 없다. 삭제하되, 사용자가
|
||||
// outputPackName 을 base 파일명과 똑같이 둬서 우리가 방금 쓴 최종 zip 과
|
||||
// 같은 경로면 그대로 둔다(우리 산출물을 지우면 안 되므로).
|
||||
if (pack.resourcepackPath) {
|
||||
const basePackPath = path.join(resourcepackDir, pack.resourcepackPath)
|
||||
if (path.resolve(basePackPath) !== path.resolve(resourcepackPath)) {
|
||||
try {
|
||||
await fsp.rm(basePackPath, { force: true })
|
||||
sendLog(t('log.baseRemoved', { path: basePackPath }))
|
||||
} catch { /* 없으면 무시 */ }
|
||||
}
|
||||
}
|
||||
|
||||
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 })))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
248
src/installer-rp/pack.ts
Normal file
248
src/installer-rp/pack.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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
|
||||
/**
|
||||
* 사용자 취소 신호. true 가 되면 가능한 시점에 build 를 중단한다.
|
||||
* - 단계 사이 (extract → meta → 음악 복사 → painting 복사 → zip) 폴링.
|
||||
* - zip 생성 중에도 폴링해서 archive.abort() 로 끊는다.
|
||||
* 호출자는 후속 처리에서 임시 폴더와 부분 zip 파일을 정리해야 한다.
|
||||
*/
|
||||
cancelChecker?: () => boolean
|
||||
}
|
||||
|
||||
/** cancelChecker 가 true 를 반환하면 던지는 에러. main 쪽 에러 처리와 동일한 메시지를 쓰지 않고,
|
||||
* 명시적인 클래스 마커로 식별하기 쉽게 한다. 메시지는 i18n 의 errors.cancelledByUser 와 1:1. */
|
||||
class CancelledError extends Error {
|
||||
constructor() {
|
||||
super(t('errors.cancelledByUser'))
|
||||
this.name = 'CancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfCancelled(checker?: () => boolean): void {
|
||||
if (checker && checker()) throw new CancelledError()
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 폴더에 리소스팩 트리를 펼치고, 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 cancel = opts.cancelChecker
|
||||
throwIfCancelled(cancel)
|
||||
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 })
|
||||
throwIfCancelled(cancel)
|
||||
}
|
||||
|
||||
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 생성/병합
|
||||
// 핵심 정책: 베이스 리소스팩에 이미 있는 자산은 절대 덮어쓰지 않는다.
|
||||
// - 베이스 sounds.json 의 엔트리는 그대로 보존하고, 우리 트랙은 그 위에 "추가" 만 한다.
|
||||
// - 베이스 sounds/track_NN.ogg 가 이미 있으면 덮어쓰지 않고 건너뛴다.
|
||||
// - 키나 파일명이 충돌하면 우리 트랙을 스킵하고 로그로 알린다.
|
||||
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) {
|
||||
throwIfCancelled(cancel)
|
||||
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
|
||||
const stem = path.basename(fname, path.extname(fname)) // "01"
|
||||
const trackId = `track_${stem}`
|
||||
const destFile = path.join(soundsDir, `${trackId}.ogg`)
|
||||
// 베이스에 같은 trackId 의 엔트리/파일이 있으면 두 선택지 다 깨진다:
|
||||
// (a) 덮어쓰면 베이스의 기존 곡이 사라지고,
|
||||
// (b) 새 곡을 스킵하면 데이터팩이 가리키는 곡이 빠진 채로 설치된다.
|
||||
// 안전하게 설치를 즉시 실패시키고 어떤 키가 충돌했는지 알린다.
|
||||
let collides = soundsJson[trackId] !== undefined
|
||||
if (!collides) {
|
||||
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
|
||||
}
|
||||
if (collides) {
|
||||
throw new Error(t('errors.baseTrackCollision', { trackId }))
|
||||
}
|
||||
await fs.copyFile(path.join(opts.musicDir, fname), destFile)
|
||||
soundsJson[trackId] = {
|
||||
sounds: [
|
||||
{ name: `${NAMESPACE}:${trackId}`, stream: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
|
||||
opts.log?.(t('log.tracksAdded', { count: musicFiles.length }))
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태).
|
||||
// 음악과 동일한 정책: 베이스에 같은 파일명이 이미 있으면 설치를 실패시킨다.
|
||||
const paintingFiles = (await fs.readdir(opts.paintingDir))
|
||||
.filter((n) => n.toLowerCase().endsWith('.png'))
|
||||
.sort()
|
||||
for (const fname of paintingFiles) {
|
||||
throwIfCancelled(cancel)
|
||||
const destFile = path.join(paintingOutDir, fname)
|
||||
let collides = false
|
||||
try { await fs.access(destFile); collides = true } catch { /* 없음 → OK */ }
|
||||
if (collides) {
|
||||
throw new Error(t('errors.basePaintingCollision', { name: fname }))
|
||||
}
|
||||
await fs.copyFile(path.join(opts.paintingDir, fname), destFile)
|
||||
}
|
||||
opts.log?.(t('log.paintingsAdded', { count: paintingFiles.length }))
|
||||
throwIfCancelled(cancel)
|
||||
|
||||
// 4) zip 으로 묶기. 이 단계가 가장 길어서 별도로 cancel 폴링이 들어간다.
|
||||
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
|
||||
await zipDirectory(root, opts.outZipPath, cancel)
|
||||
// zip 빌드가 끝난 직후에도 한 번 더 확인: 마지막 순간 취소가 들어왔을 수 있다.
|
||||
if (cancel && cancel()) {
|
||||
// 부분 zip 파일이 디스크에 남아있을 수 있으니 삭제.
|
||||
await fs.rm(opts.outZipPath, { force: true })
|
||||
throw new CancelledError()
|
||||
}
|
||||
|
||||
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
|
||||
}
|
||||
|
||||
function zipDirectory(srcDir: string, outZipPath: string, cancelChecker?: () => boolean): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = createWriteStream(outZipPath)
|
||||
const archive = archiver('zip', { zlib: { level: 9 } })
|
||||
// 취소 폴링: archiver 자체는 abort() 후 'error' 이벤트로 ABORT 코드를 던진다.
|
||||
// 200ms 간격이면 사용자 체감으로는 즉각적이면서 CPU 부담은 없다.
|
||||
let interval: NodeJS.Timeout | null = null
|
||||
let aborted = false
|
||||
if (cancelChecker) {
|
||||
interval = setInterval(() => {
|
||||
if (cancelChecker() && !aborted) {
|
||||
aborted = true
|
||||
try { archive.abort() } catch { /* 이미 끝났거나 abort 불가 상태 */ }
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
function cleanup() {
|
||||
if (interval) { clearInterval(interval); interval = null }
|
||||
}
|
||||
output.on('close', () => { cleanup(); if (aborted) reject(new CancelledError()); else resolve() })
|
||||
output.on('error', (err) => { cleanup(); reject(err) })
|
||||
archive.on('warning', (err: Error & { code?: string }) => {
|
||||
// ENOENT 정도면 무시, 그 외는 reject.
|
||||
if (err.code === 'ENOENT') return
|
||||
cleanup(); reject(err)
|
||||
})
|
||||
archive.on('error', (err: Error & { code?: string }) => {
|
||||
cleanup()
|
||||
if (err.code === 'ABORT' || aborted) reject(new CancelledError())
|
||||
else reject(err)
|
||||
})
|
||||
archive.pipe(output)
|
||||
archive.directory(srcDir, false)
|
||||
archive.finalize().catch((err) => { cleanup(); reject(err) })
|
||||
})
|
||||
}
|
||||
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 }
|
||||
}
|
||||
57
src/installer-rp/preload.ts
Normal file
57
src/installer-rp/preload.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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),
|
||||
|
||||
/** 약관(Markdown) 다운로드. v0.3.4~ : 임의 kind 허용 (사이트에서 설정). */
|
||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||
ipcRenderer.invoke('rp:terms:get', kind),
|
||||
|
||||
/** rp 인스톨러에 표시할 약관 목록 (사이트의 visibility 토글로 필터링). */
|
||||
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
|
||||
ipcRenderer.invoke('rp:terms:list'),
|
||||
|
||||
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 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
|
||||
}
|
||||
}
|
||||
28
src/installer-rp/types.ts
Normal file
28
src/installer-rp/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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
|
||||
/**
|
||||
* /manifest/<key>.json 의 outputPackName. 관리 사이트에서 설정한 "생성되는
|
||||
* 리소스팩 이름". 비어 있으면 설치기가 `<key>_resourcepack` 형식으로 폴백.
|
||||
* 파일명으로 쓰기 전에 Windows 금지 문자(\<\>:"/\\|?*) 는 `_` 로 치환.
|
||||
*/
|
||||
outputPackName: string
|
||||
/** /file/list/<key>.json 의 음악·사진 목록. */
|
||||
list: PackList
|
||||
}
|
||||
|
||||
export interface RpInstallProgress {
|
||||
step: 'yt-dlp' | 'music' | 'image' | 'package' | 'place'
|
||||
index?: number
|
||||
total?: number
|
||||
message?: string
|
||||
}
|
||||
135
src/installer-rp/ytdlp.ts
Normal file
135
src/installer-rp/ytdlp.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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, getMcCustomInstallerDir } from '../shared/paths.js'
|
||||
import { loadComponentI18n } from '../shared/i18n.js'
|
||||
|
||||
const { t } = loadComponentI18n('installer-rp')
|
||||
|
||||
/**
|
||||
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
|
||||
* 경로: %appdata%/.mc_custom/installer/yt-dlp.exe
|
||||
*/
|
||||
export function getYtDlpExePath(): string {
|
||||
return path.join(getMcCustomInstallerDir(), 'yt-dlp.exe')
|
||||
}
|
||||
|
||||
/**
|
||||
* 0.2.1 이전 버전이 `.mc_custom/yt-dlp.exe` 에 받아둔 파일이 있으면 새 위치로
|
||||
* 옮긴다. 마인크래프트 게임 폴더 루트가 외부 도구 파일로 더럽혀지지 않도록.
|
||||
*/
|
||||
async function migrateLegacyExe(target: string): Promise<void> {
|
||||
const legacy = path.join(getMcCustomDir(), 'yt-dlp.exe')
|
||||
if (legacy === target) return
|
||||
try {
|
||||
await fs.access(legacy, fsConst.F_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.rename(legacy, target)
|
||||
} catch {
|
||||
// 권한·드라이브 문제 등으로 실패하면 그냥 새로 받으면 되므로 무시.
|
||||
try { await fs.unlink(legacy) } catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
await migrateLegacyExe(target)
|
||||
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)
|
||||
})
|
||||
}
|
||||
6
src/installer/launcherIcon.ts
Normal file
6
src/installer/launcherIcon.ts
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,23 @@
|
||||
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),
|
||||
setSelectedPack: (packKey: string): Promise<void> =>
|
||||
ipcRenderer.invoke('packs:select', packKey),
|
||||
|
||||
// 약관(Markdown) 다운로드
|
||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||
ipcRenderer.invoke('terms:get', kind),
|
||||
// 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨)
|
||||
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
|
||||
ipcRenderer.invoke('terms:list'),
|
||||
|
||||
// 3-1
|
||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
||||
validateInstallPath: (target: string): Promise<{ ok: boolean; message?: string }> =>
|
||||
@@ -15,6 +25,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 +57,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,19 @@ 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, manifestTermsDirPath,
|
||||
fileDirPath, viewsDirPath, publicDirPath
|
||||
} from '../shared/paths.js'
|
||||
import {
|
||||
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
|
||||
} from '../shared/store.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 +30,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,
|
||||
@@ -47,6 +65,60 @@ app.get('/manifest.json', (_req, res) => {
|
||||
res.sendFile(manifestRootPath)
|
||||
})
|
||||
|
||||
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
||||
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||
//
|
||||
// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
|
||||
// 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1
|
||||
// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가
|
||||
// 생성되는 것은 loadPackDefinition 으로 차단.
|
||||
// 설치기가 자기에게 표시할 약관 목록을 받아갈 수 있도록 packKey 별 index.json.
|
||||
// 응답: [{ kind, label, showInInstaller, showInInstallerRp }]. v0.3.4~ builtin 개념이
|
||||
// 없어졌으므로 인스톨러는 이 목록을 받아 자기 인스톨러용(`showInInstaller` / `showInInstallerRp`)
|
||||
// 으로 필터링해서 탭을 만든다.
|
||||
app.get('/manifest/terms/:packKey/index.json', async (req, res, next) => {
|
||||
try {
|
||||
const { packKey } = req.params
|
||||
if (!/^[a-zA-Z0-9_\-]+$/.test(packKey)) {
|
||||
res.status(404).json({ terms: [] })
|
||||
return
|
||||
}
|
||||
const pack = await loadPackDefinition(packKey)
|
||||
if (!pack) {
|
||||
res.status(404).json({ terms: [] })
|
||||
return
|
||||
}
|
||||
const terms = await listTermsWithLabels(packKey)
|
||||
res.json({ terms })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => {
|
||||
try {
|
||||
const { packKey, fileName } = req.params
|
||||
if (!isPublicTermsFile(packKey, fileName)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
const pack = await loadPackDefinition(packKey)
|
||||
if (!pack) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
await ensurePackTermsDir(packKey)
|
||||
res.type('text/markdown; charset=utf-8')
|
||||
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
|
||||
if (!err || res.headersSent) return
|
||||
res.status(404).send('Not Found')
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
||||
// 디렉토리 리스팅, 다른 확장자, 경로 탈출은 차단.
|
||||
app.get('/manifest/:fileName', (req, res) => {
|
||||
@@ -101,8 +173,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,33 @@
|
||||
import { Router } from 'express'
|
||||
import archiver from 'archiver'
|
||||
import {
|
||||
createPack,
|
||||
createTerm,
|
||||
deletePackKeys,
|
||||
deleteTerm,
|
||||
getTermEntry,
|
||||
importTerms,
|
||||
isTermKind,
|
||||
listPackKeys,
|
||||
listTermsWithLabels,
|
||||
loadPackDefinition,
|
||||
loadPackList,
|
||||
loadTerm,
|
||||
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,
|
||||
saveTerm,
|
||||
savePackList,
|
||||
setTermVisibility
|
||||
} 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 +58,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 +118,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 +133,358 @@ 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)
|
||||
}
|
||||
})
|
||||
|
||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
||||
// 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만
|
||||
// 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는
|
||||
// /manifest/terms/<packKey>/index.json 으로 자신에게 표시할 약관 목록을 받는다.
|
||||
|
||||
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
||||
opRouter.get('/op/agreement', 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/terms', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가/불러오기/삭제.
|
||||
opRouter.get('/op/agreement/: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 items = await listTermsWithLabels(packKey)
|
||||
// 불러오기 source 후보: 현재 pack 을 제외한 나머지.
|
||||
const allKeys = await listPackKeys()
|
||||
const sourceCandidates = await Promise.all(
|
||||
allKeys
|
||||
.filter((k) => k !== packKey)
|
||||
.map(async (k) => ({ key: k, definition: await loadPackDefinition(k) }))
|
||||
)
|
||||
res.render('op/terms-pack', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
items,
|
||||
sourceCandidates
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/create', 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 kindInput = pickFirstValue(req.body.kind).trim().toLowerCase()
|
||||
const label = pickFirstValue(req.body.label)
|
||||
if (!isTermKind(kindInput)) {
|
||||
res.status(400).send(t('terms.invalidKind'))
|
||||
return
|
||||
}
|
||||
await createTerm(packKey, kindInput, label)
|
||||
res.redirect(`/op/agreement/${packKey}/${kindInput}`)
|
||||
} catch (error) {
|
||||
res.status(400).send((error as Error).message || t('terms.createFailed'))
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/import', 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 sourceKey = sanitizePackKey(pickFirstValue(req.body.source))
|
||||
if (!sourceKey || sourceKey === packKey) {
|
||||
res.status(400).send(t('terms.invalidImportSource'))
|
||||
return
|
||||
}
|
||||
const sourceDefinition = await loadPackDefinition(sourceKey)
|
||||
if (!sourceDefinition) {
|
||||
res.status(404).send(t('terms.invalidImportSource'))
|
||||
return
|
||||
}
|
||||
await importTerms(packKey, sourceKey)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (error) {
|
||||
res.status(400).send((error as Error).message || t('terms.importFailed'))
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/:kind/delete', 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 kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(400).send(t('terms.invalidKind'))
|
||||
return
|
||||
}
|
||||
await deleteTerm(packKey, kind)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.get('/op/agreement/:packName/:kind', 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 kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(404).send(t('errors.unknown'))
|
||||
return
|
||||
}
|
||||
const entry = await getTermEntry(packKey, kind)
|
||||
if (!entry) {
|
||||
res.status(404).send(t('errors.unknown'))
|
||||
return
|
||||
}
|
||||
const content = await loadTerm(packKey, kind)
|
||||
res.render('op/termsEditor', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
kind,
|
||||
label: entry.label,
|
||||
showInInstaller: entry.showInInstaller,
|
||||
showInInstallerRp: entry.showInInstallerRp,
|
||||
content
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:packName/:kind', 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 kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(404).json({ ok: false, message: t('errors.unknown') })
|
||||
return
|
||||
}
|
||||
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
||||
await saveTerm(packKey, kind, content)
|
||||
// visibility 토글이 함께 전송되면 동시에 갱신. 두 값이 모두 false 면 어디에도
|
||||
// 표시되지 않지만 사용자가 의도적으로 선택한 결과이므로 그대로 저장한다.
|
||||
if (
|
||||
typeof req.body?.showInInstaller === 'boolean'
|
||||
|| typeof req.body?.showInInstallerRp === 'boolean'
|
||||
) {
|
||||
await setTermVisibility(packKey, kind, {
|
||||
showInInstaller: req.body.showInInstaller === true,
|
||||
showInInstallerRp: req.body.showInInstallerRp === true
|
||||
})
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||
@@ -124,16 +492,19 @@ 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),
|
||||
outputPackName: pickFirstValue(req.body.outputPackName),
|
||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||
@@ -144,7 +515,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,10 +1,45 @@
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
|
||||
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
|
||||
export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const manifestTermsDirPath = path.join(manifestDirPath, 'terms')
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* %appdata%/.mc_custom/installer — 설치기가 자체적으로 다운로드해 사용하는
|
||||
* 외부 바이너리(yt-dlp.exe, ffmpeg.exe 등) 보관 위치. .mc_custom 루트가
|
||||
* 마인크래프트 게임 폴더(`mods/`, `resourcepacks/`, `saves/` 등)와 섞이지
|
||||
* 않도록 별도 하위 폴더에 둔다.
|
||||
*/
|
||||
export function getMcCustomInstallerDir(): string {
|
||||
return path.join(getMcCustomDir(), 'installer')
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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, manifestTermsDirPath,
|
||||
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 {
|
||||
@@ -34,6 +40,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
|
||||
platform: { type: 'vanilla' },
|
||||
modsFolder: '',
|
||||
resourcepackPath: '',
|
||||
outputPackName: '',
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 2048,
|
||||
@@ -78,12 +85,22 @@ 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),
|
||||
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
|
||||
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
|
||||
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
|
||||
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
||||
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
||||
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
||||
@@ -161,6 +178,14 @@ export async function deletePackKeys(keys: string[]): Promise<void> {
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
// pack 이 삭제되면 약관 폴더도 함께 정리한다. 동일 packKey 로 재생성될 때
|
||||
// 옛 약관이 부활하는 것을 막기 위함.
|
||||
const termsDir = path.join(manifestTermsDirPath, key)
|
||||
try {
|
||||
await fsp.rm(termsDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await syncManifestWith(key, '', 'remove')
|
||||
}
|
||||
}
|
||||
@@ -181,6 +206,19 @@ export async function renamePack(oldKey: string, newKey: string, pack: PackDefin
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
// 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이
|
||||
// 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다.
|
||||
const oldTermsDir = path.join(manifestTermsDirPath, oldKey)
|
||||
const newTermsDir = path.join(manifestTermsDirPath, safeNew)
|
||||
try {
|
||||
await fsp.rename(oldTermsDir, newTermsDir)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
// 옛 약관 폴더가 없으면 그대로 둔다. 새 폴더가 이미 있어 충돌하면 그것도 그냥 둔다
|
||||
// (renamePack 단계에서 사용자에게 보낼 마땅한 UX 가 없고, 다음 약관 접근 때
|
||||
// 새 폴더 내용이 정상적으로 사용된다).
|
||||
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error
|
||||
}
|
||||
await syncManifestWith(oldKey, '', 'remove')
|
||||
}
|
||||
await syncManifestWith(safeNew, pack.name, 'upsert')
|
||||
@@ -204,6 +242,504 @@ 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')
|
||||
}
|
||||
|
||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
||||
// - 각 약관(.md) 은 `_meta.json` 의 `terms.<kind>` 엔트리로 라벨/표시 대상이 관리된다.
|
||||
// 엔트리: { label, showInInstaller, showInInstallerRp }
|
||||
// - 모든 약관은 추가/삭제 가능. builtin 같은 보호 개념은 더 이상 없음 (v0.3.4~).
|
||||
// 인스톨러는 하드코딩 5종 대신 `index.json` 에서 자기 인스톨러용 약관 목록을 받는다.
|
||||
// - 첫 접근 시 5개 기본 약관(map/mod/installer + resourcepack/installer-rp) 을 시드.
|
||||
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
|
||||
export type TermKind = string
|
||||
|
||||
/**
|
||||
* 처음 pack 폴더를 만들 때 시드되는 기본 약관 5종 + 기본 표시 대상.
|
||||
* 사용자는 이후 자유롭게 삭제하거나 표시 대상을 바꿀 수 있다.
|
||||
*/
|
||||
const DEFAULT_TERM_SEEDS: Array<{
|
||||
kind: string
|
||||
label: string
|
||||
showInInstaller: boolean
|
||||
showInInstallerRp: boolean
|
||||
}> = [
|
||||
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false },
|
||||
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false },
|
||||
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false },
|
||||
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true },
|
||||
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true }
|
||||
]
|
||||
|
||||
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
|
||||
export function isTermKind(value: unknown): value is TermKind {
|
||||
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
||||
}
|
||||
|
||||
export interface TermEntry {
|
||||
label: string
|
||||
showInInstaller: boolean
|
||||
showInInstallerRp: boolean
|
||||
}
|
||||
|
||||
interface TermsMeta {
|
||||
terms: Record<string, TermEntry>
|
||||
}
|
||||
|
||||
const TERMS_META_FILE = '_meta.json'
|
||||
|
||||
function termsDirForPack(packKey: string): string {
|
||||
return path.join(manifestTermsDirPath, packKey)
|
||||
}
|
||||
|
||||
function isValidPackKey(packKey: string): boolean {
|
||||
return typeof packKey === 'string'
|
||||
&& packKey.length > 0
|
||||
&& /^[a-zA-Z0-9_\-]+$/.test(packKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
|
||||
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
|
||||
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
|
||||
*
|
||||
* 공개 라우트(`/manifest/terms/<packKey>/<file>`) 에서도 호출되므로 export 한다.
|
||||
* 라우트 측은 packKey 가 실제 존재하는 pack 인지 확인한 다음에 호출해야 한다
|
||||
* (그렇지 않으면 임의 키로 빈 폴더가 생성될 수 있다).
|
||||
*/
|
||||
export async function ensurePackTermsDir(packKey: string): Promise<string> {
|
||||
const dir = termsDirForPack(packKey)
|
||||
let isNew = false
|
||||
try {
|
||||
await fsp.access(dir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
isNew = true
|
||||
await fsp.mkdir(dir, { recursive: true })
|
||||
// 레거시(전역) .md 파일이 남아 있으면 그대로 복사 (.md 만, _meta.json 은 새 스키마로 새로 씀).
|
||||
try {
|
||||
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
|
||||
for (const ent of legacyEntries) {
|
||||
if (!ent.isFile()) continue
|
||||
const name = ent.name
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const kind = name.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
try {
|
||||
await fsp.copyFile(
|
||||
path.join(manifestTermsDirPath, name),
|
||||
path.join(dir, name)
|
||||
)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
} catch (error2) {
|
||||
if ((error2 as NodeJS.ErrnoException).code !== 'ENOENT') throw error2
|
||||
}
|
||||
}
|
||||
// 폴더가 새로 만들어졌든 기존이든, _meta.json 이 없거나 구 스키마면 5종 기본 + .md 매칭으로 보완.
|
||||
await ensureMetaInitialized(dir, isNew)
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* `_meta.json` 이 없으면 5종 기본 + 디스크 .md 매칭으로 새로 작성한다.
|
||||
* 구 스키마(`customLabels`) 가 있으면 새 스키마(`terms`) 로 변환한다.
|
||||
* 이미 새 스키마면 그대로 둔다 (사용자가 끈 visibility 가 다시 켜지지 않도록).
|
||||
*/
|
||||
async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): Promise<void> {
|
||||
const metaPath = path.join(dir, TERMS_META_FILE)
|
||||
let parsed: unknown = null
|
||||
try {
|
||||
const raw = await fsp.readFile(metaPath, 'utf8')
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
|
||||
// 이미 새 스키마면 종료. 빠진 default kind 가 디스크에 있다면 그것만 보충.
|
||||
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms) {
|
||||
const meta = parsed as { terms: Record<string, unknown> }
|
||||
let changed = false
|
||||
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||
if (meta.terms[seed.kind]) continue
|
||||
// .md 가 실제로 디스크에 있을 때만 보충 (없는 약관까지 자동 부활시키지 않음).
|
||||
try {
|
||||
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
meta.terms[seed.kind] = {
|
||||
label: seed.label,
|
||||
showInInstaller: seed.showInInstaller,
|
||||
showInInstallerRp: seed.showInInstallerRp
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if (changed) {
|
||||
await fsp.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 구 스키마 customLabels 만 있던 경우 → 새 스키마로 변환.
|
||||
const oldCustomLabels: Record<string, string> = {}
|
||||
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).customLabels
|
||||
&& typeof (parsed as Record<string, unknown>).customLabels === 'object') {
|
||||
for (const [k, v] of Object.entries((parsed as { customLabels: Record<string, unknown> }).customLabels)) {
|
||||
if (typeof v === 'string' && TERM_KIND_RE.test(k)) oldCustomLabels[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
const terms: Record<string, TermEntry> = {}
|
||||
// 5종 기본: 디스크에 .md 가 있을 때만 추가 (없는 건 사용자가 의도적으로 지운 것일 수 있음).
|
||||
// 다만 폴더가 막 생성된 경우는 5종을 무조건 시드 (legacy 시드가 비어 있어도).
|
||||
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||
if (!dirWasJustCreated) {
|
||||
try {
|
||||
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 폴더 새로 생성 케이스: .md 가 없으면 빈 파일 만들어 줌.
|
||||
const filePath = path.join(dir, `${seed.kind}.md`)
|
||||
try {
|
||||
await fsp.access(filePath)
|
||||
} catch {
|
||||
await fsp.writeFile(filePath, `# ${seed.label}\n\n`, 'utf8')
|
||||
}
|
||||
}
|
||||
terms[seed.kind] = {
|
||||
label: seed.label,
|
||||
showInInstaller: seed.showInInstaller,
|
||||
showInInstallerRp: seed.showInInstallerRp
|
||||
}
|
||||
}
|
||||
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로.
|
||||
for (const [k, label] of Object.entries(oldCustomLabels)) {
|
||||
if (terms[k]) continue
|
||||
try {
|
||||
await fsp.access(path.join(dir, `${k}.md`))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
terms[k] = { label, showInInstaller: true, showInInstallerRp: true }
|
||||
}
|
||||
await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(dir, TERMS_META_FILE), 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
const result: TermsMeta = { terms: {} }
|
||||
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms
|
||||
&& typeof (parsed as Record<string, unknown>).terms === 'object') {
|
||||
for (const [k, v] of Object.entries((parsed as { terms: Record<string, unknown> }).terms)) {
|
||||
if (!TERM_KIND_RE.test(k)) continue
|
||||
if (!v || typeof v !== 'object') continue
|
||||
const entry = v as Record<string, unknown>
|
||||
const label = typeof entry.label === 'string' ? entry.label : k
|
||||
result.terms[k] = {
|
||||
label,
|
||||
showInInstaller: entry.showInInstaller === true,
|
||||
showInInstallerRp: entry.showInInstallerRp === true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { terms: {} }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
await fsp.writeFile(
|
||||
path.join(dir, TERMS_META_FILE),
|
||||
`${JSON.stringify(meta, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export interface TermItem {
|
||||
kind: string
|
||||
label: string
|
||||
showInInstaller: boolean
|
||||
showInInstallerRp: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크의 .md 파일과 매칭되면서 `_meta.json` 의 `terms` 에 등록된 약관 목록을 반환.
|
||||
* 정렬: 5종 기본(DEFAULT_TERM_SEEDS 순서) → 그 외 사용자 정의 (kind 사전순).
|
||||
*/
|
||||
export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
let onDisk: string[] = []
|
||||
try {
|
||||
onDisk = await fsp.readdir(dir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const mdKinds = new Set<string>()
|
||||
for (const fname of onDisk) {
|
||||
if (!fname.toLowerCase().endsWith('.md')) continue
|
||||
const kind = fname.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
mdKinds.add(kind)
|
||||
}
|
||||
const items: TermItem[] = []
|
||||
const seen = new Set<string>()
|
||||
// 1) 기본 시드 순서 우선.
|
||||
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||
const entry = meta.terms[seed.kind]
|
||||
if (!entry) continue
|
||||
if (!mdKinds.has(seed.kind)) continue
|
||||
items.push({
|
||||
kind: seed.kind,
|
||||
label: entry.label,
|
||||
showInInstaller: entry.showInInstaller,
|
||||
showInInstallerRp: entry.showInInstallerRp
|
||||
})
|
||||
seen.add(seed.kind)
|
||||
}
|
||||
// 2) 그 외 사용자 정의: 사전순.
|
||||
const rest = Object.keys(meta.terms).filter((k) => !seen.has(k))
|
||||
rest.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||
for (const kind of rest) {
|
||||
if (!mdKinds.has(kind)) continue
|
||||
const entry = meta.terms[kind]
|
||||
items.push({
|
||||
kind,
|
||||
label: entry.label,
|
||||
showInInstaller: entry.showInInstaller,
|
||||
showInInstallerRp: entry.showInInstallerRp
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
return meta.terms[kind]?.label ?? kind
|
||||
}
|
||||
|
||||
export async function getTermEntry(packKey: string, kind: string): Promise<TermEntry | null> {
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
return meta.terms[kind] ?? null
|
||||
}
|
||||
|
||||
export async function setTermVisibility(
|
||||
packKey: string,
|
||||
kind: string,
|
||||
visibility: { showInInstaller: boolean; showInInstallerRp: boolean }
|
||||
): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
const entry = meta.terms[kind]
|
||||
if (!entry) throw new Error('term not found')
|
||||
entry.showInInstaller = !!visibility.showInInstaller
|
||||
entry.showInInstallerRp = !!visibility.showInInstallerRp
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
|
||||
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
|
||||
if (!isTermKind(kind)) return ''
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTerm(packKey: string, kind: TermKind, markdown: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
|
||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 약관 추가. kind 충돌은 예외. 빈 `.md` 파일을 만든다.
|
||||
* v0.3.4~: builtin 보호 개념이 없어 임의 kind 를 추가/삭제할 수 있다. 다만
|
||||
* `meta.terms` 에 이미 있는 kind 와 충돌하면 거부. 표시 대상 기본값은 양쪽 인스톨러 모두.
|
||||
*/
|
||||
export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
const cleanLabel = label.trim()
|
||||
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.terms[kind]) throw new Error('term kind already exists')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
||||
try {
|
||||
await fsp.access(filePath)
|
||||
throw new Error('term file already exists')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
||||
// 기본 시드 kind 면 그 시드의 visibility 기본을 따르고, 그 외는 양쪽 인스톨러 모두 표시.
|
||||
const seed = DEFAULT_TERM_SEEDS.find((s) => s.kind === kind)
|
||||
meta.terms[kind] = {
|
||||
label: cleanLabel,
|
||||
showInInstaller: seed ? seed.showInInstaller : true,
|
||||
showInInstallerRp: seed ? seed.showInInstallerRp : true
|
||||
}
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
|
||||
/** 약관 삭제. v0.3.4~: builtin 보호 없음 — 모든 kind 삭제 가능. */
|
||||
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
const dir = await ensurePackTermsDir(packKey)
|
||||
const filePath = path.join(dir, `${kind}.md`)
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const meta = await loadTermsMeta(packKey)
|
||||
if (meta.terms[kind]) {
|
||||
delete meta.terms[kind]
|
||||
await saveTermsMeta(packKey, meta)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
||||
* - source 의 모든 .md 를 target 에 덮어쓴다.
|
||||
* - target 에만 있던 약관 엔트리는 그대로 둔다 (source 에는 없으니 안 건드림).
|
||||
* - 동일한 kind 가 source 에도 있다면 source 의 라벨/표시 대상으로 덮어씀.
|
||||
*/
|
||||
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
|
||||
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
|
||||
throw new Error('invalid pack key')
|
||||
}
|
||||
if (targetPackKey === sourcePackKey) throw new Error('source and target are identical')
|
||||
const sourceDir = await ensurePackTermsDir(sourcePackKey)
|
||||
const targetDir = await ensurePackTermsDir(targetPackKey)
|
||||
|
||||
const sourceMeta = await loadTermsMeta(sourcePackKey)
|
||||
const targetMeta = await loadTermsMeta(targetPackKey)
|
||||
|
||||
// source 의 .md 파일을 모두 target 으로 복사.
|
||||
let entries: string[] = []
|
||||
try {
|
||||
entries = await fsp.readdir(sourceDir)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (!name.toLowerCase().endsWith('.md')) continue
|
||||
const kind = name.slice(0, -3)
|
||||
if (!TERM_KIND_RE.test(kind)) continue
|
||||
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
|
||||
}
|
||||
|
||||
// 약관 엔트리도 source 기준으로 머지 (덮어쓰기).
|
||||
const mergedTerms: Record<string, TermEntry> = { ...targetMeta.terms }
|
||||
for (const [k, v] of Object.entries(sourceMeta.terms)) {
|
||||
mergedTerms[k] = { ...v }
|
||||
}
|
||||
await saveTermsMeta(targetPackKey, { terms: mergedTerms })
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 라우트(`/manifest/terms/<packKey>/<file>`)에서 호출.
|
||||
* - packKey 가 영문/숫자/언더스코어/하이픈만 사용했는지 검사.
|
||||
* - 파일명이 .md 로 끝나고 정상 kind 패턴인지 검사.
|
||||
* - _meta.json 같은 시스템 파일은 차단.
|
||||
*/
|
||||
export function isPublicTermsFile(packKey: string, fileName: string): boolean {
|
||||
if (!isValidPackKey(packKey)) return false
|
||||
if (!fileName.toLowerCase().endsWith('.md')) return false
|
||||
const kind = fileName.slice(0, -3)
|
||||
return TERM_KIND_RE.test(kind)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -13,6 +16,14 @@ export interface PackDefinition {
|
||||
modsFolder: string
|
||||
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
|
||||
resourcepackPath: string
|
||||
/**
|
||||
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
|
||||
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.
|
||||
* 마인크래프트 리소스팩 목록에서 사용자에게 제목처럼 보이는 값이므로
|
||||
* 한글 등 자유 입력을 그대로 보존하고, 파일 시스템에서 사용할 때 금지 문자만
|
||||
* `_` 로 치환한다(치환 책임은 설치기 측에 있음).
|
||||
*/
|
||||
outputPackName: string
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
@@ -36,3 +47,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,37 @@
|
||||
|
||||
<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>
|
||||
<a class="secondaryButton" href="/op/agreement"><%= t('dashboard.editTerms') %></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 +49,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,173 @@
|
||||
</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>
|
||||
<label class="fullSpan">
|
||||
<span><%= t('editor.outputPackName') %></span>
|
||||
<input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" />
|
||||
<small class="muted"><%= t('editor.outputPackNameHint') %></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 +215,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>
|
||||
|
||||
154
views/op/terms-pack.ejs
Normal file
154
views/op/terms-pack.ejs
Normal file
@@ -0,0 +1,154 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.packBrowserTitle', { name: pack.name }) %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<style>
|
||||
/* 약관 목록 — 카드 한 줄(가로 풀폭) 씩 세로로 쌓이도록. */
|
||||
.termsList { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
|
||||
.termsRow {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.termsRow .termsRowMain { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.termsRow .termsRowLabel { display: flex; align-items: center; gap: 8px; }
|
||||
.termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; }
|
||||
.termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||
.termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; }
|
||||
.visibilityBadges {
|
||||
display: flex; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.visibilityBadge {
|
||||
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px;
|
||||
background: rgba(76, 175, 80, 0.15); color: #8ed68f;
|
||||
border: 1px solid rgba(76, 175, 80, 0.35);
|
||||
font-size: 11px;
|
||||
}
|
||||
.visibilityBadge.off {
|
||||
background: rgba(255,255,255,0.05); color: var(--text-muted);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
.termsSideBySide {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.termsSideBySide { grid-template-columns: 1fr; }
|
||||
}
|
||||
.termsSection {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.termsSection h2 { margin: 0 0 12px; font-size: 15px; }
|
||||
.termsAddForm { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: end; }
|
||||
.termsAddForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.termsAddForm label { font-size: 12px; color: var(--text-muted); }
|
||||
.termsAddForm input, .termsImportForm select {
|
||||
background: var(--bg-alt); color: var(--text);
|
||||
border: 1px solid var(--border, #30363d); border-radius: 6px;
|
||||
padding: 8px 10px; font-size: 13px;
|
||||
}
|
||||
.termsAddForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||
.termsAddForm .formActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
|
||||
.termsImportForm { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||
.termsImportForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.termsImportForm label { font-size: 12px; color: var(--text-muted); }
|
||||
.termsImportForm .formActions { display: flex; justify-content: flex-end; }
|
||||
.termsImportForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('terms.packTitle', { name: pack.name }) %></h1>
|
||||
<p class="muted"><%= packKey %>.json</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('terms.hint') %></p>
|
||||
|
||||
<section class="termsList">
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="termsRow">
|
||||
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
|
||||
<div class="termsRowLabel">
|
||||
<h2><%= item.label %></h2>
|
||||
<span class="visibilityBadges">
|
||||
<span class="visibilityBadge <%= item.showInInstaller ? '' : 'off' %>"><%= t('terms.visibilityInstallerShort') %></span>
|
||||
<span class="visibilityBadge <%= item.showInInstallerRp ? '' : 'off' %>"><%= t('terms.visibilityInstallerRpShort') %></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="termsRowSub"><%= item.kind %>.md</div>
|
||||
</a>
|
||||
<div class="termsRowActions">
|
||||
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
|
||||
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
|
||||
style="margin:0;">
|
||||
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
<% }) %>
|
||||
</section>
|
||||
|
||||
<section class="termsSideBySide">
|
||||
<div class="termsSection">
|
||||
<h2><%= t('terms.addHeading') %></h2>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/create" class="termsAddForm">
|
||||
<div class="field">
|
||||
<label for="newKind"><%= t('terms.kindLabel') %></label>
|
||||
<input id="newKind" name="kind" type="text" required
|
||||
pattern="[a-z0-9][a-z0-9-]{0,31}"
|
||||
placeholder="<%= t('terms.kindPlaceholder') %>" />
|
||||
<span class="hint"><%= t('terms.kindHint') %></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="newLabel"><%= t('terms.labelLabel') %></label>
|
||||
<input id="newLabel" name="label" type="text" required maxlength="50"
|
||||
placeholder="<%= t('terms.labelPlaceholder') %>" />
|
||||
</div>
|
||||
<div class="formActions">
|
||||
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="termsSection">
|
||||
<h2><%= t('terms.importHeading') %></h2>
|
||||
<% if (sourceCandidates.length === 0) { %>
|
||||
<p class="muted"><%= t('terms.importEmpty') %></p>
|
||||
<% } else { %>
|
||||
<form method="post" action="/op/agreement/<%= packKey %>/import" class="termsImportForm"
|
||||
onsubmit="return confirm('<%= t('terms.importConfirm').replace(/'/g, "\\'") %>');">
|
||||
<div class="field">
|
||||
<label for="importSource"><%= t('terms.importSourceLabel') %></label>
|
||||
<select id="importSource" name="source" required>
|
||||
<option value=""><%= t('terms.importSourcePlaceholder') %></option>
|
||||
<% sourceCandidates.forEach(function (cand) { %>
|
||||
<option value="<%= cand.key %>"><%= cand.definition ? cand.definition.name : cand.key %> (<%= cand.key %>)</option>
|
||||
<% }) %>
|
||||
</select>
|
||||
<span class="hint"><%= t('terms.importHint') %></span>
|
||||
</div>
|
||||
<div class="formActions">
|
||||
<button type="submit" class="primaryButton"><%= t('terms.importButton') %></button>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
44
views/op/terms.ejs
Normal file
44
views/op/terms.ejs
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.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('terms.title') %></h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="muted"><%= t('terms.pickPackHint') %></p>
|
||||
|
||||
<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/agreement/<%= 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>
|
||||
63
views/op/termsEditor.ejs
Normal file
63
views/op/termsEditor.ejs
Normal file
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.editorBrowserTitle', { label: label }) %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="/static/termsEditor.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<div>
|
||||
<a class="ghostLink" href="/op/agreement/<%= packKey %>"><%= t('common.back') %></a>
|
||||
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
|
||||
<p class="muted"><%= pack.name %> · <%= kind %>.md</p>
|
||||
</div>
|
||||
<div class="dirtyMark" id="dirty-mark" hidden>*</div>
|
||||
</section>
|
||||
|
||||
<div class="listActionsRow" style="align-items:center;">
|
||||
<button type="button" class="primaryButton" id="saveBtn"><%= t('terms.save') %></button>
|
||||
<div class="tabBar" style="margin:0 0 0 12px;">
|
||||
<button type="button" class="tabBtn active" data-mode="edit"><%= t('terms.edit') %></button>
|
||||
<button type="button" class="tabBtn" data-mode="preview"><%= t('terms.preview') %></button>
|
||||
</div>
|
||||
<span class="statusText" id="status"></span>
|
||||
</div>
|
||||
|
||||
<!-- 표시 대상 토글: 어느 인스톨러에서 이 약관을 보여줄지 (중복 선택 가능). -->
|
||||
<fieldset class="termsVisibility" style="margin-top:16px; padding:10px 14px; border:1px solid var(--border, #30363d); border-radius:8px;">
|
||||
<legend style="padding:0 6px; font-size:12px; color:var(--text-muted);"><%= t('terms.visibilityHeading') %></legend>
|
||||
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:18px;">
|
||||
<input type="checkbox" id="visInstaller" <%= showInInstaller ? 'checked' : '' %> />
|
||||
<span><%= t('terms.visibilityInstaller') %></span>
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<input type="checkbox" id="visInstallerRp" <%= showInInstallerRp ? 'checked' : '' %> />
|
||||
<span><%= t('terms.visibilityInstallerRp') %></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
|
||||
|
||||
<div id="editorWrap" class="termsEditorWrap">
|
||||
<textarea id="editor" class="termsEditor" spellcheck="false"></textarea>
|
||||
<div id="preview" class="termsPreview" hidden></div>
|
||||
<div id="slashMenu" class="slashMenu" hidden></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||
var TERM_KIND = <%- JSON.stringify(kind) %>;
|
||||
var INITIAL = <%- JSON.stringify(content) %>;
|
||||
var I18N = <%- JSON.stringify(localeDict.terms) %>;
|
||||
I18N.common = <%- JSON.stringify(localeDict.common) %>;
|
||||
</script>
|
||||
<script src="/static/termsEditor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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