38 Commits

Author SHA1 Message Date
ae771668de installer-rp: ship installer/styles.css so packaged UI renders
The rp installer's `index.html` references `../installer/styles.css`,
which works in dev because both source directories sit side by side.
The packaged exe's `files` list only included `installer-rp/**`, so
inside the asar the stylesheet path resolved to nothing and the UI
rendered completely unstyled (per user screenshot).

Add the single shared file `installer/styles.css` to the rp build's
file list. The cross-directory `<link>` reference now resolves inside
the asar, and we avoid duplicating the stylesheet.

Bump to 0.1.1 — small patch-level fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:14:42 +09:00
40c47fbeb3 build: bundle sharp's win32-x64 prebuilt for Windows packaging
The packaged installer-rp crashed on launch with
"Could not load the 'sharp' module using the win32-x64 runtime" because
electron-builder ran on Linux and only the Linux sharp variants were
present in node_modules.

- Add `preinstall:sharp-win32` script that force-installs
  `@img/sharp-win32-x64@0.34.5` into the local node_modules (npm refuses
  it on Linux without --force due to its os/cpu restrictions).
- Chain that script before both `dist:win` and `dist:win:rp` so future
  Windows builds always have the native prebuilt available.
- Exclude `@img/sharp-{,libvips-}linux*` from the packaged files list in
  both electron-builder configs so the unused Linux variants don't bloat
  the portable exe.

Verified `release/win-unpacked/resources/app.asar.unpacked/node_modules/
@img/sharp-win32-x64/lib/sharp-win32-x64.node` is present and that no
linux sharp variants ship inside the asar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:14:55 +09:00
6e170646a7 env: commit .env.build with production SITE_BASE_URL
Track `.env.build` in version control so the production site domain
(`https://mc.tkrmagid.kr`) is baked into every portable exe build by
default. `.env` (server/dev secrets) stays gitignored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:15:22 +09:00
3017e77479 env: merge .env + .env.build instead of stopping at first match
Reviewer noted that returning on the first found file meant a project-root
`.env.build` could shadow the dev `.env`, leaving the server without
`PORT`/`HOST`/`SESSION_SECRET`. Switch `loadEnv()` to iterate every
candidate with `override:false` so multiple files merge — first-loaded
value wins per key.

Order also reshuffled so dev's `.env` takes precedence over `.env.build`
at the project root (server settings stay alive), while in packaged
mode `resources/.env.build` still wins. Verified manually with a
temp-dir reproduction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:11:15 +09:00
c8da4207fc build: separate .env.build for packaging, keep .env dev-only
The previous setup packaged the development `.env` into the installer
resources, mixing local server settings (PORT/HOST/SESSION_SECRET) with
the build-time site domain. Introduce a dedicated `.env.build`:

- electron-builder configs now copy `.env.build` (gitignored) into
  `resources/`, no longer touching the dev `.env`.
- `loadEnv()` prefers `resources/.env.build` first, falling back to
  `resources/.env` (for operators who hand-edit the packaged file),
  then `<root>/.env.build`, then `<root>/.env`.
- `.env.build.example` documents the build-only keys (SITE_BASE_URL,
  MANIFEST_URL, MUSIC_CONCURRENCY); server-side keys stay in `.env`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:03:22 +09:00
tkrmagid-desktop
dfb60046c8 Merge branch 'main' of https://git.tkrmagid.kr/tkrmagid/minecraft_launcher 2026-05-18 01:02:40 +09:00
tkrmagid-desktop
6472b12d58 한국어 변경 2026-05-18 01:02:23 +09:00
bc974ecd24 installer: switch Windows target from NSIS to portable single-exe
Both electron-builder configs now produce a single-file portable .exe
instead of an NSIS installer. Removes installer/uninstaller icons and
the install-directory wizard — users can run the .exe directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:38:22 +09:00
132700720d installer-rp: override packaged main entry to dist/installer-rp/main.js
The root package.json's `main` field points at dist/installer/main.js
because that's the default `npm run installer` entry. Without an
override, `electron-builder --config electron-builder-rp.yml` would
package the resourcepack installer with the wrong main, so the exe
would start the regular installer (or fail outright).

Add `extraMetadata.main: dist/installer-rp/main.js` so the packaged
package.json is rewritten at build time to point at the right entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:17:48 +09:00
c527efc42f installer: address vanilla mods preservation, robust map rename, new app icon
Reviewer follow-ups:

1) Preserve mods/ for vanilla packs. `downloadModsFolder` now checks
   `!pack.modsFolder` BEFORE wiping — vanilla packs (no modsFolder) no
   longer clobber a user's hand-curated mods directory. Wipe still runs
   for modded packs to keep different MC versions from colliding.

2) Always rename the extracted map to `saves/<퀴즈이름>/`, regardless of
   the zip's top-level layout. The zip is now extracted into a temp
   directory under saves/, and:
   - if the temp has a single subdirectory, that subdirectory's content
     becomes the world;
   - otherwise the temp dir itself (e.g. level.dat + region/ at root) is
     the world.
   In either case, it is renamed atomically to `saves/<sanitized name>`
   (or `<name>_2` etc. if a user world collides). Marker tracks the
   final folder name for participant cleanup.

User request: replace both .exe icons.

- Added build/icon.ico (multi-size 16/32/48/64/128/256) and build/icon.png
  generated from the new music-note artwork.
- electron-builder.yml: set win.icon, nsis installer/uninstaller icons,
  buildResources=build, include build/icon.* in files for runtime use.
- New electron-builder-rp.yml + dist:win:rp script so the resourcepack
  installer also packages with the same icon.
- BrowserWindow({ icon }) wired in both installer and installer-rp main
  processes so the running window's titlebar/taskbar icon matches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:14:44 +09:00
4ee0a59f2b installer: wipe mods/ before install and rename extracted map to pack name
Two follow-ups requested by the user (and the first flagged by the
reviewer for omission):

1) Different Minecraft versions or different packs leave behind mod jars
   that crash Fabric on load. `downloadModsFolder` now removes the entire
   `.mc_custom/mods/` directory before every install — including when the
   pack is vanilla (no modsFolder) so leftovers from a previous modded
   pack get cleared too.

2) `downloadMapZip` renames the single extracted top-level folder to the
   pack name (sanitized for Windows: forbidden chars `<>:"/\|?*` and
   control chars → `_`, trailing space/dot trimmed, reserved names like
   CON/NUL prefixed, empty fallback to `map`). Collisions with user
   worlds get `_2`, `_3` … suffixes so we never overwrite the user's
   own worlds. The marker file tracks the post-rename folder so future
   participant cleanup still removes only what the installer created.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:06:11 +09:00
06b35abcb1 installer: split host/participant choice into its own tab
Previously the multi-mode role pick appeared as a sub-section that
expanded inline under the single/multi cards. Per request, separate it
into a dedicated page reached by pressing Next on the mode tab.

- renderStep2 now only handles mode selection (single/multi). Next
  routes single → step4 directly, multi → renderStep2Role.
- New renderStep2Role shows the host/participant cards on its own page
  with back to renderStep2. Next routes host → step3, participant → step4.
- backToPrevStep in step4 and the back from sub31 in step3 updated so
  the role tab is the correct intermediate landing for multi flows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:00:29 +09:00
ca1c5f237f installer: clean up installer-extracted map when switching to participant
When the user installs as single (skipMap=false) and then navigates back
to choose participant (skipMap=true), the previously-extracted map files
in .mc_custom/saves/ would remain because skipMap=true only skipped the
download. The final participant install state was therefore inconsistent
with the chosen role.

Track the top-level entries that downloadMapZip extracts via a marker
file (.musicquiz-installer-map.json) inside saves/. On participant
install (skipMap=true) or before a re-download, only the entries listed
in the marker are removed, so user-created worlds are preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:16:43 +09:00
5ea9b49630 installer: re-install client when navigation changes install payload
기존 state.client.clientInstalled boolean 은 packKey/installPlatform/skipMap
차이를 보지 않아, 참가자→싱글 로 뒤로가서 변경했을 때 skipMap=false
경로의 맵 설치가 영영 안 일어났다 (반대로 싱글→참가자 면 안 풀어도 될
맵이 남음). state.client.lastInstall 에 마지막 성공 payload 전체를
저장하고, 진입 시 새 payload 와 필드별 비교해서 다르면 재설치한다.
실패는 lastInstall 을 비워 다음 진입에서 자동 재시도.

리뷰어 지적사항(installer/renderer.js:657) 대응.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:11:44 +09:00
49f320fa71 installer: multi-role host/participant split + auto-platform + EULA + port UX
step2:
- 멀티 선택 시 호스트 / 참가자 sub-choice 추가. 호스트 는 기존 멀티 흐름 그대로,
  참가자 는 step3 (서버 설치) 를 건너뛰고 step4 client install 만 진행.

step4 client install:
- 플랫폼 설치/생략 선택 화면(sub41) 제거. 음악퀴즈 platform.type 이 vanilla 가
  아니면 무조건 자동 설치, vanilla 면 자동 건너뜀. 사용자 결정 없음.
- 참가자 모드에서는 ClientInstallPayload.skipMap=true 로 보내 client 측
  saves/ 에 맵을 풀지 않는다 (서버에 이미 있음).
- types.ts 에 skipMap 필드 추가. main.ts client:install 핸들러에서 분기.

step3 EULA modal:
- eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 서버 EULA 페이지를
  받아 iframe 에 표시. readEula() 분기 제거.

step3 포트포워딩 결과:
- 성공(preForwarded/upnpOk) 시 "친구는 <address> 주소로 서버에 접속할 수
  있습니다" 처럼 외부 주소를 강조해 표시.
- 포트가 25565 면 :포트 를 생략하고 ip 만 보여줌 (마인크래프트 자바판
  기본 포트라 클라이언트에서도 생략 가능).

step5:
- 서버 마무리 액션 (바로가기/서버 실행 토글) 은 호스트 만 노출. 참가자는
  서버를 띄우지 않으므로 런처 토글만 보인다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:06:48 +09:00
848fac500e op/datapack: add painting_variant JSON zip export button
데이터팩 수정 페이지에 "이미지.zip 출력" 버튼과 크기 입력(기본 4, 1~16)
을 추가. 누르면 GET /op/datapack/:key/images-zip?size=N 으로 음악 개수만큼
cover_NN.json (asset_id, width=size, height=size, title, author) 을 zip 으로
스트리밍해서 내려준다. 사용자가 맵 데이터팩의 data/musicquiz/painting_variant/
에 그대로 풀어 넣을 수 있다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:22:23 +09:00
212e70cd56 installer-rp: adjust music download stagger 1500ms → 2000ms
사용자 요청에 따라 1.5s 는 너무 짧다고 판단해 2s 로 재조정.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:40:20 +09:00
3ca93abae9 installer-rp: reduce music download stagger 2500ms → 1500ms
체감 속도 향상을 위해 음악 다운로드 시작 간격을 1.5초로 단축한다.
동시성 시각 효과는 유지하면서 전체 대기시간을 줄인다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:40:05 +09:00
a8b9b689c2 resourcepack: gate shader strip on declared maxFmt, not build target
베이스팩의 vanilla 셰이더는 manifest 의 mcVersion(resolved.format) 이 64 이하
라도, 우리가 supported_formats/max_format 으로 1.21.9+ 까지 호환을 선언하면
새 GLSL API 환경에서 로드돼 "리소스 새로고침 실패" 가 다시 난다. 셰이더 제거
판정 기준을 resolved.format > 64 에서 maxFmt > 64 로 옮기고, 그 계산을
mcmeta 작성보다 먼저 수행한다. 로그의 format 값도 maxFmt 를 표시해 어떤
호환 상한 때문에 제거됐는지 추적 가능하게 했다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:49:11 +09:00
1665f05c55 resourcepack: declare compatibility range from 1.21.6 to latest known
Pack.mcmeta now spans pack_format MIN_SUPPORTED_FORMAT (=63, 1.21.6) up to
max(LATEST_KNOWN_FORMAT, resolved.format) so a single build loads on every
MC from 1.21.6 through 26.1.2+ (currently extending to 86 = 26.2). Both
schemas are written: supported_formats for clients on pack_format <= 64,
and min_format/max_format for 1.21.9+ clients. pack_format itself stays
at the build target so newer clients see the pack as current rather than
legacy.
2026-05-14 21:43:31 +09:00
40b2ff81f5 resourcepack: strip vanilla shader overrides when pack_format > 64
Reviewer was right that warn-only let broken zips through. On 1.21.9+
(pack_format > 64) the vanilla shader GLSL API changed (ProjMat, FogColor
etc.) so any base pack carrying old assets/minecraft/shaders/* fails to
compile and causes the same "리소스 새로고침 실패" the pack.mcmeta fix
addressed. Strip that directory at build time when the target pack_format
exceeds 64; keep textures/models/sounds intact and log what was removed.
On <= 64 the old shaders still work, so leave the base pack untouched.
2026-05-14 21:34:30 +09:00
9cb7c05b43 resourcepack: warn when base zip overrides vanilla shaders
The actual "리소스 새로고침 실패" was caused by pack.mcmeta JsonParseException
(fixed in 6718315). The shader compile errors seen on the same log come from
the user-supplied base resourcepack overriding vanilla shaders for an older
MC. Auto-stripping would silently destroy the user's intended look, so emit
a clear warning during build instead and let the user decide whether to
update the base pack.
2026-05-14 21:28:41 +09:00
671831535b fix(resourcepack): emit min_format/max_format when pack_format > 64
MC 1.21.9+ (pack_format >= 65) deprecated supported_formats and now
requires min_format/max_format at pack root. On 26.1.x (format 84) the
old supported_formats made pack.mcmeta unparseable, so MC rejected the
music-quiz resourcepack with "리소스 새로고침 실패" on reload.
2026-05-14 21:23:45 +09:00
506e506cfa resourcepack: add music quiz pack.png icon to base zip 2026-05-14 02:29:01 +09:00
9db70d0bea installer: options.txt 류는 .mc_custom 으로 매번 덮어쓰기 동기화
기존 copyMinecraftUserSettings 는 .mc_custom 에 같은 이름의 파일이
있으면 무조건 보존했기 때문에, 사용자가 .minecraft 에서 새로 바꾼
키설정·옵션이 .mc_custom 으로 이어지지 못했다. options.txt /
optionsof.txt / optionsshaders.txt 는 사용자가 원래 쓰던 설정을
그대로 가져오기 위한 파일이므로 매번 .minecraft 쪽으로 덮어써서
동기화하고, servers.dat 같은 그 외 파일은 종전대로 보존한다.
2026-05-14 00:43:55 +09:00
c8911a9a62 fix(editor): fabric에서도 platformDownloadUrl 저장되도록 수정
normalizePackDefinition 이 fabric 일 때 downloadUrl 을 의도적으로
스트립하고 있어 저장 후 입력값이 사라지는 문제. vanilla 외에는
모두 보관하도록 조건을 변경하고, 에디터 UI 도 fabric 에서 URL
입력 칸을 다시 보여주도록 되돌렸다.
2026-05-14 00:19:19 +09:00
2a500a381f op editor: 플랫폼 설치파일 URL 필드 초기 렌더에서 플랫폼 타입에 맞게 숨김
증상: 플랫폼 타입이 fabric 인 음악퀴즈를 편집할 때 "플랫폼 설치파일
URL" 필드가 잠깐 보이고, 그 자리에 값을 입력해 저장해도 disk 에는
저장되지 않아 다시 비어 보였다 (normalizePackDefinition 이 fabric 의
downloadUrl 을 의도적으로 제거하기 때문).

원인: editor.ejs 가 platformDownloadField 를 항상 visible 로,
platformLoaderField 를 항상 hidden 으로 렌더한 뒤 JS 가 뒤늦게 보정.
이 짧은 깜빡임 동안 사용자가 URL 필드를 보고 입력하게 됨.

수정: 서버 렌더 시점에 pack.platform.type 에 따라 hidden 속성을 미리
붙여 둔다 (fabric/vanilla → URL 숨김, fabric → loader 표시).
2026-05-14 00:06:15 +09:00
ea72051e43 installer: Fabric 이미 설치돼 있으면 fabric-installer 재실행 건너뛰기
증상: 두 번째 설치 시도에서 fabric-installer 가
  FileSystemException: ...fabric-loader-X-Y.jar: 다른 프로세스가 파일을
  사용 중이기 때문에 프로세스가 액세스 할 수 없습니다
로 실패. 마인크래프트(또는 OS 인덱서)가 jar 핸들을 잡고 있을 때 발생.

원인: fabric-installer 는 매 실행마다 versions/<id>/<id>.jar 를
deleteIfExists 한 뒤 다시 쓰려고 한다. 이미 설치돼 있으면 굳이 다시
쓸 필요가 없다.

수정: installFabricLoader 에서 customRoot/versions/<versionId>/<versionId>.jar
와 .json 이 둘 다 존재하면 곧바로 return 하고 안내 로그만 남긴다.
2026-05-13 23:51:13 +09:00
c0472bb57b site: 사이트 팝업창 ESC 로 닫기 지원
- listEditor: keydown(Escape) 시 열린 .modalOverlay 닫기. 별칭 모달은
  "돌아가기" 와 동일하게 입력값을 저장한 뒤 닫는다.
- datapack: pickModal 도 ESC 로 닫히게 추가.

(팝업 바깥 영역 클릭으로 닫기는 두 페이지 모두 기존부터 동작 중.)
2026-05-13 16:44:58 +09:00
de08f9a810 op: 데이터팩 출력을 zip 대신 songs.mcfunction 코드 텍스트로 변경
운영자가 mc_datapack 의 init/songs.mcfunction 파일에 직접 복사해 붙여넣
는 워크플로로 단순화. 전체 데이터팩을 패키징할 필요가 없다.

- /op/datapack/:packName/generate 가 buildSongsMcfunction(list) 결과를
  text/plain 으로 반환 (zip 스트리밍 제거).
- file/datapacks/music_quiz_template/ 정적 사본 제거.
- datapack.ejs 에 코드블록·복사 버튼 복원, 안내 문구 추가
  ("data/mq/function/init/songs.mcfunction 에 그대로 덮어쓰세요").
- datapack 로케일 라벨을 "코드 출력 / 복사 / 출력 완료" 로 정리.
2026-05-13 16:41:25 +09:00
af884706d4 op: 데이터팩 출력을 실제 music_quiz zip 으로 교체
가이드 (mc_datapack/launcher_datapack_연동_가이드.txt) 에 따라:
- file/datapacks/music_quiz_template/ 에 mc_datapack 의 music_quiz/ 정적
  파일을 미리 동봉 (data/mq/function/init/songs.mcfunction 제외).
- src/server/datapack.ts: list.music → SNBT (`{title, author, alias}`)
  songs.mcfunction 빌더와 archiver 기반 zip 스트리머 추가.
- /op/datapack/:packName/generate 가 텍스트 placeholder 대신
  music_quiz_<key>.zip 을 Content-Disposition attachment 로 내려준다.
- datapack.ejs 의 코드블록·복사 UI 제거, 곡 수는 서버 렌더 시점에 표시.
- 더 이상 쓰이지 않는 locales 의 datapackOutput.* 키 제거, datapack
  버튼 라벨/상태 문구를 zip 다운로드용으로 정리.
2026-05-13 16:34:34 +09:00
2344c4b8d2 site: 음악목록 항목별 별칭 편집 기능 추가
- MusicListEntry 에 aliases: string[] 필드 추가, 저장 시 trim·중복 제거.
- 목록 행에 "별칭" 버튼 표시(개수 있으면 강조), 클릭 시 모달 오픈.
- 모달에서 "별칭 추가" → 입력행 생성, "−" 버튼 → 해당 행 삭제,
  좌상단 "← 돌아가기" 또는 오버레이 클릭으로 저장 후 닫기.
2026-05-13 15:57:35 +09:00
f9cf373550 수정 2026-05-13 10:31:08 +09:00
f92dc02879 installer: 4단계 sub43(완료 확인) 화면 제거
sub42 에서 클라이언트 설치가 끝나면 그 자리의 "다음" 버튼이 바로 5단계로
넘어가도록 변경. 중복적이던 sub43 의 i18n 키와 renderSubStep43 함수도 함께
삭제.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:18:11 +09:00
5e418a5c21 ko수정 2026-05-13 10:15:51 +09:00
6cd402121b i18n: 리소스팩 설치기 UI 문구를 locales/installer-rp/ko-kr.json 으로 분리
- main/preload/ytdlp/ffmpeg/music/images/pack/renderer 전반에서 로그·에러·진행
  메시지 문자열을 locales/installer-rp/ko-kr.json 사전 키로 교체
- preload 에 loadLocale 추가, main 에 rp:i18n:dict IPC 핸들러 추가
- 패키징된 .exe 에서도 한국어 사전이 적용되도록 electron-builder.yml 의
  extraResources 에 locales/ 폴더 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 04:00:31 +09:00
135bc98840 i18n: 음악퀴즈 설치기 UI 문구를 locales/installer/ko-kr.json 으로 분리
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 03:53:55 +09:00
c2fcc2fbbf i18n: 서버 측 모든 UI 문구를 locales/server/ko-kr.json 으로 분리
- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
2026-05-13 03:43:04 +09:00
44 changed files with 2146 additions and 658 deletions

4
.env.build Normal file
View 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
View 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=

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

41
electron-builder-rp.yml Normal file
View 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}

View File

@@ -2,22 +2,37 @@ appId: kr.tkrmagid.musicquiz.installer
productName: MusicQuizInstaller
directories:
output: release
buildResources: build
files:
- dist/installer/**
- dist/shared/**
- installer/**
- build/icon.*
- package.json
# 빌드 시점의 .env 를 설치기 옆에 함께 배포(없으면 조용히 패스).
# 패키징 후 운영자가 resources/.env 만 교체해서 도메인을 바꿀 수도 있음.
# 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
- .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}

Binary file not shown.

View File

@@ -10,6 +10,25 @@ const state = {
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')
@@ -20,10 +39,10 @@ logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = '펼치기'
logToggle.textContent = tt('logViewer.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = '접기'
logToggle.textContent = tt('logViewer.collapse')
}
})
@@ -33,6 +52,22 @@ api.onLog(function (line) {
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'))
@@ -51,9 +86,9 @@ function renderStep1() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>1단계. 음악퀴즈 선택</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<h2>' + 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')
@@ -61,7 +96,7 @@ function renderStep1() {
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
listEl.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('common.noPacks')) + '</p>'
return
}
state.packs.forEach(function (pack) {
@@ -69,11 +104,14 @@ function renderStep1() {
card.type = 'button'
card.className = 'choiceCard'
if (state.selectedKey === pack.key) card.classList.add('selected')
var verLabel = pack.mcVersion ? '마인크래프트 ' + escapeHtml(pack.mcVersion) + ' · ' : ''
var verLabel = pack.mcVersion
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
: ''
card.innerHTML =
'<strong>' + escapeHtml(pack.name) + '</strong>' +
'<small>' + verLabel +
'음악 ' + pack.list.music.length + '곡 · 사진 ' + pack.list.images.length + '장</small>'
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
@@ -88,7 +126,7 @@ function renderStep1() {
api.selectPack(state.selectedKey).then(function () {
renderStep2()
}).catch(function (err) {
alert(err.message || '선택 실패')
alert(err.message || tt('common.selectFailed'))
})
})
@@ -96,7 +134,9 @@ function renderStep1() {
state.packs = packs || []
renderList()
}).catch(function (err) {
listEl.innerHTML = '<p class="formMessage error">목록 로드 실패: ' + escapeHtml(err.message || '') + '</p>'
listEl.innerHTML = '<p class="formMessage error">' +
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
'</p>'
})
}
@@ -115,30 +155,29 @@ function renderStep2() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>2단계. 리소스팩 설치</h2>' +
'<p class="formMessage">음악·사진을 받아 리소스팩을 만들고 ' +
'<code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.</p>' +
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
'<p class="formMessage">' + tt('step2.description') + '</p>' +
'<div class="prepRow">' +
' <span class="prepChip" id="chip-ytdlp">yt-dlp 준비</span>' +
' <span class="prepChip" id="chip-ffmpeg">ffmpeg 준비</span>' +
' <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>음악 다운로드</h3>' +
' <div class="sectionSub" id="music-sub">' + musicTotal + '</div>' +
' <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>사진 다운로드</h3>' +
' <div class="sectionSub" id="image-sub">' + imageTotal + '</div>' +
' <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>리소스팩 빌드</h3>' +
' <div class="sectionSub" id="pkg-sub">대기 중…</div>' +
' <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">취소</button>' +
' <button class="dangerBtn" id="cancel">' + escapeHtml(tt('common.cancel')) + '</button>' +
'</div>'
pageHost.appendChild(section)
@@ -156,7 +195,7 @@ function renderStep2() {
card.innerHTML =
'<div class="cardTop"><span class="label">' + idx + '</span><span class="icon">○</span></div>' +
'<div class="bar"><span></span></div>' +
'<div class="pct">대기</div>'
'<div class="pct">' + escapeHtml(tt('step2.cardWaiting')) + '</div>'
return card
}
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
@@ -172,17 +211,17 @@ function renderStep2() {
var pct = card.querySelector('.pct')
var icon = card.querySelector('.icon')
if (status === 'done') {
if (pct) pct.textContent = '완료'
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 = '실패'
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 = '대기'
if (pct) pct.textContent = tt('step2.cardWaiting')
if (icon) icon.textContent = '○'
}
}
@@ -209,7 +248,9 @@ function renderStep2() {
return
}
if (payload.phase === 'package') {
pkgSub.textContent = payload.done ? '설치 완료' : (payload.message || '빌드 중…')
pkgSub.textContent = payload.done
? tt('step2.packageDone')
: (payload.message || tt('step2.packageBuilding'))
return
}
})
@@ -232,7 +273,7 @@ function renderStep2() {
}).catch(function (err) {
state.installing = false
if (stopProgress) stopProgress()
alert('설치 실패: ' + ((err && err.message) || err))
alert(tt('common.installFailed', { message: (err && err.message) || err }))
renderStep1()
})
}
@@ -244,14 +285,14 @@ function renderStep3() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>3단계. 완료</h2>' +
'<p class="formMessage">리소스팩 설치를 완료했습니다.</p>' +
'<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">리소스팩 폴더 열기</button>' +
' <button class="primaryBtn" id="finish">확인</button>' +
' <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 () {
@@ -268,4 +309,8 @@ function escapeHtml(s) {
})
}
renderStep1()
;(async function () {
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()

View File

@@ -2,10 +2,33 @@
const installerApi = window.installer
// I18N 사전: locales/installer/ko-kr.json. 처음 한 번 메인 프로세스에서 받아오고
// 그 뒤로는 동기적으로 lookup. tt() 가 호출될 때 사전이 비어 있어도 키를 그대로 반환해
// 화면이 깨지지는 않는다.
var I18N = {}
function tt(key, params) {
var parts = key.split('.')
var cur = I18N
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
else { cur = null; break }
}
var tpl = (typeof cur === 'string') ? cur : key
if (!params) return tpl
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
})
}
const state = {
packs: [],
selectedPackKey: null,
mode: null, // 'single' | 'multi'
// mode==='multi' 일 때만 의미가 있다.
// 'host' → 서버를 직접 연다. 기존 멀티 흐름 (step3 + step4) 그대로.
// 'participant' → 친구 서버에 접속만 한다. step3 (서버 설치) 를 건너뛰고
// client 측에서도 맵은 받지 않는다 (참가자라 서버에 이미 있음).
role: null, // 'host' | 'participant' | null
serverInstall: {
path: '',
jdk: '',
@@ -30,14 +53,27 @@ const logViewer = document.getElementById('logViewer')
const logBody = document.getElementById('logBody')
const logToggle = document.getElementById('logToggle')
function applyStaticI18n() {
document.title = tt('app.browserTitle')
var headerH1 = document.querySelector('.appHeader h1')
if (headerH1) headerH1.textContent = tt('app.headerTitle')
stepIndicator.querySelectorAll('li').forEach(function (item) {
var step = Number(item.getAttribute('data-step'))
item.textContent = tt('stepIndicator.step' + step)
})
var logHeader = logViewer.querySelector('h2')
if (logHeader) logHeader.textContent = tt('logViewer.title')
logToggle.textContent = tt('common.collapse')
}
logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = '펼치기'
logToggle.textContent = tt('common.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = '접기'
logToggle.textContent = tt('common.collapse')
}
})
@@ -66,9 +102,9 @@ function renderStep1() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>1단계. 설치할 음악퀴즈 선택</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<h2>' + tt('step1.heading') + '</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">' + tt('step1.loading') + '</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var listEl = section.querySelector('#packList')
var nextBtn = section.querySelector('#next')
@@ -76,13 +112,14 @@ function renderStep1() {
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.empty') + '</p>'
return
}
state.packs.forEach(function (pack) {
var btn = document.createElement('button')
btn.type = 'button'
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '</small>'
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>' +
tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + '</small>'
if (state.selectedPackKey === pack.key) btn.classList.add('selected')
btn.addEventListener('click', function () {
state.selectedPackKey = pack.key
@@ -106,7 +143,7 @@ function renderStep1() {
state.packs = packs
renderList()
} catch (err) {
listEl.innerHTML = '<p class="formMessage error">목록을 가져오지 못했습니다: ' + err.message + '</p>'
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.fetchFailed', { message: err.message }) + '</p>'
}
})()
}
@@ -117,54 +154,105 @@ function renderStep2() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>2단계. 싱글 / 멀티 선택</h2>' +
'<h2>' + tt('step2.heading') + '</h2>' +
'<div class="cardChoice">' +
'<button id="single" type="button" data-mode="single"><strong>싱글</strong><br><small>혼자 즐기는 모드. 4단계만 진행합니다.</small></button>' +
'<button id="multi" type="button" data-mode="multi"><strong>멀티</strong><br><small>친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다.</small></button>' +
'<button id="single" type="button" data-mode="single"><strong>' + tt('step2.singleTitle') + '</strong><br><small>' + tt('step2.singleHint') + '</small></button>' +
'<button id="multi" type="button" data-mode="multi"><strong>' + tt('step2.multiTitle') + '</strong><br><small>' + tt('step2.multiHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var modeButtons = section.querySelectorAll('[data-mode]')
function applySelection(mode) {
function applyMode(mode) {
state.mode = mode
modeButtons.forEach(function (btn) {
if (btn.getAttribute('data-mode') === mode) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
// 모드가 바뀌면 이전에 골랐던 역할은 의미가 없어진다. 멀티→싱글 전환 시 잔존하던
// role 이 다음 단계 분기에 영향 주지 않도록 명시적으로 초기화.
if (mode !== 'multi') state.role = null
}
modeButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applySelection(btn.getAttribute('data-mode'))
applyMode(btn.getAttribute('data-mode'))
})
})
if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode)
if (state.mode === 'single' || state.mode === 'multi') {
applyMode(state.mode)
}
nextBtn.addEventListener('click', function () {
if (!state.mode) return
state.stepDone[2] = true
if (state.mode === 'single') renderStep4()
else renderStep3()
// 멀티는 호스트/참가자 선택 탭을 거친다. 싱글은 곧장 클라이언트(step4) 로.
if (state.mode === 'multi') renderStep2Role()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderStep1)
}
function renderStep2Role() {
// 스텝 인디케이터는 여전히 2 단계 안쪽이다 — 호스트/참가자 선택은 모드 선택의
// 하위 결정이기 때문. 별도 탭으로 분리해서 한 화면에 한 결정만 보이도록 한다.
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('step2.roleHeading') + '</h2>' +
'<div class="cardChoice">' +
'<button type="button" data-role="host"><strong>' + tt('step2.hostTitle') + '</strong><br><small>' + tt('step2.hostHint') + '</small></button>' +
'<button type="button" data-role="participant"><strong>' + tt('step2.participantTitle') + '</strong><br><small>' + tt('step2.participantHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var roleButtons = section.querySelectorAll('[data-role]')
function applyRole(role) {
state.role = role
roleButtons.forEach(function (btn) {
if (btn.getAttribute('data-role') === role) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
roleButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyRole(btn.getAttribute('data-role'))
})
})
if (state.role === 'host' || state.role === 'participant') applyRole(state.role)
nextBtn.addEventListener('click', function () {
if (!state.role) return
// 호스트는 서버 설치(step3) 부터, 참가자는 클라이언트(step4) 로 바로.
if (state.role === 'host') renderStep3()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderStep2)
}
function renderStep3() {
setActiveStep(3)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>3단계. 서버 관련 설정</h2>' +
'<h2>' + tt('step3.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) }
// step3 는 멀티+호스트 만 진입하므로 sub31 의 back 은 역할 선택 탭으로.
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2Role, show32) }
function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) }
function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) }
function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) }
@@ -180,12 +268,12 @@ function renderStep3() {
function renderSubStep31(host, back, done) {
host.innerHTML =
'<h3>3-1. 서버 설치 경로</h3>' +
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
'<h3>' + tt('step3.sub31.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub31.description') + '</p>' +
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' +
'<button class="secondaryBtn" id="pickFolder">' + tt('step3.sub31.pickFolder') + '</button></div>' +
'<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
var input = host.querySelector('#installPath')
var msg = host.querySelector('#msg')
host.querySelector('#pickFolder').addEventListener('click', async function () {
@@ -196,11 +284,11 @@ function renderSubStep31(host, back, done) {
host.querySelector('#next').addEventListener('click', async function () {
var result = await installerApi.validateInstallPath(input.value.trim())
if (!result.ok) {
msg.textContent = result.message || '경로가 유효하지 않습니다.'
msg.textContent = result.message || tt('step3.sub31.invalidPath')
msg.classList.add('error')
return
}
msg.textContent = '경로 확정: ' + result.message
msg.textContent = tt('step3.sub31.confirmed', { message: result.message })
msg.classList.remove('error')
msg.classList.add('success')
state.serverInstall.path = input.value.trim()
@@ -210,14 +298,14 @@ function renderSubStep31(host, back, done) {
function renderSubStep32(host, back, done) {
host.innerHTML =
'<h3>3-2. JDK 확인</h3>' +
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 없으면 "자동 설치" 로 Temurin 21 을 받아 설치할 수 있습니다.</p>' +
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
'<button class="secondaryBtn" id="auto">자동 탐색</button>' +
'<button class="secondaryBtn" id="install">자동 설치</button></div>' +
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
'<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
var input = host.querySelector('#jdkPath')
var msg = host.querySelector('#msg')
var installBtn = host.querySelector('#install')
@@ -229,7 +317,7 @@ function renderSubStep32(host, back, done) {
function setInstallingUi(on) {
installing = on
if (on) {
installBtn.textContent = '설치 취소'
installBtn.textContent = tt('step3.sub32.installCancel')
installBtn.classList.remove('secondaryBtn')
installBtn.classList.add('dangerBtn')
autoBtn.disabled = true
@@ -237,7 +325,7 @@ function renderSubStep32(host, back, done) {
nextBtn.disabled = true
input.disabled = true
} else {
installBtn.textContent = '자동 설치'
installBtn.textContent = tt('step3.sub32.install')
installBtn.classList.remove('dangerBtn')
installBtn.classList.add('secondaryBtn')
autoBtn.disabled = false
@@ -252,11 +340,11 @@ function renderSubStep32(host, back, done) {
var detect = await installerApi.detectJdk()
if (detect.found) {
input.value = detect.path
msg.textContent = 'JDK 발견: ' + detect.path
msg.textContent = tt('step3.sub32.found', { path: detect.path })
msg.classList.remove('error')
msg.classList.add('success')
} else {
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 눌러 Temurin 21 을 설치하거나 직접 선택해 주세요.'
msg.textContent = tt('step3.sub32.notFound')
msg.classList.remove('success')
msg.classList.add('error')
}
@@ -269,27 +357,30 @@ function renderSubStep32(host, back, done) {
installBtn.addEventListener('click', async function () {
if (installing) {
// 진행 중이면 취소.
msg.textContent = 'JDK 설치 취소 요청 중...'
msg.textContent = tt('step3.sub32.cancelRequested')
msg.classList.remove('success', 'error')
await installerApi.cancelJdkInstall()
return
}
setInstallingUi(true)
msg.classList.remove('success', 'error')
msg.textContent = 'Temurin 21 다운로드 중... (네트워크 상태에 따라 1~5분)'
msg.textContent = tt('step3.sub32.downloading')
try {
var result = await installerApi.installJdk()
if (result.ok && result.path) {
input.value = result.path
state.serverInstall.jdk = result.path
msg.textContent = 'JDK 자동 설치 완료: ' + result.path
msg.textContent = tt('step3.sub32.installComplete', { path: result.path })
msg.classList.add('success')
} else {
msg.textContent = 'JDK 설치 ' + (result.message === '취소됨' ? '취소됨' : '실패: ' + (result.message || '알 수 없는 오류'))
var raw = result.message || tt('common.unknownError')
msg.textContent = raw === '취소됨'
? tt('step3.sub32.installCanceled')
: tt('step3.sub32.installFailed', { message: raw })
msg.classList.add('error')
}
} catch (err) {
msg.textContent = 'JDK 설치 오류: ' + (err && err.message ? err.message : err)
msg.textContent = tt('step3.sub32.installError', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
} finally {
setInstallingUi(false)
@@ -302,7 +393,7 @@ function renderSubStep32(host, back, done) {
nextBtn.addEventListener('click', function () {
if (installing) return
if (!input.value.trim()) {
msg.textContent = 'JDK 경로를 입력해 주세요.'
msg.textContent = tt('step3.sub32.pathRequired')
msg.classList.add('error')
return
}
@@ -313,24 +404,24 @@ function renderSubStep32(host, back, done) {
var detect = await installerApi.detectJdk()
if (detect.found && !input.value) {
input.value = detect.path
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
msg.textContent = tt('step3.sub32.autoDetected', { path: detect.path })
msg.classList.add('success')
} else if (!detect.found) {
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. "자동 설치" 를 누르면 Temurin 21 LTS 를 받아 설치합니다.'
msg.textContent = tt('step3.sub32.notFoundHint')
}
})()
}
function renderSubStep33(host, back, done) {
host.innerHTML =
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
'<h3>' + tt('step3.sub33.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub33.description') + '</p>' +
'<div class="formMessage" id="downloadStatus">' + tt('step3.sub33.waiting') + '</div>' +
'<div id="ramSection" hidden style="margin-top:14px;">' +
'<h4>램 검사</h4>' +
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
'<h4>' + tt('step3.sub33.ramHeading') + '</h4>' +
'<div class="formMessage" id="ramMsg">' + tt('step3.sub33.ramChecking') + '</div>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var statusEl = host.querySelector('#downloadStatus')
var ramSection = host.querySelector('#ramSection')
@@ -345,7 +436,7 @@ function renderSubStep33(host, back, done) {
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
statusEl.textContent = tt('step3.sub33.doneSummary')
statusEl.classList.add('success')
showRamResult(state.serverInstall.ram)
nextBtn.disabled = false
@@ -357,29 +448,29 @@ function renderSubStep33(host, back, done) {
state.serverInstall.eulaAccepted = false
nextBtn.disabled = true
statusEl.classList.remove('success', 'error')
statusEl.textContent = '다운로드 중...'
statusEl.textContent = tt('step3.sub33.downloading')
try {
await installerApi.startServerInstall({
packKey: state.selectedPackKey,
installPath: state.serverInstall.path,
jdkPath: state.serverInstall.jdk
})
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
statusEl.textContent = tt('step3.sub33.eulaPrompt')
var accepted = await openEulaPopup(state.serverInstall.path)
if (!accepted) {
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소했습니다. 이전→다음으로 다시 시도하세요.'
statusEl.textContent = tt('step3.sub33.eulaRejected')
statusEl.classList.add('error')
return
}
try {
await installerApi.acceptEula(state.serverInstall.path)
} catch (err) {
statusEl.textContent = 'EULA 저장 실패: ' + err.message
statusEl.textContent = tt('step3.sub33.eulaSaveFailed', { message: err.message })
statusEl.classList.add('error')
return
}
state.serverInstall.eulaAccepted = true
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
statusEl.textContent = tt('step3.sub33.doneSummary')
statusEl.classList.add('success')
var ram = await installerApi.checkRam(state.selectedPackKey)
state.serverInstall.ram = ram
@@ -387,7 +478,7 @@ function renderSubStep33(host, back, done) {
if (ram.decision === 'tooLow') return
nextBtn.disabled = false
} catch (err) {
statusEl.textContent = '다운로드 실패: ' + (err && err.message ? err.message : err)
statusEl.textContent = tt('step3.sub33.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
statusEl.classList.add('error')
}
})()
@@ -398,44 +489,40 @@ function renderSubStep33(host, back, done) {
if (result.decision === 'tooLow') {
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
var minRam = pack ? pack.pack.serverMinRam : 0
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + minRam + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
ramMsg.innerHTML = tt('step3.sub33.ramTooLow', { system: result.systemRamMb, min: minRam })
ramMsg.classList.add('error')
} else if (result.decision === 'minOk') {
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
ramMsg.innerHTML = tt('step3.sub33.ramMinOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('warn')
} else {
ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
ramMsg.textContent = tt('step3.sub33.ramMaxOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('success')
}
}
}
// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
async function openEulaPopup(installPath) {
var read = await installerApi.readEula(installPath)
// eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 EULA 페이지를 받아서
// 표시한다 — 사용자가 실제 서버 약관을 보고 동의하도록.
async function openEulaPopup(_installPath) {
var bodyHtml = ''
if (read.exists) {
bodyHtml = '<p class="formMessage">서버 파일에 포함된 eula.txt 내용입니다.</p>' +
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
} else {
var fetched = await installerApi.fetchMinecraftEula()
if (fetched.html) {
bodyHtml = '<p class="formMessage">서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href="' + fetched.url + '" target="_blank">' + fetched.url + '</a>).</p>' +
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
} else {
bodyHtml = '<p class="formMessage error">EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href="https://www.minecraft.net/en-us/eula" target="_blank">https://www.minecraft.net/en-us/eula</a></p>'
}
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
}
return new Promise(function (resolve) {
var overlay = document.createElement('div')
overlay.className = 'modalOverlay'
overlay.innerHTML =
'<div class="modalCard" role="dialog" aria-modal="true">' +
'<header><h3>Minecraft EULA 동의</h3><button type="button" class="modalClose" aria-label="닫기">×</button></header>' +
'<header><h3>' + tt('step3.eulaModal.title') + '</h3><button type="button" class="modalClose" aria-label="' + tt('common.close') + '">×</button></header>' +
'<div class="modalBody">' + bodyHtml + '</div>' +
'<footer class="actionRow">' +
'<button type="button" class="secondaryBtn" data-action="reject">비동의</button>' +
'<button type="button" class="primaryBtn" data-action="accept">동의</button>' +
'<button type="button" class="secondaryBtn" data-action="reject">' + tt('common.reject') + '</button>' +
'<button type="button" class="primaryBtn" data-action="accept">' + tt('common.agree') + '</button>' +
'</footer>' +
'</div>'
document.body.appendChild(overlay)
@@ -456,30 +543,24 @@ async function openEulaPopup(installPath) {
})
}
function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, function (ch) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]
})
}
function escapeAttr(text) {
return String(text).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
}
function renderSubStep34(host, back, done) {
host.innerHTML =
'<h3>3-4. 서버 설정 편집</h3>' +
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
'<button class="secondaryBtn" id="open">편집기 열기</button>' +
'<h3>' + tt('step3.sub34.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub34.description') + '</p>' +
'<button class="secondaryBtn" id="open">' + tt('step3.sub34.open') + '</button>' +
'<div class="formMessage" id="editorMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
host.querySelector('#open').addEventListener('click', async function () {
var msg = host.querySelector('#editorMsg')
try {
var result = await installerApi.startServerConfigEditor(state.serverInstall.path)
msg.innerHTML = '편집기 주소: <a href="' + result.url + '" target="_blank">' + result.url + '</a>'
msg.innerHTML = tt('step3.sub34.openedAt', { url: result.url })
} catch (err) {
msg.textContent = '편집기 실행 실패: ' + err.message
msg.textContent = tt('step3.sub34.openFailed', { message: err.message })
msg.classList.add('error')
}
})
@@ -489,39 +570,48 @@ function renderSubStep34(host, back, done) {
function renderSubStep35(host, back, done) {
host.innerHTML =
'<h3>3-5. 포트포워딩 점검</h3>' +
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
'<button class="secondaryBtn" id="run">재점검</button>' +
'<h3>' + tt('step3.sub35.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub35.description') + '</p>' +
'<div class="fieldset"><label>' + tt('step3.sub35.portLabel') + ' <input id="port" type="text" value="25565" /></label></div>' +
'<button class="secondaryBtn" id="run">' + tt('step3.sub35.recheck') + '</button>' +
'<div class="formMessage" id="resultMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var resultMsg = host.querySelector('#resultMsg')
var nextBtn = host.querySelector('#next')
var runBtn = host.querySelector('#run')
host.querySelector('#back').addEventListener('click', back)
// 25565 는 마인크래프트 자바판 기본 포트라 클라이언트에서 생략 가능 →
// 사용자에게도 ip 만 보여주는 게 깔끔하다.
function formatServerAddress(ip, port) {
var safeIp = ip || tt('step3.sub35.ipUnknown')
if (Number(port) === 25565) return safeIp
return safeIp + ':' + port
}
async function runCheck() {
runBtn.disabled = true
resultMsg.classList.remove('success', 'warn', 'error')
resultMsg.textContent = '확인 중...'
resultMsg.textContent = tt('step3.sub35.checking')
var port = Number(host.querySelector('#port').value) || 25565
try {
var result = await installerApi.checkPortForward(port)
state.serverInstall.portStatus = result
var address = formatServerAddress(result.externalIp, result.port)
if (result.status === 'preForwarded') {
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
resultMsg.classList.add('success')
} else if (result.status === 'upnpOk') {
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
resultMsg.classList.add('success')
} else {
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
tt('step3.sub35.manualDetail', { address: address })
resultMsg.classList.add('warn')
}
nextBtn.disabled = false
} catch (err) {
resultMsg.textContent = '점검 실패: ' + (err && err.message ? err.message : err)
resultMsg.textContent = tt('step3.sub35.checkFailed', { message: (err && err.message) ? err.message : String(err) })
resultMsg.classList.add('error')
} finally {
runBtn.disabled = false
@@ -541,87 +631,58 @@ function renderStep4() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>4단계. 유저 클라이언트 설정</h2>' +
'<h2>' + tt('step4.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) }
function show43() {
subHost.innerHTML = ''
renderSubStep43(subHost, show42, function () {
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
var platformType = pack ? pack.pack.platform.type : 'vanilla'
state.client.installPlatform = platformType !== 'vanilla'
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
// 멀티+참가자 는 직전 화면이 역할 선택 탭이므로 거기로, 싱글은 모드 탭으로.
function backToPrevStep() {
if (state.mode === 'multi' && state.role === 'host') renderStep3()
else if (state.mode === 'multi') renderStep2Role()
else renderStep2()
}
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
function goStep5() {
state.stepDone[4] = true
renderStep5()
})
}
show41()
}
function renderSubStep41(host, pack, back, done) {
var platformType = pack ? pack.pack.platform.type : 'vanilla'
if (platformType === 'vanilla') {
state.client.installPlatform = false
host.innerHTML =
'<h3>4-1. 모드 플랫폼</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong></p>' +
'<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
return
}
host.innerHTML =
'<h3>4-1. 모드 플랫폼</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>' + platformType + '</strong></p>' +
'<div class="cardChoice">' +
'<button type="button" data-choice="install"><strong>권장 플랫폼 설치</strong><br><small>' + platformType + ' 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.</small></button>' +
'<button type="button" data-choice="skip"><strong>기본 마인크래프트로 설치</strong><br><small>플랫폼은 설치하지 않고 바닐라로 진행합니다.</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var nextBtn = host.querySelector('#next')
var choiceButtons = host.querySelectorAll('[data-choice]')
function applyChoice(choice) {
state.client.installPlatform = choice === 'install'
choiceButtons.forEach(function (btn) {
if (btn.getAttribute('data-choice') === choice) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
choiceButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyChoice(btn.getAttribute('data-choice'))
})
})
if (typeof state.client.installPlatform === 'boolean') {
applyChoice(state.client.installPlatform ? 'install' : 'skip')
}
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
show42()
}
function renderSubStep42(host, back, done) {
host.innerHTML =
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
'<div class="formMessage" id="msg">설치 중...</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<h3>' + tt('step4.sub42.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub42.description') + '</p>' +
'<div class="formMessage" id="msg">' + tt('step4.sub42.installing') + '</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var msg = host.querySelector('#msg')
var nextBtn = host.querySelector('#next')
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
// 이미 설치됐다면 다시 돌리지 않음
if (state.client.clientInstalled) {
msg.textContent = '클라이언트 설치 완료.'
// 이번에 실제로 보내야 할 payload. 이전 진입에서 같은 payload 로 이미 끝났으면
// 다시 돌리지 않지만, packKey / installPlatform / skipMap 중 하나라도 다르면
// (예: 참가자 → 싱글 로 뒤로가서 변경) 재설치한다.
var payload = {
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform,
// 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다.
skipMap: state.mode === 'multi' && state.role === 'participant'
}
var last = state.client.lastInstall
if (last
&& last.packKey === payload.packKey
&& last.installPlatform === payload.installPlatform
&& last.skipMap === payload.skipMap) {
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
nextBtn.disabled = false
return
@@ -630,53 +691,45 @@ function renderSubStep42(host, back, done) {
// 페이지 진입 즉시 자동 설치
;(async function () {
try {
await installerApi.installClient({
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform
})
msg.textContent = '클라이언트 설치 완료.'
await installerApi.installClient(payload)
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
state.client.clientInstalled = true
state.client.lastInstall = payload
nextBtn.disabled = false
} catch (err) {
msg.textContent = '설치 실패: ' + (err && err.message ? err.message : err)
// 실패한 호출은 "마지막 성공" 기록에 남기지 않는다. 다음 진입 시 재시도.
state.client.lastInstall = null
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
}
})()
}
function renderSubStep43(host, back, done) {
host.innerHTML =
'<h3>4-3. 완료 확인</h3>' +
'<p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">5단계로</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
}
function renderStep5() {
setActiveStep(5)
clearPage()
var section = document.createElement('section')
section.className = 'page'
var multi = state.mode === 'multi'
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
var showServerActions = state.mode === 'multi' && state.role === 'host'
section.innerHTML =
'<h2>5단계. 설치 완료</h2>' +
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</p>' +
(multi ? '<div class="subStep">' +
'<h3>서버</h3>' +
'<button class="secondaryBtn" id="openFolder">서버 폴더 열기</button>' +
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> 바탕화면에 서버 실행 바로가기 만들기</label>' +
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> 서버 바로 실행</label>' +
'<h2>' + tt('step5.heading') + '</h2>' +
'<p>' + tt('step5.summary') + '</p>' +
(showServerActions ? '<div class="subStep">' +
'<h3>' + tt('step5.serverHeading') + '</h3>' +
'<button class="secondaryBtn" id="openFolder">' + tt('step5.openServerFolder') + '</button>' +
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> ' + tt('step5.shortcut') + '</label>' +
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> ' + tt('step5.startServer') + '</label>' +
'</div>' : '') +
'<div class="subStep">' +
'<h3>마인크래프트 런처</h3>' +
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> 마인크래프트 런처 실행</label>' +
'<h3>' + tt('step5.launcherHeading') + '</h3>' +
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> ' + tt('step5.startLauncher') + '</label>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="finish">완료</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="finish">' + tt('step5.finish') + '</button></div>'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep4)
if (multi) {
if (showServerActions) {
section.querySelector('#openFolder').addEventListener('click', function () {
installerApi.openServerFolder()
})
@@ -684,9 +737,9 @@ function renderStep5() {
section.querySelector('#finish').addEventListener('click', async function () {
var finishBtn = section.querySelector('#finish')
finishBtn.disabled = true
finishBtn.textContent = '마무리 중…'
finishBtn.textContent = tt('step5.finishing')
try {
if (multi) {
if (showServerActions) {
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
if (section.querySelector('#startServer').checked) await installerApi.startServer()
}
@@ -694,9 +747,16 @@ function renderStep5() {
} catch (err) {
// 마무리 액션 실패는 무시하고 종료 진행
}
finishBtn.textContent = '완료됨'
finishBtn.textContent = tt('step5.finished')
if (installerApi.quitApp) installerApi.quitApp()
})
}
renderStep1()
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
;(async function () {
try {
I18N = (await installerApi.loadLocale()) || {}
} catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()

View File

@@ -0,0 +1,129 @@
{
"app": {
"title": "마인크래프트 음악퀴즈 리소스팩 간편설치기"
},
"stepIndicator": {
"step1": "1. 음악퀴즈",
"step2": "2. 설치",
"step3": "3. 완료"
},
"logViewer": {
"heading": "설치 로그",
"collapse": "접기",
"expand": "펼치기"
},
"common": {
"next": "다음",
"cancel": "취소",
"confirm": "확인",
"openFolder": "리소스팩 폴더 열기",
"loading": "목록을 불러오는 중...",
"selectFailed": "선택 실패",
"listLoadFailed": "목록 로드 실패: {{message}}",
"installFailed": "설치 실패: {{message}}",
"noPacks": "등록된 음악퀴즈가 없습니다.",
"mcVersionLabel": "마인크래프트 {{version}} · ",
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
"requestTimeout": "요청 시간 초과",
"tooManyRedirects": "너무 많은 요청."
},
"step1": {
"heading": "음악퀴즈 선택"
},
"step2": {
"heading": "리소스팩 설치",
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
"chipYtdlp": "yt-dlp 준비",
"chipFfmpeg": "ffmpeg 준비",
"musicHeading": "음악 다운로드",
"musicSub": "{{count}}곡",
"imageHeading": "사진 다운로드",
"imageSub": "{{count}}장",
"packageHeading": "리소스팩 빌드",
"packageWaiting": "대기 중…",
"packageBuilding": "빌드 중…",
"packageDone": "설치 완료",
"cardWaiting": "대기",
"cardDone": "완료",
"cardError": "실패"
},
"step3": {
"heading": "완료",
"message": "리소스팩 설치를 완료했습니다."
},
"log": {
"manifestDownload": "manifest 다운로드: {{url}}",
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
"listLoadFailed": "목록 로드 실패 ({{file}}): {{message}}",
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
"packEntry": " - {{key}}: mc={{mc}} 베이스={{base}}",
"packEntryUnknownVersion": "?",
"packEntryNoBase": "(없음)",
"selectedPack": "선택: {{key}}",
"ytdlpPreparing": "yt-dlp 준비 중…",
"ytdlpPath": "yt-dlp 경로: {{path}}",
"ffmpegPreparing": "ffmpeg 준비 중…",
"ffmpegPath": "ffmpeg 경로: {{path}}",
"cpuDetected": "CPU 코어 {{cores}}개 감지 → 동시 다운로드 {{concurrency}}개",
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"imageStart": "사진 다운로드 시작 ({{total}}장)",
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
"imageDone": "{{idx}}번 사진 완료: {{name}}",
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
"baseUrl": " URL: {{url}}",
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
"installComplete": "설치 완료: {{path}}",
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",
"ytdlpExists": "yt-dlp.exe 이미 있음: {{path}}",
"ytdlpDownloading": "yt-dlp.exe 다운로드 중: {{url}}",
"ytdlpReady": "yt-dlp.exe 준비 완료: {{path}}",
"ffmpegExists": "ffmpeg.exe 이미 있음: {{path}}",
"ffmpegDownloading": "ffmpeg.exe 다운로드 중: {{url}}",
"ffmpegExtracting": "ffmpeg zip 압축 해제 중…",
"ffmpegReady": "ffmpeg.exe 준비 완료: {{path}}",
"baseExtract": "베이스 리소스팩 압축 해제: {{name}}",
"baseShaderOverrideStripped": "베이스 리소스팩의 vanilla 셰이더 오버라이드 제거: assets/minecraft/shaders/{{path}} — mcVersion {{mc}} (pack_format {{format}}) 의 새 GLSL API 와 호환되지 않아 결과 zip 에서 제외했습니다.",
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
"ytdlpLine": "yt-dlp> {{line}}"
},
"progress": {
"ytdlpPreparing": "yt-dlp 준비 중",
"ffmpegPreparing": "ffmpeg 준비 중",
"ready": "준비 완료",
"cancelled": "취소됨",
"baseDownloading": "베이스 리소스팩 다운로드 중",
"buildingWithBase": "베이스에 음악·사진 추가 중",
"buildingZip": "zip 빌드 중",
"installComplete": "설치 완료"
},
"pack": {
"description": "음악퀴즈 리소스팩 - {{name}}"
},
"errors": {
"selectedPackNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
"selectPackFirst": "음악퀴즈를 먼저 선택해주세요.",
"currentPackNotFound": "선택된 음악퀴즈를 찾을 수 없습니다.",
"cancelledByUser": "사용자가 설치를 취소했습니다.",
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",
"ytdlpExit": "yt-dlp 종료 코드 {{code}}: {{stderr}}",
"ytdlpNoStderr": "(stderr 없음)",
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
"imageMetaUnknown": "이미지 크기를 읽지 못함",
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
}
}

View File

@@ -0,0 +1,295 @@
{
"common": {
"back": "이전",
"next": "다음",
"ok": "확인",
"cancel": "취소",
"close": "닫기",
"agree": "동의",
"reject": "비동의",
"apply": "적용",
"save": "저장",
"load": "불러오기",
"expand": "펼치기",
"collapse": "접기",
"saved": "저장 완료",
"saveFailed": "저장 실패",
"unknownError": "알 수 없는 오류"
},
"app": {
"browserTitle": "마인크래프트 음악퀴즈 간편설치기",
"headerTitle": "마인크래프트 음악퀴즈 간편설치기"
},
"stepIndicator": {
"step1": "1. 음악퀴즈",
"step2": "2. 모드",
"step3": "3. 서버",
"step4": "4. 클라이언트",
"step5": "5. 완료"
},
"logViewer": {
"title": "설치 로그"
},
"step1": {
"heading": "설치할 음악퀴즈 선택",
"loading": "목록을 불러오는 중...",
"empty": "등록된 음악퀴즈가 없습니다.",
"fetchFailed": "목록을 가져오지 못했습니다: {{message}}",
"subtitle": "마인크래프트 {{mc}} / {{platform}}"
},
"step2": {
"heading": "싱글 / 멀티 선택",
"singleTitle": "싱글",
"singleHint": "싱글 맵으로 혼자 플레이할때",
"multiTitle": "멀티",
"multiHint": "버킷 서버로 친구들과 같이 플레이할때",
"roleHeading": "호스트 / 참가자",
"hostTitle": "호스트",
"hostHint": "내가 서버를 직접 열고 친구들을 초대할 때",
"participantTitle": "참가자",
"participantHint": "친구가 연 서버에 접속만 할 때"
},
"step3": {
"heading": "서버 관련 설정",
"sub31": {
"heading": "서버 설치 경로",
"description": "서버를 생성할 폴더를 선택하세요.",
"pickFolder": "폴더 선택",
"invalidPath": "경로가 유효하지 않습니다.",
"confirmed": "경로 확정: {{message}}"
},
"sub32": {
"heading": "JDK 확인",
"description": "JDK 자동탐색 or 설치",
"pickFolder": "폴더 선택",
"auto": "자동 탐색",
"install": "자동 설치",
"installCancel": "설치 취소",
"found": "JDK 발견: {{path}}",
"autoDetected": "JDK 자동 탐색됨: {{path}}",
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.",
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.",
"cancelRequested": "JDK 설치 취소 요청 중...",
"downloading": "JDK 다운로드 중...",
"installComplete": "JDK 자동 설치 완료: {{path}}",
"installCanceled": "JDK 설치 취소됨",
"installFailed": "JDK 설치 실패: {{message}}",
"installError": "JDK 설치 오류: {{message}}",
"pathRequired": "JDK 경로를 입력해 주세요."
},
"sub33": {
"heading": "서버 다운로드 및 설치",
"description": "서버 파일 다운로드",
"waiting": "대기 중",
"downloading": "다운로드 중...",
"ramHeading": "램 검사",
"ramChecking": "검사 중...",
"eulaPrompt": "EULA 동의가 필요합니다. 팝업을 확인해 주세요.",
"eulaRejected": "EULA 동의 실패. 다운로드를 취소했습니다.",
"eulaSaveFailed": "EULA 저장 실패: {{message}}",
"doneSummary": "다운로드 및 EULA 동의 완료.",
"downloadFailed": "다운로드 실패: {{message}}",
"ramTooLow": "시스템 램({{system}}MB)이 음악퀴즈 최소 요구치({{min}}MB)에 미치지 못합니다. 설치를 중단합니다.",
"ramMinOk": "시스템 램({{system}}MB)이 권장치보다 부족합니다. 최소치({{applied}}MB)로 진행합니다.",
"ramMaxOk": "시스템 램({{system}}MB) 확인. {{applied}}MB로 설정."
},
"eulaModal": {
"title": "Minecraft EULA 동의",
"fromMojang": "마인크래프트 서버를 실행하려면 아래 EULA에 동의해야 합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
"loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href=\"https://www.minecraft.net/en-us/eula\" target=\"_blank\">https://www.minecraft.net/en-us/eula</a>"
},
"sub34": {
"heading": "서버 설정 편집",
"description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.",
"open": "편집기 열기",
"openedAt": "편집기 주소: <a href=\"{{url}}\" target=\"_blank\">{{url}}</a>",
"openFailed": "편집기 실행 실패: {{message}}"
},
"sub35": {
"heading": "포트포워딩",
"description": "UPNP를 개방해 외부 접속을 허용합니다.",
"portLabel": "포트",
"recheck": "재점검",
"checking": "확인 중...",
"preForwarded": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)",
"upnpOk": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)",
"manualHint": "직접 포트포워딩을 해주세요.",
"manualDetail": "<br><small>외부 주소: {{address}}</small>",
"checkFailed": "점검 실패: {{message}}",
"ipUnknown": "확인 불가"
}
},
"step4": {
"heading": "클라이언트 설정",
"sub42": {
"heading": "다운로드 및 적용",
"description": "클라이언트 설정",
"installing": "설치 중...",
"done": "클라이언트 설치 완료.",
"failed": "설치 실패: {{message}}"
}
},
"step5": {
"heading": "설치 완료",
"summary": "",
"serverHeading": "서버",
"openServerFolder": "서버 폴더 열기",
"shortcut": "바탕화면에 서버 실행 바로가기 만들기",
"startServer": "서버 바로 실행",
"launcherHeading": "마인크래프트 런처",
"startLauncher": "마인크래프트 런처 실행",
"finish": "완료",
"finishing": "마무리 중…",
"finished": "완료됨"
},
"configEditor": {
"pageTitle": "서버 설정 편집기",
"heading": "서버 설정 편집기",
"intro": "아래 파일을 직접 편집한 후 \"적용\" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.",
"targetLabel": "대상 파일",
"applyButton": "적용",
"saved": "저장 완료",
"saveFailed": "저장 실패",
"unknownFile": "알 수 없는 파일",
"serverError": "서버 오류: {{message}}"
},
"errors": {
"requestTimeout": "요청 시간 초과",
"requestTimeout15s": "요청 시간 초과(15s)",
"canceled": "취소되었습니다.",
"canceledShort": "취소됨",
"packNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
"packNotFound2": "음악퀴즈를 찾을 수 없습니다.",
"installPathRequired": "서버 설치 경로를 입력해 주세요.",
"installPathHangul": "경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.",
"installPathHangulShort": "경로에 한글이 포함되면 안 됩니다.",
"jdkBusy": "이미 JDK 설치가 진행 중입니다.",
"javaExeMissing": "설치 후 java 실행 파일을 찾지 못했습니다: {{path}}",
"javaSpawnFailed": "Java 실행 실패: {{message}}",
"fabricInstallerExit": "fabric-installer 종료 코드 {{code}}{{detail}}",
"fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.",
"fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.",
"portAllocFail": "포트를 할당할 수 없습니다.",
"upnpTimeout": "UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.",
"parseResponseFailed": "응답 파싱 실패: {{snippet}}"
},
"log": {
"manifestDownload": "manifest 다운로드: {{url}}",
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
"selectedPack": "선택: {{key}}",
"jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...",
"jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)",
"jdkExtracting": "JDK 압축 해제 중...",
"jdkDoneRoot": "JDK 자동 설치 완료: {{path}}",
"jdkCanceled": "JDK 설치가 취소되었습니다.",
"jdkInstallFailedLog": "JDK 설치 실패: {{message}}",
"jdkCancelRequested": "JDK 설치 취소 요청을 보냈습니다.",
"labelDownload": "{{label}} 다운로드: {{url}}",
"labelExtract": "{{label}} 압축 해제: {{dir}}",
"labelServerFile": "서버 파일",
"labelMap": "맵",
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
"mapInstalledAs": "맵을 saves/{{name}} 으로 설치했습니다.",
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
"modsIndexFetch": "모드 목록 조회: {{url}}",
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",
"modDownload": "모드 다운로드: {{file}}",
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
"serverInstallPath": "서버 설치 경로: {{path}}",
"runBatMissing": "run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.",
"runBatAlreadyInjected": "run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.",
"runBatNoJava": "run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.",
"runBatInjected": "run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.",
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
"eulaAccepted": "EULA 동의 저장 완료.",
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
"portCheckStart": "포트포워딩 점검 시작: 포트 {{port}}",
"upnpCleanup": "이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...",
"externalIpHttp": "외부 IP 확인(HTTP): {{ip}}",
"externalIpHttpFail": "외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...",
"externalIpUpnp": "외부 IP 확인(UPnP): {{ip}}",
"externalIpUpnpFail": "UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.",
"probeStart": "외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...",
"probeResult": "1차 점검 결과: {{verdict}} ({{detail}})",
"probeVerdictSuccess": "성공",
"probeVerdictFail": "실패",
"probeVerdictUnknown": "확인 불가",
"probePreForwarded": "외부에서 {{addr}}:{{port}} 접근 확인됨. 사용자 규칙으로 포워딩 됨.",
"ipUnknown": "(IP 미상)",
"upnpTryOpen": "UPnP로 포트 {{port}} 자동 개방 시도(TCP)...",
"upnpReqOk": "UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.",
"upnpTryFail": "UPnP 시도 실패: {{message}}",
"upnpFailDetail": "UPnP 실패: {{message}}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.",
"upnpRecheck": "UPnP 적용 후 재점검 {{attempt}}/3...",
"upnpDone": "UPnP로 포트 {{port}} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).",
"upnpCleanupTest": "테스트용 UPnP 매핑을 정리합니다.",
"upnpFailReason1": "UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.",
"upnpFailReason2": "외부 포트체크 결과를 받지 못했습니다({{detail}}). UPnP 매핑은 등록됐을 수 있습니다.",
"upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}",
"upnpExternalTimeout": "UPnP externalIp 조회 타임아웃(8s).",
"upnpExternalErr": "UPnP externalIp 오류: {{message}}",
"portInUse": "포트 {{port}}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.",
"listenerBindFail": "임시 리스너 바인딩 실패: {{message}}",
"detailListenerHit": "임시 리스너 도달={{value}}",
"detailListenerSkip": "임시 리스너=skip(포트 사용중)",
"detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}",
"detailIfconfigFail": "ifconfig.co 실패={{error}}",
"detailNone": "결과 없음",
"upnpClientFailRemove": "UPnP 클라이언트 생성 실패(매핑 제거 단계): {{message}}",
"upnpRemoveTimeout": "UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.",
"upnpRemoveAttempt": "UPnP 매핑 제거 시도 결과: {{message}} (없으면 정상)",
"upnpRemoveDone": "UPnP 매핑 제거 완료(포트 {{port}}).",
"platformDownload": "플랫폼({{type}}) 다운로드: {{url}}",
"platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)",
"platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.",
"fabricFetchInstallerList": "Fabric installer 최신 버전 조회 중...",
"fabricInstallerDownload": "Fabric installer {{version}} 다운로드: {{url}}",
"javaUsed": "Java 사용: {{path}}",
"fabricInstallStart": "Fabric 자동 설치 시작: {{mc}} / loader {{loader}} → {{dir}}",
"fabricInstallDone": "Fabric 자동 설치 완료.",
"fabricAlreadyInstalled": "Fabric 이미 설치돼 있어 건너뜁니다: {{id}} ({{dir}})",
"launcherProfilesMissing": "launcher_profiles.json을 찾을 수 없습니다: {{path}}",
"javaArgsUpdated": "JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): \"{{before}}\" → \"{{after}}\"",
"lastVersionId": "launcher_profiles 의 lastVersionId = {{id}}",
"versionMissingWarn": "경고: .minecraft/versions/{{id}} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.",
"launcherProfilesUpdated": "launcher_profiles.json 갱신: 프로필 \"{{profile}}\", gameDir={{dir}}",
"minecraftRootMissing": ".minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.",
"settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}",
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{skipped}}개.",
"settingCopyError": "기존 설정 복사 중 오류: {{message}}",
"runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.",
"runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.",
"runtimeLinkCreated": "링크 생성: .mc_custom/{{dir}} → .minecraft/{{dir}}",
"runtimeLinkFail": "링크 생성 실패 ({{dir}}): {{message}}",
"shortcutCreated": "바로가기 생성: {{path}}",
"shortcutFailed": "바로가기 생성 실패",
"shortcutDescription": "음악퀴즈 서버 실행",
"runBatMissingPath": "run.bat을 찾을 수 없습니다: {{path}}",
"serverStartRequested": "서버 실행 요청 완료.",
"launcherUrlSchemeNonWin": "마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).",
"launcherFail": "런처 실행 실패: {{message}}",
"launcherExecShell": "마인크래프트 런처 실행({{label}}, 셸 경유): {{path}}",
"launcherExec": "마인크래프트 런처 실행({{label}}): {{path}}",
"launcherCandFail": "{{path}} 실행 실패: {{message}}",
"launcherAppsFolderTry": "AppsFolder 로 MS Store 런처 실행 시도: {{aumid}}",
"launcherAppsFolderFail": "AppsFolder 실행 실패: {{message}}",
"launcherUrlSchemeFallback": "마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).",
"launcherUrlSchemeFail": "URL 스킴 실행 실패: {{message}}.",
"launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요."
},
"candidates": {
"winProgramFiles86": "Win32 설치(Program Files (x86))",
"winProgramFiles": "Win32 설치(Program Files)",
"winLegacy86": "Win32 설치(legacy Minecraft 폴더)",
"winLegacy": "Win32 설치(legacy Minecraft 폴더)",
"xboxGamePass": "Xbox / Game Pass",
"npmPortable": "npm/portable",
"appAliasMinecraft": "App Execution Alias(Minecraft.exe)",
"appAliasLauncher": "App Execution Alias(MinecraftLauncher.exe)"
}
}

170
locales/server/ko-kr.json Normal file
View File

@@ -0,0 +1,170 @@
{
"common": {
"back": "← 돌아가기",
"backToList": "목록으로",
"save": "저장",
"cancel": "취소",
"ok": "확인",
"delete": "삭제",
"edit": "수정",
"close": "x",
"loading": "불러오는 중..."
},
"site": {
"indexTitle": "음악퀴즈 목록",
"heroTitle": "마인크래프트 음악퀴즈",
"heroSubtitle": "설치기에서 사용 가능한 음악퀴즈 목록입니다.",
"empty": "등록된 음악퀴즈가 없습니다.",
"fileLabel": "파일: {{file}}.json",
"mcVersion": "마인크래프트",
"platform": "플랫폼",
"modsFolder": "모드 폴더",
"resourcepack": "리소스팩",
"noneFallback": "없음"
},
"nav": {
"brand": "관리자 페이지",
"logout": "로그아웃"
},
"login": {
"title": "관리자 로그인",
"password": "비밀번호",
"submit": "로그인",
"wrongPassword": "비밀번호가 올바르지 않습니다."
},
"dashboard": {
"title": "음악퀴즈 목록",
"browserTitle": "관리자 대시보드",
"editList": "음악목록 수정",
"editDatapack": "데이터팩 수정",
"addPack": "음악퀴즈 추가",
"deletePack": "음악퀴즈 삭제",
"emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
"select": "선택",
"confirmDelete": "삭제 확인",
"mcShort": "MC"
},
"list": {
"browserTitle": "음악목록 수정",
"title": "음악목록 수정"
},
"listEditor": {
"browserTitle": "{{name}} — 음악/사진 목록",
"dirtyTooltip": "저장되지 않은 변경사항이 있습니다",
"tabMusic": "음악목록",
"tabImage": "사진목록",
"saveList": "목록 저장",
"clearList": "목록 초기화",
"playlistPlaceholder": "유튜브 플레이리스트 URL",
"fetchPlaylist": "플레이리스트 불러오기",
"imageFromMusic": "음악목록에서 가져오기",
"modalConfirmTitle": "확인",
"musicEditTitle": "음악 항목 수정",
"musicEditUrl": "유튜브 영상 주소",
"musicEditHint": "저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.",
"imageEditTitle": "사진 항목 수정",
"imageSegYt": "유튜브 주소",
"imageSegImg": "이미지 주소",
"imageEditUrl": "주소",
"titleFallback": "(제목 없음)",
"artistFallback": "(가수 미상)",
"rowEditTooltip": "더블클릭해서 수정",
"aliasBtn": "별칭",
"aliasBtnWithCount": "별칭 ({{count}})",
"aliasModalTitle": "별칭 - {{title}}",
"aliasBack": "← 돌아가기",
"aliasAdd": "별칭 추가",
"aliasPlaceholder": "별칭 입력",
"aliasRemove": "삭제",
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
"metaLoading": "메타데이터 가져오는 중…",
"metaFailedShort": "메타 조회 실패",
"metaFailedTitle": "메타데이터 조회 실패",
"metaFailedAsk": "{{message}}\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?",
"saving": "저장 중…",
"saved": "저장 완료",
"saveFailed": "저장 실패: {{message}}",
"fetchEnterUrl": "플레이리스트 주소를 입력해 주세요.",
"fetchTitle": "플레이리스트 불러오기",
"fetchConfirm": "현재 {{type}}목록 순서가 모두 사라집니다. 진행할까요?",
"fetchTypeMusic": "음악",
"fetchTypeImage": "사진",
"fetchLoading": "불러오는 중…",
"fetchedCount": "{{count}}개 항목을 불러왔습니다.",
"failed": "실패: {{message}}",
"clearTitle": "목록 초기화",
"clearConfirm": "\"{{type}}목록\"을 비웁니다. 진행할까요?",
"imageFromMusicEmpty": "음악목록이 비어 있어 가져올 수 없습니다.",
"imageFromMusicTitle": "사진목록 가져오기",
"imageFromMusicConfirm": "저장된 음악목록의 영상 {{count}}개를 그대로 사진목록으로 가져옵니다.\n현재 사진목록은 모두 사라집니다. 진행할까요?",
"leaveTitle": "저장되지 않은 변경사항",
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
},
"editor": {
"browserTitle": "{{name}} 편집",
"eyebrow": "PACK EDITOR",
"displayName": "음악퀴즈 이름",
"fileName": "JSON 파일 이름 (확장자 제외)",
"mcVersion": "마인크래프트 버전",
"platformType": "모드 플랫폼",
"platformDownloadUrl": "플랫폼 설치파일 URL",
"platformDownloadHint": "도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/&lt;파일명&gt;</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/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
"resourcepackPath": "리소스팩 (.zip)",
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
},
"datapack": {
"browserTitle": "데이터팩 수정",
"title": "데이터팩 수정",
"pickPack": "음악퀴즈 선택",
"pickedNone": "선택된 음악퀴즈 없음",
"pickedLabel": "선택: {{name}}",
"totalCount": "총 {{count}}개의 음악을 찾았습니다.",
"hint": "music_quiz 데이터팩의 data/mq/function/init/songs.mcfunction 파일에 아래 코드를 그대로 덮어쓰세요.",
"export": "코드 출력",
"copy": "복사",
"copied": "복사됨",
"exporting": "출력 중…",
"exported": "출력 완료",
"failed": "실패: {{message}}",
"modalPickTitle": "음악퀴즈 선택",
"imagesZip": "이미지.zip 출력",
"imagesZipSizeLabel": "크기",
"imagesZipDownloading": "이미지.zip 생성 중…",
"imagesZipDone": "이미지.zip 다운로드 완료"
},
"errors": {
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
"packNotFoundJson": "음악퀴즈를 찾을 수 없습니다.",
"videoUrlRequired": "영상 주소를 입력해 주세요.",
"playlistUrlRequired": "플레이리스트 주소를 입력해 주세요.",
"metaNotFound": "메타데이터를 찾을 수 없습니다.",
"ramOrderInvalid": "clientMinRam은 clientRecommendedRam보다 클 수 없습니다.",
"unknown": "알 수 없는 오류",
"serverError": "서버 오류: {{message}}"
},
"youtube": {
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
"tooManyRedirects": "redirect 가 너무 많습니다."
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "minecraft-music-quiz-installer",
"version": "0.1.0",
"version": "0.1.1",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js",
"scripts": {
@@ -9,7 +9,9 @@
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
"installer": "tsc -p tsconfig.installer.json && electron .",
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
"preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5",
"dist:win": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml",
"dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml"
},
"dependencies": {
"@types/archiver": "^7.0.0",

View File

@@ -1,6 +1,22 @@
(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) || '',
@@ -83,20 +99,28 @@
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="(제목 없음)" title="더블클릭해서 수정">' +
'<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="(가수 미상)" title="더블클릭해서 수정">' +
'<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)
})
}
@@ -116,7 +140,7 @@
'<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">(제목 없음)</span>') + '</div>' +
'<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)
@@ -324,13 +348,26 @@
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', '메타데이터 가져오는 중…')
setStatus('edit-music-status', tt('metaLoading'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -339,8 +376,8 @@
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 : '메타 조회 실패'
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
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()
@@ -360,7 +397,7 @@
closeAllModals()
renderMusic()
}).catch(function (err) {
setStatus('edit-music-status', '실패: ' + err.message, true)
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
})
})
@@ -386,20 +423,123 @@
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', '음악목록이 비어 있어 가져올 수 없습니다.', true)
setStatus('status-image', tt('imageFromMusicEmpty'), true)
return
}
ask('사진목록 가져오기',
'저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n'
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
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', state.images.length + '개 항목을 불러왔습니다.')
setStatus('status-image', tt('fetchedCount', { count: state.images.length }))
})
})
@@ -431,7 +571,8 @@
var action = btn.getAttribute('data-action')
var target = btn.getAttribute('data-target')
if (action === 'clear') {
ask('목록 초기화', '"' + (target === 'music' ? '음악' : '사진') + '목록"을 비웁니다. 진행할까요?', function () {
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()
@@ -457,7 +598,7 @@
}
})
var statusId = 'status-' + target
setStatus(statusId, '저장 중…')
setStatus(statusId, tt('saving'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -465,10 +606,10 @@
}).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, '저장 완료'); markClean() }
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
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, '저장 실패: ' + err.message, true)
setStatus(statusId, tt('saveFailed', { message: err.message }), true)
})
}
@@ -476,11 +617,12 @@
var input = document.getElementById(target + '-playlist-url')
var url = input.value.trim()
if (!url) {
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true)
setStatus('status-' + target, tt('fetchEnterUrl'), true)
return
}
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () {
setStatus('status-' + target, '불러오는 중…')
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' },
@@ -489,7 +631,7 @@
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) {
if (!result.ok || !result.body.ok) {
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true)
setStatus('status-' + target, tt('failed', { message: result.body.message || '' }), true)
return
}
var entries = result.body.entries || []
@@ -503,9 +645,9 @@
renderImage()
}
markDirty()
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
}).catch(function (err) {
setStatus('status-' + target, '실패: ' + err.message, true)
setStatus('status-' + target, tt('failed', { message: err.message }), true)
})
})
}
@@ -527,9 +669,7 @@
if (!dirty) return
e.preventDefault()
var href = a.getAttribute('href')
ask('저장되지 않은 변경사항',
'저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?',
function () {
ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
markClean()
window.location.href = href
})

View File

@@ -407,12 +407,42 @@ body.siteBody.centerLayout {
.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;
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; }

View File

@@ -4,6 +4,9 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
@@ -31,7 +34,7 @@ export async function ensureFfmpegExe(
): Promise<string> {
const target = getFfmpegExePath()
if (await canExecute(target)) {
log?.(`ffmpeg.exe 이미 있음: ${target}`)
log?.(t('log.ffmpegExists', { path: target }))
return target
}
if (installPromise) return installPromise
@@ -46,14 +49,14 @@ export async function ensureFfmpegExe(
await fs.rm(zipPath, { force: true })
await fs.rm(extractDir, { recursive: true, force: true })
log?.(`ffmpeg.exe 다운로드 중: ${FFMPEG_ZIP_URL}`)
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
log?.('ffmpeg zip 압축 해제 중…')
log?.(t('log.ffmpegExtracting'))
await extractZip(zipPath, { dir: extractDir })
const found = await findFile(extractDir, 'ffmpeg.exe')
if (!found) {
throw new Error('zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.')
throw new Error(t('errors.ffmpegNotInZip'))
}
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
try {
@@ -63,14 +66,15 @@ export async function ensureFfmpegExe(
}
const ok = await probeVersion(target)
if (!ok) throw new Error('ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
log?.(`ffmpeg.exe 준비 완료: ${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(
'ffmpeg.exe 자동 설치 실패: ' +
(err instanceof Error ? err.message : String(err))
t('errors.ffmpegInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
// 임시 파일/폴더 정리
@@ -114,7 +118,7 @@ async function findFile(root: string, name: string): Promise<string | null> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http

View File

@@ -4,6 +4,9 @@ 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
@@ -30,7 +33,7 @@ export function ytIdFromUrl(url: string): string {
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const target = new URL(url)
@@ -56,7 +59,7 @@ function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
res.on('end', () => resolve(Buffer.concat(chunks)))
})
req.on('error', reject)
req.on('timeout', () => req.destroy(new Error('요청 시간 초과')))
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
})
}
@@ -91,7 +94,7 @@ export async function normalizeToCover(buffer: Buffer, outPath: string): Promise
const meta = await img.metadata()
const w = meta.width ?? 0
const h = meta.height ?? 0
if (w <= 0 || h <= 0) throw new Error('이미지 크기를 읽지 못함')
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)

View File

@@ -11,6 +11,7 @@ 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'
@@ -19,6 +20,9 @@ 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
@@ -54,9 +58,9 @@ function pickMusicConcurrency(): number {
* - 동시 N개를 모두 t=0 에 시작하면 카드들이 0% 에서 같이 정지된 듯 보임.
* - 시차를 두고 시작하면 "1번 끝남 → 4번 시작 → 2번 끝남 → 5번 시작" 식으로
* 유저 입장에서 항상 뭔가 새로 시작/완료되는 흐름이 보임.
* - 너무 길면 동시성 이득을 깎아먹음. 2.5s 가 체감/속도 균형점.
* - 너무 길면 동시성 이득을 깎아먹음. 2s 가 체감/속도 균형점.
*/
const MUSIC_START_STAGGER_MS = 2500
const MUSIC_START_STAGGER_MS = 2000
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
let musicStartChain: Promise<void> = Promise.resolve()
@@ -94,9 +98,12 @@ function deriveBaseUrl(manifestUrl: string): string {
}
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,
@@ -154,7 +161,7 @@ function fetchBuffer(url: string): Promise<Buffer> {
response.on('end', () => resolve(Buffer.concat(chunks)))
})
request.on('error', reject)
request.on('timeout', () => request.destroy(new Error('요청 시간 초과')))
request.on('timeout', () => request.destroy(new Error(t('common.requestTimeout'))))
})
}
@@ -169,7 +176,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
state.manifestUrl = manifestUrlInput
state.baseUrl = deriveBaseUrl(manifestUrlInput)
}
sendLog(`manifest 다운로드: ${state.manifestUrl}`)
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
const manifest = await fetchJson<Manifest>(state.manifestUrl)
const results: RpFetchedPack[] = []
for (const entry of manifest.packs ?? []) {
@@ -181,7 +188,7 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
const [listRaw, packRaw] = await Promise.all([
fetchJson<Partial<PackList>>(listUrl),
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
sendLog(`팩 정의 로드 실패 (${entry.file}): ${(err as Error).message} — mcVersion 폴백`)
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
return null
})
])
@@ -202,31 +209,37 @@ ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promi
list
})
} catch (error) {
sendLog(`목록 로드 실패 (${entry.file}): ${(error as Error).message}`)
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(`로드된 음악퀴즈: ${results.length}`)
sendLog(t('log.packsLoaded', { count: results.length }))
for (const item of results) {
sendLog(` - ${item.key}: mc=${item.mcVersion || '?'} 베이스=${item.resourcepackPath || '(없음)'}`)
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('선택한 음악퀴즈를 찾을 수 없습니다.')
throw new Error(t('errors.selectedPackNotFound'))
}
state.selectedKey = packKey
sendLog(`선택: ${packKey}`)
sendLog(t('log.selectedPack', { key: packKey }))
})
ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 2단계 설치 ──────────────────────────────────
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
if (!state.selectedKey) throw new Error('음악퀴즈를 먼저 선택해주세요.')
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
const pack = state.packs.get(state.selectedKey)
if (!pack) throw new Error('선택된 음악퀴즈를 찾을 수 없습니다.')
if (!pack) throw new Error(t('errors.currentPackNotFound'))
state.cancelRequested = false
const tempRoot = path.join(getMcCustomDir(), '.temp')
@@ -237,16 +250,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
try {
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
sendLog('yt-dlp 준비 중…')
sendProgress({ phase: 'prep', message: 'yt-dlp 준비 중' })
sendLog(t('log.ytdlpPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
const ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(`yt-dlp 경로: ${ytDlpBin}`)
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
throwIfCancelled()
sendLog('ffmpeg 준비 중…')
sendProgress({ phase: 'prep', message: 'ffmpeg 준비 중' })
sendLog(t('log.ffmpegPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
const ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(`ffmpeg 경로: ${ffmpegBin}`)
sendProgress({ phase: 'prep', message: '준비 완료', done: true })
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
throwIfCancelled()
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
@@ -256,8 +269,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const cpuCount = os.cpus()?.length ?? 0
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
nextMusicStartAt = Date.now()
sendLog(`CPU 코어 ${cpuCount}개 감지 → 동시 다운로드 ${concurrency}`)
sendLog(`음악 다운로드 시작 (${musicTotal}곡, 동시 ${concurrency}개, 시차 ${MUSIC_START_STAGGER_MS}ms)`)
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
@@ -272,7 +285,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
if (state.cancelRequested) return
const entry = musicList[i]
const idx = i + 1
sendLog(`${idx}번 노래 다운로드 시작`)
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
@@ -296,16 +309,16 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
}
})
if (child) state.activeChildren.delete(child)
sendLog(`${idx}번 노래 완료: ${path.basename(outPath)}`)
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: '취소됨' })
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(`${idx}번 노래 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
}
}
}
@@ -319,19 +332,19 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
// 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true })
sendLog(`사진 다운로드 시작 (${imageTotal}장)`)
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(`${idx}번 사진 다운로드 중…`)
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(`${idx}번 사진 다운로드 실패: ${(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' })
@@ -340,9 +353,9 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
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(`${idx}번 사진 정규화 실패: ${(err as Error).message}`)
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
}
sendLog(`${idx}번 사진 완료: ${path.basename(outPath)}`)
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
}
@@ -354,18 +367,18 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
baseZipPath = path.join(tempRoot, 'base.zip')
sendLog(`베이스 리소스팩 다운로드: ${cleaned}`)
sendLog(` URL: ${baseUrl}`)
sendProgress({ phase: 'package', message: '베이스 리소스팩 다운로드 중' })
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(`베이스 리소스팩 받음 (${(buf.length / 1024).toFixed(1)} KB)`)
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
} catch (err) {
throw new Error(`베이스 리소스팩 다운로드 실패: ${(err as Error).message}`)
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
}
} else {
sendLog('베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성')
sendLog(t('log.baseAbsent'))
}
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
@@ -373,8 +386,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
const resourcepackName = `${state.selectedKey}_musicquiz.zip`
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
sendLog(`리소스팩 zip 빌드 중… (${resourcepackName})`)
sendProgress({ phase: 'package', message: baseZipPath ? '베이스에 음악·사진 추가 중' : 'zip 빌드 중' })
sendLog(t('log.buildingZip', { name: resourcepackName }))
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
await buildResourcepackZip({
musicDir,
paintingDir,
@@ -387,8 +400,8 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
})
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
sendLog(`설치 완료: ${resourcepackPath}`)
sendProgress({ phase: 'package', message: '설치 완료', done: true })
sendLog(t('log.installComplete', { path: resourcepackPath }))
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
return { resourcepackPath }
} finally {
// 임시 파일 정리
@@ -398,7 +411,7 @@ ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string
ipcMain.handle('rp:install:cancel', async () => {
state.cancelRequested = true
sendLog(`취소 요청됨. 실행 중 프로세스 ${state.activeChildren.size}개 중단…`)
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
for (const child of state.activeChildren) {
if (!child.killed) child.kill()
}
@@ -406,7 +419,7 @@ ipcMain.handle('rp:install:cancel', async () => {
function throwIfCancelled(): void {
if (state.cancelRequested) {
throw new Error('사용자가 설치를 취소했습니다.')
throw new Error(t('errors.cancelledByUser'))
}
}

View File

@@ -1,6 +1,9 @@
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
@@ -58,7 +61,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
for (const raw of lines) {
const line = raw.trimEnd()
if (!line) continue
opts.log?.(`yt-dlp> ${line}`)
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])))
@@ -76,11 +79,16 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
child.on('error', (err) => reject(err))
child.on('close', async (code, signal) => {
if (signal) {
reject(new Error(`yt-dlp 가 신호 ${signal} 로 종료됨`))
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
return
}
if (code !== 0) {
reject(new Error(`yt-dlp 종료 코드 ${code}: ${stderr.trim() || '(stderr 없음)'}`))
reject(new Error(
t('errors.ytdlpExit', {
code: code ?? '',
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
})
))
return
}
// .ogg 가 실제로 생성됐는지 확인
@@ -88,7 +96,7 @@ export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string>
await fs.access(outPath)
resolve(outPath)
} catch {
reject(new Error(`예상 출력파일이 없음: ${outPath}`))
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
}
})
})

View File

@@ -2,7 +2,10 @@ import { promises as fs, createWriteStream } from 'node:fs'
import path from 'node:path'
import archiver from 'archiver'
import extract from 'extract-zip'
import { resolveResourcePackFormat } from './packFormat.js'
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'
@@ -45,7 +48,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
if (opts.baseZipPath) {
opts.log?.(`베이스 리소스팩 압축 해제: ${path.basename(opts.baseZipPath)}`)
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
await extract(opts.baseZipPath, { dir: root })
}
@@ -57,18 +60,52 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
const resolved = resolveResourcePackFormat(opts.mcVersion)
if (resolved.matched) {
opts.log?.(`pack_format = ${resolved.format} (mcVersion ${resolved.matched})`)
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
} else {
opts.log?.(`pack_format = ${resolved.format} (mcVersion "${opts.mcVersion}" 매칭 실패, 최신 폴백)`)
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
}
const mcmeta = {
pack: {
description: `음악퀴즈 리소스팩 - ${opts.packName}`,
// 호환 범위는 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: resolved.format, max_inclusive: resolved.format }
}
supported_formats: { min_inclusive: minFmt, max_inclusive: maxFmt },
min_format: minFmt,
max_format: maxFmt
}
const mcmeta = { pack: packMeta }
await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n')
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
// 2) 음악 파일 복사 + sounds.json 생성/병합
const musicFiles = (await fs.readdir(opts.musicDir))
@@ -82,7 +119,7 @@ export async function buildResourcepackZip(opts: BuildResourcepackOptions): Prom
const parsed = JSON.parse(existing)
if (parsed && typeof parsed === 'object') {
soundsJson = parsed as Record<string, unknown>
opts.log?.(`기존 sounds.json 병합 (${Object.keys(soundsJson).length}개 항목)`)
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
}
} catch {
// 없으면 새로 생성.

View File

@@ -24,6 +24,12 @@ const TABLE: Array<readonly [string, number]> = [
/** 테이블에서 마지막(=최신) 항목의 포맷. 알 수 없는 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

View File

@@ -2,6 +2,9 @@ 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),

View File

@@ -4,6 +4,9 @@ import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
@@ -27,7 +30,7 @@ export async function ensureYtDlpExe(
): Promise<string> {
const target = getYtDlpExePath()
if (await canExecute(target)) {
log?.(`yt-dlp.exe 이미 있음: ${target}`)
log?.(t('log.ytdlpExists', { path: target }))
return target
}
if (installPromise) return installPromise
@@ -35,20 +38,21 @@ export async function ensureYtDlpExe(
installPromise = (async () => {
try {
await fs.mkdir(path.dirname(target), { recursive: true })
log?.(`yt-dlp.exe 다운로드 중: ${YT_DLP_DOWNLOAD_URL}`)
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('yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.')
throw new Error(t('errors.ytdlpVerifyFailed'))
}
log?.(`yt-dlp.exe 준비 완료: ${target}`)
log?.(t('log.ytdlpReady', { path: target }))
return target
} catch (err) {
// 부분 다운로드 흔적 정리
try { await fs.unlink(target) } catch { /* noop */ }
throw new Error(
'yt-dlp.exe 자동 설치 실패: ' +
(err instanceof Error ? err.message : String(err))
t('errors.ytdlpInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
installPromise = null
@@ -80,7 +84,7 @@ function probeVersion(bin: string): Promise<boolean> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,9 @@ import { contextBridge, ipcRenderer } from 'electron'
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),

View File

@@ -25,6 +25,8 @@ export interface ServerInstallPayload {
export interface ClientInstallPayload {
packKey: string
installPlatform: boolean
/** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */
skipMap?: boolean
}
export interface RamCheckResult {

View File

@@ -4,6 +4,7 @@ import path from 'node:path'
import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js'
import { opRouter } from './routes/op.js'
@@ -23,6 +24,14 @@ app.set('trust proxy', 1)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
app.use((_req, res, next) => {
res.locals.t = t
res.locals.localeDict = localeDict
next()
})
app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
resave: false,
@@ -104,8 +113,8 @@ app.use('/', opRouter)
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err)
const message = err instanceof Error ? err.message : '알 수 없는 오류'
res.status(500).send(`서버 오류: ${message}`)
const message = err instanceof Error ? err.message : t('errors.unknown')
res.status(500).send(t('errors.serverError', { message }))
})
app.listen(PORT, HOST, () => {

45
src/server/datapack.ts Normal file
View 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
View 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

View File

@@ -1,4 +1,5 @@
import { Router } from 'express'
import archiver from 'archiver'
import {
createPack,
deletePackKeys,
@@ -16,6 +17,8 @@ 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()
@@ -46,7 +49,7 @@ opRouter.post('/op', async (req, res, next) => {
const accounts = await readAccounts()
const matched = accounts.find((entry) => entry.password === password)
if (!matched) {
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
res.status(401).render('op/login', { error: t('login.wrongPassword') })
return
}
req.session.userId = matched.id
@@ -106,7 +109,7 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
res.status(404).send(t('errors.packNotFound'))
return
}
const releases = await fetchReleaseVersions()
@@ -142,7 +145,7 @@ opRouter.get('/op/list/: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 list = await loadPackList(packKey)
@@ -163,7 +166,7 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).json({ ok: false, message: '음악퀴즈를 찾을 수 없습니다.' })
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
return
}
const normalized = normalizePackList(req.body)
@@ -179,13 +182,13 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
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: '영상 주소를 입력해 주세요.' })
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: '메타데이터를 찾을 수 없습니다.' })
res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
return
}
res.json({ ok: true, entry })
@@ -203,7 +206,7 @@ opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) =>
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: '플레이리스트 주소를 입력해 주세요.' })
res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') })
return
}
try {
@@ -222,37 +225,71 @@ opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
opRouter.get('/op/datapack', requireAuth, async (req, res, next) => {
try {
const keys = await listPackKeys()
const items = await Promise.all(keys.map(async (key) => ({
key,
definition: await loadPackDefinition(key)
})))
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)
}
})
// 데이터팩 출력: 임시 포맷의 mcfunction 텍스트를 반환.
// 데이터팩 출력: 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('음악퀴즈를 찾을 수 없습니다.')
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
return
}
const list = await loadPackList(packKey)
const lines: string[] = []
lines.push(`# === musicquiz: ${definition.name} ===`)
lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}`)
lines.push(`say [musicquiz] 데이터팩 초기화`)
lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`)
list.music.forEach((entry, index) => {
const title = entry.title || '(제목 없음)'
const artist = entry.artist || '(가수 미상)'
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
})
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
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)
}
@@ -287,7 +324,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const normalized = normalizePackDefinition(partial)
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
res.status(400).send(t('errors.ramOrderInvalid'))
return
}
const finalKey = await renamePack(packKey, requestedKey, normalized)

View File

@@ -4,6 +4,7 @@ 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
@@ -15,7 +16,7 @@ export interface YtPlaylistEntry {
export class YtDlpUnavailableError extends Error {
constructor(message?: string) {
super(message || 'yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)')
super(message || t('youtube.ytdlpUnavailable'))
}
}
@@ -62,7 +63,7 @@ export async function ensureYtDlp(): Promise<string> {
// 검증
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new YtDlpUnavailableError('yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.')
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
}
return target
} catch (err) {
@@ -71,7 +72,7 @@ export async function ensureYtDlp(): Promise<string> {
throw err instanceof YtDlpUnavailableError
? err
: new YtDlpUnavailableError(
'yt-dlp 자동 설치에 실패했습니다: ' + (err instanceof Error ? err.message : String(err))
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
)
} finally {
installPromise = null
@@ -112,7 +113,7 @@ function probeVersion(bin: string): Promise<boolean> {
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error('redirect 가 너무 많습니다.'))
reject(new Error(t('youtube.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http
@@ -161,7 +162,7 @@ export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | nul
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`yt-dlp 영상 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
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)
@@ -208,7 +209,7 @@ export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`yt-dlp 플레이리스트 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
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)

View File

@@ -4,30 +4,42 @@ import dotenv from 'dotenv'
import { projectRoot } from './paths.js'
/**
* `.env` 를 읽어 `process.env` 에 주입.
* `.env` / `.env.build` 를 읽어 `process.env` 에 주입.
*
* 탐색 순서(처음 발견된 것만 사용):
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/.env`
* — electron-builder 의 extraResources 로 빌드 시점 `.env` 가 함께 배포됨.
* 2. `<프로젝트 루트>/.env`
* — 개발 실행(npm start / npm run installer*) 및 서버 운영용.
* 여러 파일을 순서대로 읽되 `override:false` 로 병합하므로 **먼저 로드된 값이
* 우선**. 두 도메인(패키지 빌드용 vs 개발/서버용) 이 한 함수에서 자연스럽게
* 분리됨:
*
* - 이미 설정된 환경변수는 덮어쓰지 않음(쉘/systemd 에서 넘긴 값이 우선).
* - 파일이 없으면 조용히 통과.
* 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 })
return
}
}
}

93
src/shared/i18n.ts Normal file
View 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] ?? '')
}

View File

@@ -8,6 +8,7 @@ export const manifestDirPath = path.join(projectRoot, 'manifest')
export const accountFilePath = path.join(projectRoot, 'account.json')
export const fileDirPath = path.join(projectRoot, 'file')
export const fileListDirPath = path.join(fileDirPath, 'list')
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
export const viewsDirPath = path.join(projectRoot, 'views')
export const publicDirPath = path.join(projectRoot, 'public')

View File

@@ -81,8 +81,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
: fallback.mcVersion,
platform: {
type: platformType,
// fabric 은 downloadUrl 을 쓰지 않고 loaderVersion 기반으로 자동 설치한다.
downloadUrl: platformType !== 'fabric'
// vanilla 외에는 fabric/forge/neoforge 모두 downloadUrl 을 보관한다.
downloadUrl: platformType !== 'vanilla'
&& typeof platform.downloadUrl === 'string'
&& platform.downloadUrl.trim().length > 0
? platform.downloadUrl.trim()
@@ -229,6 +229,21 @@ function sanitizeNumber(value: unknown): number {
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
@@ -244,7 +259,8 @@ export function normalizePackList(input: unknown): PackList {
url: sanitizeStr(entry.url),
title: sanitizeStr(entry.title),
artist: sanitizeStr(entry.artist),
durationSec: sanitizeNumber(entry.durationSec)
durationSec: sanitizeNumber(entry.durationSec),
aliases: sanitizeAliases(entry.aliases)
}))
.filter((entry) => entry.url.length > 0),
images: images

View File

@@ -47,6 +47,8 @@ export interface MusicListEntry {
artist: string
/** 노래 길이 (초). */
durationSec: number
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
aliases: string[]
}
export interface ImageListEntry {

View File

@@ -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>

View File

@@ -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,36 +11,36 @@
<main class="pageWrap">
<section class="dashboardHeader">
<h1>음악퀴즈 목록</h1>
<h1><%= t('dashboard.title') %></h1>
<div class="dashboardActions">
<a class="secondaryButton" href="/op/list">음악목록 수정</a>
<a class="secondaryButton" href="/op/datapack">데이터팩 수정</a>
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
<form method="post" action="/op/dashboard/create" class="inlineForm">
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
<button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button>
</form>
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button>
<button type="button" class="secondaryButton" id="deleteToggle"><%= t('dashboard.deletePack') %></button>
</div>
</section>
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p>
<p class="muted"><%= t('dashboard.emptyHint') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard editableCard" data-key="<%= item.key %>">
<label class="cardCheckbox" hidden>
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
<span>선택</span>
<span><%= t('dashboard.select') %></span>
</label>
<a class="cardLink" href="/op/dashboard/<%= item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a>
@@ -48,8 +48,8 @@
<% }) %>
</section>
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
<button type="submit" class="dangerButton">삭제 확인</button>
<button type="button" class="secondaryButton" id="deleteCancel"><%= t('common.cancel') %></button>
<button type="submit" class="dangerButton"><%= t('dashboard.confirmDelete') %></button>
</div>
</form>
</main>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>데이터팩 수정</title>
<title><%= t('datapack.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,21 +12,26 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
<h1 style="margin-top:20px;">데이터팩 수정</h1>
<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">음악퀴즈 선택</button>
<span class="muted" id="pickedLabel">선택된 음악퀴즈 없음</span>
<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="exportBtn">데이터팩 출력</button>
<button type="button" class="secondaryButton" id="copyBtn">복사</button>
<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>
@@ -36,19 +41,22 @@
<!-- 음악퀴즈 선택 팝업 -->
<div class="modalOverlay" id="pickModal" hidden>
<div class="modalCard">
<header><h3>음악퀴즈 선택</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<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 %>">
<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>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
</ul>
<% } %>
</article>
@@ -58,6 +66,9 @@
</div>
</div>
<script>
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
</script>
<script>
(function () {
var pickModal = document.getElementById('pickModal')
@@ -71,47 +82,67 @@
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')
document.getElementById('pickedLabel').textContent = '선택: ' + 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
// 곡 수 미리 가져오기
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
document.getElementById('countLabel').textContent = ''
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 = '출력 중…'; s.classList.remove('error')
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 = '실패: ' + res.text; s.classList.add('error')
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
// 첫줄/둘째줄에서 카운트 가져와 표기
var m = res.text.match(/총\s+(\d+)곡/)
if (m) document.getElementById('countLabel').textContent = '총 ' + m[1] + '개의 음악을 찾았습니다.'
s.textContent = '출력 완료'
s.textContent = I18N.exported
})
.catch(function (err) { s.textContent = '실패: ' + err.message; s.classList.add('error') })
.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 = '복사됨'
s.textContent = I18N.copied
s.classList.remove('error')
})
})

View File

@@ -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,70 +40,83 @@
</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/&lt;파일명&gt;</code>으로 해석됩니다.</small>
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
</label>
<label class="fullSpan" id="platformLoaderField" hidden>
<span>Fabric Loader 버전</span>
<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="">불러오는 중...</option>
<option value=""><%= t('common.loading') %></option>
</select>
<small class="muted">선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.</small>
<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/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
<small class="muted"><%- t('editor.modsFolderHint') %></small>
</label>
<label>
<span>리소스팩 (.zip)</span>
<span><%= t('editor.resourcepackPath') %></span>
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small>
<small class="muted"><%= t('editor.resourcepackHint') %></small>
</label>
</div>
<button class="primaryButton" type="submit">저장</button>
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
</form>
</main>
<script>
var I18N = {
ramOrderInvalid: <%- JSON.stringify(t('editor.ramOrderInvalid')) %>,
fabricLoaderRequired: <%- JSON.stringify(t('editor.fabricLoaderRequired')) %>,
loaderEmpty: <%- JSON.stringify(t('editor.platformLoaderEmpty')) %>,
loaderPickMc: <%- JSON.stringify(t('editor.platformLoaderPickMc')) %>,
loaderLoadFailedPrefix: <%- JSON.stringify(t('editor.platformLoaderLoadFailed', { message: '__M__' })) %>,
loading: <%- JSON.stringify(t('common.loading')) %>
}
function formatLoaderLoadFailed(message) {
return I18N.loaderLoadFailedPrefix.replace('__M__', message)
}
</script>
<script>
(function () {
var platformSelect = document.getElementById('platformType')
@@ -118,9 +131,8 @@
function syncPlatformVisibility() {
var type = platformSelect.value
if (type === 'fabric') {
downloadField.removeAttribute('hidden')
loaderField.removeAttribute('hidden')
downloadField.setAttribute('hidden', '')
downloadField.querySelector('input').value = ''
loadFabricLoaders()
} else if (type === 'vanilla') {
downloadField.setAttribute('hidden', '')
@@ -136,7 +148,7 @@
function populateLoaderOptions(versions, preselect) {
if (!versions || versions.length === 0) {
loaderSelect.innerHTML = '<option value="">호환 로더 없음</option>'
loaderSelect.innerHTML = '<option value="">' + I18N.loaderEmpty + '</option>'
return
}
var html = ''
@@ -156,7 +168,7 @@
function loadFabricLoaders() {
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
if (!mc) {
loaderSelect.innerHTML = '<option value="">마인크래프트 버전을 먼저 선택하세요</option>'
loaderSelect.innerHTML = '<option value="">' + I18N.loaderPickMc + '</option>'
return
}
if (loaderCache[mc]) {
@@ -164,7 +176,7 @@
return
}
var seq = ++loaderFetchSeq
loaderSelect.innerHTML = '<option value="">불러오는 중...</option>'
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)
@@ -181,7 +193,8 @@
})
.catch(function (err) {
if (seq !== loaderFetchSeq) return
loaderSelect.innerHTML = '<option value="">로더 목록 로드 실패: ' + (err && err.message ? err.message : err) + '</option>'
var msg = (err && err.message) ? err.message : String(err)
loaderSelect.innerHTML = '<option value="">' + formatLoaderLoadFailed(msg) + '</option>'
})
}
@@ -197,12 +210,12 @@
var clientReco = Number(form.clientRecommendedRam.value)
if (clientMin > clientReco) {
event.preventDefault()
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
alert(I18N.ramOrderInvalid)
return
}
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
event.preventDefault()
alert('Fabric 로더 버전을 선택해 주세요.')
alert(I18N.fabricLoaderRequired)
}
})
})()

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악목록 수정</title>
<title><%= t('list.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,14 +12,14 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
<h1 style="margin-top:20px;">음악목록 수정</h1>
<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">등록된 음악퀴즈가 없습니다.</p>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard">
@@ -28,9 +28,9 @@
<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>

View File

@@ -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('listEditor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,31 +12,31 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/list">← 돌아가기</a>
<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="저장되지 않은 변경사항이 있습니다">*</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">음악목록</button>
<button type="button" class="tabBtn" data-tab="image">사진목록</button>
<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">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="music">목록 초기화</button>
<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="유튜브 플레이리스트 URL"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.musicPlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="music">플레이리스트 불러오기</button>
<button type="button" class="secondaryButton" data-action="fetch" data-target="music"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<ol class="trackList" id="music-list"></ol>
@@ -45,17 +45,17 @@
<!-- 사진 탭 -->
<section class="tabPanel" id="tab-image" hidden>
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
<button type="button" class="secondaryButton" id="image-from-music">음악목록에서 가져오기</button>
<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="유튜브 플레이리스트 URL"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.imagePlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="image">플레이리스트 불러오기</button>
<button type="button" class="secondaryButton" data-action="fetch" data-target="image"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<div class="imageGrid" id="image-list"></div>
@@ -64,22 +64,22 @@
<!-- Context menu -->
<div class="ctxMenu" id="ctxMenu" hidden>
<button type="button" data-ctx="edit">수정</button>
<button type="button" data-ctx="delete">삭제</button>
<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">확인</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<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>취소</button>
<button type="button" class="primaryButton" id="confirm-ok">확인</button>
<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>
@@ -87,43 +87,61 @@
<!-- Edit modal (music) -->
<div class="modalOverlay" id="editMusicModal" hidden>
<div class="modalCard">
<header><h3>음악 항목 수정</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<header><h3><%= t('listEditor.musicEditTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<label>유튜브 영상 주소
<label><%= t('listEditor.musicEditUrl') %>
<input type="url" id="edit-music-url" class="textInput" />
</label>
<p class="muted" style="margin-top:6px;font-size:12px;">
저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.
<%= 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>취소</button>
<button type="button" class="primaryButton" id="edit-music-save">저장</button>
<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>사진 항목 수정</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<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">유튜브 주소</button>
<button type="button" class="segBtn" data-seg="img">이미지 주소</button>
<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>주소
<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>취소</button>
<button type="button" class="primaryButton" id="edit-image-save">저장</button>
<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>
@@ -131,6 +149,8 @@
<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>

View File

@@ -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>

View File

@@ -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>