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() 헬퍼 사용
This commit is contained in:
166
locales/server/ko-kr.json
Normal file
166
locales/server/ko-kr.json
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"back": "← 돌아가기",
|
||||||
|
"backToList": "목록으로",
|
||||||
|
"save": "저장",
|
||||||
|
"cancel": "취소",
|
||||||
|
"ok": "확인",
|
||||||
|
"delete": "삭제",
|
||||||
|
"edit": "수정",
|
||||||
|
"close": "×",
|
||||||
|
"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": "더블클릭해서 수정",
|
||||||
|
"metaLoading": "메타데이터 가져오는 중…",
|
||||||
|
"metaFailedShort": "메타 조회 실패",
|
||||||
|
"metaFailedTitle": "메타데이터 조회 실패",
|
||||||
|
"metaFailedAsk": "{{message}}\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?",
|
||||||
|
"saving": "저장 중…",
|
||||||
|
"saved": "저장 완료",
|
||||||
|
"saveFailed": "저장 실패: {{message}}",
|
||||||
|
"fetchEnterUrl": "플레이리스트 주소를 입력해 주세요.",
|
||||||
|
"fetchTitle": "플레이리스트 불러오기",
|
||||||
|
"fetchConfirm": "현재 {{type}}목록 순서가 모두 사라집니다. 진행할까요?",
|
||||||
|
"fetchTypeMusic": "음악",
|
||||||
|
"fetchTypeImage": "사진",
|
||||||
|
"fetchLoading": "불러오는 중…",
|
||||||
|
"fetchedCount": "{{count}}개 항목을 불러왔습니다.",
|
||||||
|
"failed": "실패: {{message}}",
|
||||||
|
"clearTitle": "목록 초기화",
|
||||||
|
"clearConfirm": "\"{{type}}목록\"을 비웁니다. 진행할까요?",
|
||||||
|
"imageFromMusicEmpty": "음악목록이 비어 있어 가져올 수 없습니다.",
|
||||||
|
"imageFromMusicTitle": "사진목록 가져오기",
|
||||||
|
"imageFromMusicConfirm": "저장된 음악목록의 영상 {{count}}개를 그대로 사진목록으로 가져옵니다.\n현재 사진목록은 모두 사라집니다. 진행할까요?",
|
||||||
|
"leaveTitle": "저장되지 않은 변경사항",
|
||||||
|
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"browserTitle": "{{name}} 편집",
|
||||||
|
"eyebrow": "PACK EDITOR",
|
||||||
|
"displayName": "음악퀴즈 이름",
|
||||||
|
"fileName": "JSON 파일 이름 (확장자 제외)",
|
||||||
|
"mcVersion": "마인크래프트 버전",
|
||||||
|
"platformType": "모드 플랫폼",
|
||||||
|
"platformDownloadUrl": "플랫폼 설치파일 URL",
|
||||||
|
"platformDownloadHint": "도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/<파일명></code>으로 해석됩니다.",
|
||||||
|
"platformLoaderVersion": "Fabric Loader 버전",
|
||||||
|
"platformLoaderHint": "선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.",
|
||||||
|
"platformLoaderEmpty": "호환 로더 없음",
|
||||||
|
"platformLoaderPickMc": "마인크래프트 버전을 먼저 선택하세요",
|
||||||
|
"platformLoaderLoadFailed": "로더 목록 로드 실패: {{message}}",
|
||||||
|
"serverMinRam": "서버 최소 램 (MB)",
|
||||||
|
"serverMaxRam": "서버 최대 램 (MB)",
|
||||||
|
"clientMinRam": "클라이언트 최소 램 (MB)",
|
||||||
|
"clientRecommendedRam": "클라이언트 권장 램 (MB)",
|
||||||
|
"mapPath": "맵 파일 (.zip)",
|
||||||
|
"mapPathHint": "/file/maps/ 아래 zip 파일 이름.",
|
||||||
|
"serverPath": "서버 파일 (.zip)",
|
||||||
|
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
|
||||||
|
"modsFolder": "모드 폴더 이름",
|
||||||
|
"modsFolderHint": "/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
|
||||||
|
"resourcepackPath": "리소스팩 (.zip)",
|
||||||
|
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.",
|
||||||
|
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
|
||||||
|
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
|
||||||
|
},
|
||||||
|
"datapack": {
|
||||||
|
"browserTitle": "데이터팩 수정",
|
||||||
|
"title": "데이터팩 수정",
|
||||||
|
"pickPack": "음악퀴즈 선택",
|
||||||
|
"pickedNone": "선택된 음악퀴즈 없음",
|
||||||
|
"pickedLabel": "선택: {{name}}",
|
||||||
|
"totalCount": "총 {{count}}개의 음악을 찾았습니다.",
|
||||||
|
"export": "데이터팩 출력",
|
||||||
|
"copy": "복사",
|
||||||
|
"copied": "복사됨",
|
||||||
|
"exporting": "출력 중…",
|
||||||
|
"exported": "출력 완료",
|
||||||
|
"failed": "실패: {{message}}",
|
||||||
|
"modalPickTitle": "음악퀴즈 선택"
|
||||||
|
},
|
||||||
|
"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 가 너무 많습니다."
|
||||||
|
},
|
||||||
|
"datapackOutput": {
|
||||||
|
"header": "# === musicquiz: {{name}} ===",
|
||||||
|
"summary": "# 총 {{musicCount}}곡 / 사진 {{imageCount}}장",
|
||||||
|
"initLine": "say [musicquiz] 데이터팩 초기화",
|
||||||
|
"placeholder": "# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.",
|
||||||
|
"trackLine": "# {{index}}. {{title}} - {{artist}} ({{duration}}s)",
|
||||||
|
"titleFallback": "(제목 없음)",
|
||||||
|
"artistFallback": "(가수 미상)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,22 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict'
|
'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 = {
|
var state = {
|
||||||
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
|
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
|
||||||
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
|
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
|
||||||
@@ -87,10 +103,10 @@
|
|||||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||||
'<div class="rowMeta">' +
|
'<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 || '') +
|
escapeHtml(entry.title || '') +
|
||||||
'</div>' +
|
'</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 || '') +
|
escapeHtml(entry.artist || '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -116,7 +132,7 @@
|
|||||||
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="cardCaption">' +
|
'<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 class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
|
||||||
'</div>'
|
'</div>'
|
||||||
attachDraggable(card, 'image', idx)
|
attachDraggable(card, 'image', idx)
|
||||||
@@ -330,7 +346,7 @@
|
|||||||
if (!url) return
|
if (!url) return
|
||||||
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
|
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
|
||||||
if (url === prev.url) { closeAllModals(); return }
|
if (url === prev.url) { closeAllModals(); return }
|
||||||
setStatus('edit-music-status', '메타데이터 가져오는 중…')
|
setStatus('edit-music-status', tt('metaLoading'))
|
||||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
|
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -339,8 +355,8 @@
|
|||||||
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
|
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
|
||||||
}).then(function (result) {
|
}).then(function (result) {
|
||||||
if (!result.ok || !result.body || !result.body.ok) {
|
if (!result.ok || !result.body || !result.body.ok) {
|
||||||
var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
|
var msg = (result.body && result.body.message) ? result.body.message : tt('metaFailedShort')
|
||||||
ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
|
ask(tt('metaFailedTitle'), tt('metaFailedAsk', { message: msg }), function () {
|
||||||
state.music[editingIdx].url = url
|
state.music[editingIdx].url = url
|
||||||
markDirty()
|
markDirty()
|
||||||
closeAllModals()
|
closeAllModals()
|
||||||
@@ -360,7 +376,7 @@
|
|||||||
closeAllModals()
|
closeAllModals()
|
||||||
renderMusic()
|
renderMusic()
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus('edit-music-status', '실패: ' + err.message, true)
|
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -389,17 +405,16 @@
|
|||||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||||
document.getElementById('image-from-music').addEventListener('click', function () {
|
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||||
if (state.music.length === 0) {
|
if (state.music.length === 0) {
|
||||||
setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true)
|
setStatus('status-image', tt('imageFromMusicEmpty'), true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ask('사진목록 가져오기',
|
ask(tt('imageFromMusicTitle'),
|
||||||
'저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n'
|
tt('imageFromMusicConfirm', { count: state.music.length }),
|
||||||
+ '현재 사진목록은 모두 사라집니다. 진행할까요?',
|
|
||||||
function () {
|
function () {
|
||||||
state.images = state.music.map(function (m) { return { url: m.url } })
|
state.images = state.music.map(function (m) { return { url: m.url } })
|
||||||
markDirty()
|
markDirty()
|
||||||
renderImage()
|
renderImage()
|
||||||
setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
|
setStatus('status-image', tt('fetchedCount', { count: state.images.length }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -431,7 +446,8 @@
|
|||||||
var action = btn.getAttribute('data-action')
|
var action = btn.getAttribute('data-action')
|
||||||
var target = btn.getAttribute('data-target')
|
var target = btn.getAttribute('data-target')
|
||||||
if (action === 'clear') {
|
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() }
|
if (target === 'music') { state.music = []; renderMusic() }
|
||||||
else { state.images = []; renderImage() }
|
else { state.images = []; renderImage() }
|
||||||
markDirty()
|
markDirty()
|
||||||
@@ -457,7 +473,7 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
var statusId = 'status-' + target
|
var statusId = 'status-' + target
|
||||||
setStatus(statusId, '저장 중…')
|
setStatus(statusId, tt('saving'))
|
||||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
|
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -465,10 +481,10 @@
|
|||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||||
}).then(function (result) {
|
}).then(function (result) {
|
||||||
if (result.ok && result.body.ok) { setStatus(statusId, '저장 완료'); markClean() }
|
if (result.ok && result.body.ok) { setStatus(statusId, tt('saved')); markClean() }
|
||||||
else setStatus(statusId, '저장 실패: ' + (result.body.message || ''), true)
|
else setStatus(statusId, tt('saveFailed', { message: result.body.message || '' }), true)
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus(statusId, '저장 실패: ' + err.message, true)
|
setStatus(statusId, tt('saveFailed', { message: err.message }), true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,11 +492,12 @@
|
|||||||
var input = document.getElementById(target + '-playlist-url')
|
var input = document.getElementById(target + '-playlist-url')
|
||||||
var url = input.value.trim()
|
var url = input.value.trim()
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setStatus('status-' + target, '플레이리스트 주소를 입력해 주세요.', true)
|
setStatus('status-' + target, tt('fetchEnterUrl'), true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ask('플레이리스트 불러오기', '현재 ' + (target === 'music' ? '음악' : '사진') + '목록 순서가 모두 사라집니다. 진행할까요?', function () {
|
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
|
||||||
setStatus('status-' + target, '불러오는 중…')
|
ask(tt('fetchTitle'), tt('fetchConfirm', { type: typeLabel }), function () {
|
||||||
|
setStatus('status-' + target, tt('fetchLoading'))
|
||||||
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
|
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -489,7 +506,7 @@
|
|||||||
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
return r.json().then(function (body) { return { ok: r.ok, body: body } })
|
||||||
}).then(function (result) {
|
}).then(function (result) {
|
||||||
if (!result.ok || !result.body.ok) {
|
if (!result.ok || !result.body.ok) {
|
||||||
setStatus('status-' + target, '실패: ' + (result.body.message || ''), true)
|
setStatus('status-' + target, tt('failed', { message: result.body.message || '' }), true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var entries = result.body.entries || []
|
var entries = result.body.entries || []
|
||||||
@@ -503,9 +520,9 @@
|
|||||||
renderImage()
|
renderImage()
|
||||||
}
|
}
|
||||||
markDirty()
|
markDirty()
|
||||||
setStatus('status-' + target, entries.length + '개 항목을 불러왔습니다.')
|
setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setStatus('status-' + target, '실패: ' + err.message, true)
|
setStatus('status-' + target, tt('failed', { message: err.message }), true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -527,12 +544,10 @@
|
|||||||
if (!dirty) return
|
if (!dirty) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
var href = a.getAttribute('href')
|
var href = a.getAttribute('href')
|
||||||
ask('저장되지 않은 변경사항',
|
ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
|
||||||
'저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?',
|
markClean()
|
||||||
function () {
|
window.location.href = href
|
||||||
markClean()
|
})
|
||||||
window.location.href = href
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그
|
// 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path'
|
|||||||
import fsp from 'node:fs/promises'
|
import fsp from 'node:fs/promises'
|
||||||
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
|
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
|
||||||
import { loadEnv } from '../shared/env.js'
|
import { loadEnv } from '../shared/env.js'
|
||||||
|
import { t, localeDict } from './i18n.js'
|
||||||
import { indexRouter } from './routes/index.js'
|
import { indexRouter } from './routes/index.js'
|
||||||
import { opRouter } from './routes/op.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.urlencoded({ extended: true }))
|
||||||
app.use(express.json())
|
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({
|
app.use(session({
|
||||||
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
|
||||||
resave: false,
|
resave: false,
|
||||||
@@ -104,8 +113,8 @@ app.use('/', opRouter)
|
|||||||
|
|
||||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
const message = err instanceof Error ? err.message : t('errors.unknown')
|
||||||
res.status(500).send(`서버 오류: ${message}`)
|
res.status(500).send(t('errors.serverError', { message }))
|
||||||
})
|
})
|
||||||
|
|
||||||
app.listen(PORT, HOST, () => {
|
app.listen(PORT, HOST, () => {
|
||||||
|
|||||||
6
src/server/i18n.ts
Normal file
6
src/server/i18n.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { loadComponentI18n } from '../shared/i18n.js'
|
||||||
|
|
||||||
|
// 서버 진입 시 한 번 로드. routes/views 어디서든 동일한 사전을 공유.
|
||||||
|
const i18n = loadComponentI18n('server')
|
||||||
|
export const t = i18n.t
|
||||||
|
export const localeDict = i18n.dict
|
||||||
@@ -16,6 +16,7 @@ import { fetchReleaseVersions } from '../../shared/mojang.js'
|
|||||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||||
import { requireAuth } from '../middleware/auth.js'
|
import { requireAuth } from '../middleware/auth.js'
|
||||||
import type { PackDefinition, PackList } from '../../shared/types.js'
|
import type { PackDefinition, PackList } from '../../shared/types.js'
|
||||||
|
import { t } from '../i18n.js'
|
||||||
|
|
||||||
export const opRouter = Router()
|
export const opRouter = Router()
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ opRouter.post('/op', async (req, res, next) => {
|
|||||||
const accounts = await readAccounts()
|
const accounts = await readAccounts()
|
||||||
const matched = accounts.find((entry) => entry.password === password)
|
const matched = accounts.find((entry) => entry.password === password)
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
|
res.status(401).render('op/login', { error: t('login.wrongPassword') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
req.session.userId = matched.id
|
req.session.userId = matched.id
|
||||||
@@ -106,7 +107,7 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
|
|||||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
const definition = await loadPackDefinition(packKey)
|
const definition = await loadPackDefinition(packKey)
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
|
res.status(404).send(t('errors.packNotFound'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const releases = await fetchReleaseVersions()
|
const releases = await fetchReleaseVersions()
|
||||||
@@ -142,7 +143,7 @@ opRouter.get('/op/list/:packName', requireAuth, async (req, res, next) => {
|
|||||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
const definition = await loadPackDefinition(packKey)
|
const definition = await loadPackDefinition(packKey)
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
|
res.status(404).send(t('errors.packNotFound'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = await loadPackList(packKey)
|
const list = await loadPackList(packKey)
|
||||||
@@ -163,7 +164,7 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
|
|||||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
const definition = await loadPackDefinition(packKey)
|
const definition = await loadPackDefinition(packKey)
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
res.status(404).json({ ok: false, message: '음악퀴즈를 찾을 수 없습니다.' })
|
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const normalized = normalizePackList(req.body)
|
const normalized = normalizePackList(req.body)
|
||||||
@@ -179,13 +180,13 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
|
|||||||
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
|
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
|
||||||
const url = pickFirstValue(req.body?.url).trim()
|
const url = pickFirstValue(req.body?.url).trim()
|
||||||
if (!url) {
|
if (!url) {
|
||||||
res.status(400).json({ ok: false, message: '영상 주소를 입력해 주세요.' })
|
res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const entry = await fetchVideoMeta(url)
|
const entry = await fetchVideoMeta(url)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
res.status(404).json({ ok: false, message: '메타데이터를 찾을 수 없습니다.' })
|
res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res.json({ ok: true, entry })
|
res.json({ ok: true, entry })
|
||||||
@@ -203,7 +204,7 @@ opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) =>
|
|||||||
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
|
||||||
const url = pickFirstValue(req.body?.url).trim()
|
const url = pickFirstValue(req.body?.url).trim()
|
||||||
if (!url) {
|
if (!url) {
|
||||||
res.status(400).json({ ok: false, message: '플레이리스트 주소를 입력해 주세요.' })
|
res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -238,19 +239,27 @@ opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, ne
|
|||||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
const definition = await loadPackDefinition(packKey)
|
const definition = await loadPackDefinition(packKey)
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
res.status(404).type('text/plain').send('음악퀴즈를 찾을 수 없습니다.')
|
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const list = await loadPackList(packKey)
|
const list = await loadPackList(packKey)
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(`# === musicquiz: ${definition.name} ===`)
|
lines.push(t('datapackOutput.header', { name: definition.name }))
|
||||||
lines.push(`# 총 ${list.music.length}곡 / 사진 ${list.images.length}장`)
|
lines.push(t('datapackOutput.summary', {
|
||||||
lines.push(`say [musicquiz] 데이터팩 초기화`)
|
musicCount: list.music.length,
|
||||||
lines.push(`# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.`)
|
imageCount: list.images.length
|
||||||
|
}))
|
||||||
|
lines.push(t('datapackOutput.initLine'))
|
||||||
|
lines.push(t('datapackOutput.placeholder'))
|
||||||
list.music.forEach((entry, index) => {
|
list.music.forEach((entry, index) => {
|
||||||
const title = entry.title || '(제목 없음)'
|
const title = entry.title || t('datapackOutput.titleFallback')
|
||||||
const artist = entry.artist || '(가수 미상)'
|
const artist = entry.artist || t('datapackOutput.artistFallback')
|
||||||
lines.push(`# ${index + 1}. ${title} - ${artist} (${entry.durationSec}s)`)
|
lines.push(t('datapackOutput.trackLine', {
|
||||||
|
index: index + 1,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
duration: entry.durationSec
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
res.type('text/plain; charset=utf-8').send(lines.join('\n') + '\n')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -287,7 +296,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
|||||||
|
|
||||||
const normalized = normalizePackDefinition(partial)
|
const normalized = normalizePackDefinition(partial)
|
||||||
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
|
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
|
||||||
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
|
res.status(400).send(t('errors.ramOrderInvalid'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const finalKey = await renamePack(packKey, requestedKey, normalized)
|
const finalKey = await renamePack(packKey, requestedKey, normalized)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path'
|
|||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import { getMcCustomDir } from '../shared/paths.js'
|
import { getMcCustomDir } from '../shared/paths.js'
|
||||||
|
import { t } from './i18n.js'
|
||||||
|
|
||||||
export interface YtPlaylistEntry {
|
export interface YtPlaylistEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,7 +16,7 @@ export interface YtPlaylistEntry {
|
|||||||
|
|
||||||
export class YtDlpUnavailableError extends Error {
|
export class YtDlpUnavailableError extends Error {
|
||||||
constructor(message?: string) {
|
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)
|
const okVersion = await probeVersion(target)
|
||||||
if (!okVersion) {
|
if (!okVersion) {
|
||||||
throw new YtDlpUnavailableError('yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.')
|
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
|
||||||
}
|
}
|
||||||
return target
|
return target
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -71,7 +72,7 @@ export async function ensureYtDlp(): Promise<string> {
|
|||||||
throw err instanceof YtDlpUnavailableError
|
throw err instanceof YtDlpUnavailableError
|
||||||
? err
|
? err
|
||||||
: new YtDlpUnavailableError(
|
: new YtDlpUnavailableError(
|
||||||
'yt-dlp 자동 설치에 실패했습니다: ' + (err instanceof Error ? err.message : String(err))
|
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
installPromise = null
|
installPromise = null
|
||||||
@@ -112,7 +113,7 @@ function probeVersion(bin: string): Promise<boolean> {
|
|||||||
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (redirects > 8) {
|
if (redirects > 8) {
|
||||||
reject(new Error('redirect 가 너무 많습니다.'))
|
reject(new Error(t('youtube.tooManyRedirects')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lib = url.startsWith('https://') ? https : http
|
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('error', (err) => reject(err))
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code !== 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
|
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('error', (err) => reject(err))
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
if (code !== 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
|
||||||
|
|||||||
93
src/shared/i18n.ts
Normal file
93
src/shared/i18n.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단순 키-문자열 사전. 중첩 객체도 허용해서 그룹화 가능.
|
||||||
|
* { step1: { title: '1단계. 음악퀴즈 선택' } }
|
||||||
|
* t('step1.title') → '1단계. 음악퀴즈 선택'
|
||||||
|
*/
|
||||||
|
export type Locale = Record<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자유 형식 ko-kr.json 을 로드하고 `t(key, params)` 헬퍼를 만들어 반환.
|
||||||
|
*
|
||||||
|
* 사용 패턴:
|
||||||
|
* const { t, dict } = createI18n(path.join(__dirname, 'locales', 'ko-kr.json'))
|
||||||
|
* t('step1.title')
|
||||||
|
* t('install.downloading', { idx: 3 }) // → '3번 노래 다운로드 중…'
|
||||||
|
*
|
||||||
|
* 키가 사전에 없으면 키 자체를 반환(개발 중 누락 빨리 찾도록).
|
||||||
|
* 사전이 비어 있어도 빌드는 깨지지 않고 키만 노출.
|
||||||
|
*/
|
||||||
|
export interface I18n {
|
||||||
|
/** 키로 문자열 lookup. 누락 시 키 그대로 반환. */
|
||||||
|
t(key: string, params?: Record<string, string | number>): string
|
||||||
|
/** 렌더러로 전달하기 위한 원본 사전(JSON 그대로). */
|
||||||
|
dict: Locale
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createI18n(filePath: string): I18n {
|
||||||
|
let dict: Locale = {}
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
dict = JSON.parse(raw) as Locale
|
||||||
|
} catch {
|
||||||
|
// 파일이 없거나 깨진 경우 빈 사전. t() 가 키 자체를 돌려주므로 UI 가 깨지진 않음.
|
||||||
|
dict = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup(key: string): string | undefined {
|
||||||
|
const parts = key.split('.')
|
||||||
|
let cur: unknown = dict
|
||||||
|
for (const p of parts) {
|
||||||
|
if (cur && typeof cur === 'object' && p in (cur as Record<string, unknown>)) {
|
||||||
|
cur = (cur as Record<string, unknown>)[p]
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return typeof cur === 'string' ? cur : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(tpl: string, params?: Record<string, string | number>): string {
|
||||||
|
if (!params) return tpl
|
||||||
|
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name: string) => {
|
||||||
|
return name in params ? String(params[name]) : `{{${name}}}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
t(key, params) {
|
||||||
|
const found = lookup(key)
|
||||||
|
return interpolate(found ?? key, params)
|
||||||
|
},
|
||||||
|
dict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진입점에서 호출할 표준 로더. 컴포넌트 이름과 `__dirname`(컴파일 후) 만 주면
|
||||||
|
* `locales/<component>/ko-kr.json` 을 찾아 로드.
|
||||||
|
*
|
||||||
|
* 탐색 순서(처음 발견된 것만 사용):
|
||||||
|
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
|
||||||
|
* 2. `<프로젝트 루트>/locales/<component>/ko-kr.json`
|
||||||
|
*/
|
||||||
|
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n {
|
||||||
|
// 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
|
||||||
|
const projectRoot = path.resolve(__dirname, '..', '..')
|
||||||
|
|
||||||
|
const candidates: string[] = []
|
||||||
|
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
||||||
|
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
|
||||||
|
candidates.push(path.join(resourcesPath, 'locales', component, 'ko-kr.json'))
|
||||||
|
}
|
||||||
|
candidates.push(path.join(projectRoot, 'locales', component, 'ko-kr.json'))
|
||||||
|
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
return createI18n(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createI18n(candidates[candidates.length - 1] ?? '')
|
||||||
|
}
|
||||||
@@ -3,29 +3,29 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>음악퀴즈 목록</title>
|
<title><%= t('site.indexTitle') %></title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<h1>마인크래프트 음악퀴즈</h1>
|
<h1><%= t('site.heroTitle') %></h1>
|
||||||
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p>
|
<p><%= t('site.heroSubtitle') %></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="cardRow horizontalScroll">
|
<section class="cardRow horizontalScroll">
|
||||||
<% if (packs.length === 0) { %>
|
<% if (packs.length === 0) { %>
|
||||||
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
<p class="muted"><%= t('site.empty') %></p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% packs.forEach(function (entry) { %>
|
<% packs.forEach(function (entry) { %>
|
||||||
<article class="packCard">
|
<article class="packCard">
|
||||||
<h2><%= entry.name %></h2>
|
<h2><%= entry.name %></h2>
|
||||||
<p class="muted">파일: <%= entry.file %>.json</p>
|
<p class="muted"><%= t('site.fileLabel', { file: entry.file }) %></p>
|
||||||
<% if (entry.definition) { %>
|
<% if (entry.definition) { %>
|
||||||
<ul class="metaList">
|
<ul class="metaList">
|
||||||
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
|
<li><%= t('site.mcVersion') %> <strong><%= entry.definition.mcVersion %></strong></li>
|
||||||
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
|
<li><%= t('site.platform') %> <strong><%= entry.definition.platform.type %></strong></li>
|
||||||
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li>
|
<li><%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %></li>
|
||||||
</ul>
|
</ul>
|
||||||
<% } %>
|
<% } %>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>관리자 대시보드</title>
|
<title><%= t('dashboard.browserTitle') %></title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
@@ -11,36 +11,36 @@
|
|||||||
|
|
||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="dashboardHeader">
|
<section class="dashboardHeader">
|
||||||
<h1>음악퀴즈 목록</h1>
|
<h1><%= t('dashboard.title') %></h1>
|
||||||
<div class="dashboardActions">
|
<div class="dashboardActions">
|
||||||
<a class="secondaryButton" href="/op/list">음악목록 수정</a>
|
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
|
||||||
<a class="secondaryButton" href="/op/datapack">데이터팩 수정</a>
|
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
|
||||||
<form method="post" action="/op/dashboard/create" class="inlineForm">
|
<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>
|
</form>
|
||||||
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button>
|
<button type="button" class="secondaryButton" id="deleteToggle"><%= t('dashboard.deletePack') %></button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
|
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
|
||||||
<section class="cardRow horizontalScroll">
|
<section class="cardRow horizontalScroll">
|
||||||
<% if (items.length === 0) { %>
|
<% if (items.length === 0) { %>
|
||||||
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p>
|
<p class="muted"><%= t('dashboard.emptyHint') %></p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% items.forEach(function (item) { %>
|
<% items.forEach(function (item) { %>
|
||||||
<article class="packCard editableCard" data-key="<%= item.key %>">
|
<article class="packCard editableCard" data-key="<%= item.key %>">
|
||||||
<label class="cardCheckbox" hidden>
|
<label class="cardCheckbox" hidden>
|
||||||
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
|
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
|
||||||
<span>선택</span>
|
<span><%= t('dashboard.select') %></span>
|
||||||
</label>
|
</label>
|
||||||
<a class="cardLink" href="/op/dashboard/<%= item.key %>">
|
<a class="cardLink" href="/op/dashboard/<%= item.key %>">
|
||||||
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||||
<p class="muted"><%= item.key %>.json</p>
|
<p class="muted"><%= item.key %>.json</p>
|
||||||
<% if (item.definition) { %>
|
<% if (item.definition) { %>
|
||||||
<ul class="metaList">
|
<ul class="metaList">
|
||||||
<li>MC <%= item.definition.mcVersion %></li>
|
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||||
<li>플랫폼 <%= item.definition.platform.type %></li>
|
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||||
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
|
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||||
</ul>
|
</ul>
|
||||||
<% } %>
|
<% } %>
|
||||||
</a>
|
</a>
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
<% }) %>
|
<% }) %>
|
||||||
</section>
|
</section>
|
||||||
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
|
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
|
||||||
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
|
<button type="button" class="secondaryButton" id="deleteCancel"><%= t('common.cancel') %></button>
|
||||||
<button type="submit" class="dangerButton">삭제 확인</button>
|
<button type="submit" class="dangerButton"><%= t('dashboard.confirmDelete') %></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>데이터팩 수정</title>
|
<title><%= t('datapack.browserTitle') %></title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
@@ -12,21 +12,21 @@
|
|||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="dashboardHeader">
|
<section class="dashboardHeader">
|
||||||
<div>
|
<div>
|
||||||
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
|
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
|
||||||
<h1 style="margin-top:20px;">데이터팩 수정</h1>
|
<h1 style="margin-top:20px;"><%= t('datapack.title') %></h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="dpControls">
|
<section class="dpControls">
|
||||||
<button type="button" class="primaryButton" id="pickPackBtn">음악퀴즈 선택</button>
|
<button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
|
||||||
<span class="muted" id="pickedLabel">선택된 음악퀴즈 없음</span>
|
<span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="muted" id="countLabel"></p>
|
<p class="muted" id="countLabel"></p>
|
||||||
|
|
||||||
<section class="dpActions" hidden id="dpActions">
|
<section class="dpActions" hidden id="dpActions">
|
||||||
<button type="button" class="secondaryButton" id="exportBtn">데이터팩 출력</button>
|
<button type="button" class="secondaryButton" id="exportBtn"><%= t('datapack.export') %></button>
|
||||||
<button type="button" class="secondaryButton" id="copyBtn">복사</button>
|
<button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
|
||||||
<span class="statusText" id="dp-status"></span>
|
<span class="statusText" id="dp-status"></span>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
<!-- 음악퀴즈 선택 팝업 -->
|
<!-- 음악퀴즈 선택 팝업 -->
|
||||||
<div class="modalOverlay" id="pickModal" hidden>
|
<div class="modalOverlay" id="pickModal" hidden>
|
||||||
<div class="modalCard">
|
<div class="modalCard">
|
||||||
<header><h3>음악퀴즈 선택</h3>
|
<header><h3><%= t('datapack.modalPickTitle') %></h3>
|
||||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="modalBody">
|
<div class="modalBody">
|
||||||
<div class="cardRow horizontalScroll" id="pickList">
|
<div class="cardRow horizontalScroll" id="pickList">
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
<p class="muted"><%= item.key %>.json</p>
|
<p class="muted"><%= item.key %>.json</p>
|
||||||
<% if (item.definition) { %>
|
<% if (item.definition) { %>
|
||||||
<ul class="metaList">
|
<ul class="metaList">
|
||||||
<li>MC <%= item.definition.mcVersion %></li>
|
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||||
<li>플랫폼 <%= item.definition.platform.type %></li>
|
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||||
</ul>
|
</ul>
|
||||||
<% } %>
|
<% } %>
|
||||||
</article>
|
</article>
|
||||||
@@ -58,6 +58,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
|
||||||
|
// 데이터팩 출력 본문의 "총 N곡" 패턴은 datapackOutput.summary 와 동일.
|
||||||
|
var SUMMARY_PATTERN = <%- JSON.stringify(localeDict.datapackOutput.summary) %>;
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var pickModal = document.getElementById('pickModal')
|
var pickModal = document.getElementById('pickModal')
|
||||||
@@ -75,12 +80,10 @@
|
|||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
pickedKey = card.getAttribute('data-key')
|
pickedKey = card.getAttribute('data-key')
|
||||||
var name = card.getAttribute('data-name')
|
var name = card.getAttribute('data-name')
|
||||||
document.getElementById('pickedLabel').textContent = '선택: ' + name
|
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
|
||||||
pickModal.hidden = true
|
pickModal.hidden = true
|
||||||
document.getElementById('dpActions').hidden = false
|
document.getElementById('dpActions').hidden = false
|
||||||
// 곡 수 미리 가져오기
|
|
||||||
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
|
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
|
||||||
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
|
|
||||||
document.getElementById('countLabel').textContent = ''
|
document.getElementById('countLabel').textContent = ''
|
||||||
document.getElementById('codeOut').hidden = true
|
document.getElementById('codeOut').hidden = true
|
||||||
})
|
})
|
||||||
@@ -88,30 +91,30 @@
|
|||||||
document.getElementById('exportBtn').addEventListener('click', function () {
|
document.getElementById('exportBtn').addEventListener('click', function () {
|
||||||
if (!pickedKey) return
|
if (!pickedKey) return
|
||||||
var s = document.getElementById('dp-status')
|
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')
|
fetch('/op/datapack/' + encodeURIComponent(pickedKey) + '/generate')
|
||||||
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
|
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
s.textContent = '실패: ' + res.text; s.classList.add('error')
|
s.textContent = I18N.failed.replace('{{message}}', res.text); s.classList.add('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var out = document.getElementById('codeOut')
|
var out = document.getElementById('codeOut')
|
||||||
out.textContent = res.text
|
out.textContent = res.text
|
||||||
out.hidden = false
|
out.hidden = false
|
||||||
// 첫줄/둘째줄에서 카운트 가져와 표기
|
// 첫줄/둘째줄에서 곡 개수를 추출해 카운트 라벨에 표시.
|
||||||
var m = res.text.match(/총\s+(\d+)곡/)
|
var m = res.text.match(/총\s+(\d+)곡/)
|
||||||
if (m) document.getElementById('countLabel').textContent = '총 ' + m[1] + '개의 음악을 찾았습니다.'
|
if (m) document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', 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('copyBtn').addEventListener('click', function () {
|
document.getElementById('copyBtn').addEventListener('click', function () {
|
||||||
var out = document.getElementById('codeOut')
|
var out = document.getElementById('codeOut')
|
||||||
if (out.hidden) return
|
if (out.hidden) return
|
||||||
navigator.clipboard.writeText(out.textContent).then(function () {
|
navigator.clipboard.writeText(out.textContent).then(function () {
|
||||||
var s = document.getElementById('dp-status')
|
var s = document.getElementById('dp-status')
|
||||||
s.textContent = '복사됨'
|
s.textContent = I18N.copied
|
||||||
s.classList.remove('error')
|
s.classList.remove('error')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
@@ -12,27 +12,27 @@
|
|||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="editorHeader">
|
<section class="editorHeader">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">PACK EDITOR</p>
|
<p class="eyebrow"><%= t('editor.eyebrow') %></p>
|
||||||
<h1><%= pack.name %></h1>
|
<h1><%= pack.name %></h1>
|
||||||
</div>
|
</div>
|
||||||
<a class="ghostLink" href="/op/dashboard">목록으로</a>
|
<a class="ghostLink" href="/op/dashboard"><%= t('common.backToList') %></a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form method="post" class="editorForm" id="editorForm">
|
<form method="post" class="editorForm" id="editorForm">
|
||||||
<div class="gridTwo">
|
<div class="gridTwo">
|
||||||
<label>
|
<label>
|
||||||
<span>음악퀴즈 이름</span>
|
<span><%= t('editor.displayName') %></span>
|
||||||
<input name="displayName" value="<%= pack.name %>" required />
|
<input name="displayName" value="<%= pack.name %>" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>JSON 파일 이름 (확장자 제외)</span>
|
<span><%= t('editor.fileName') %></span>
|
||||||
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
|
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gridTwo">
|
<div class="gridTwo">
|
||||||
<label>
|
<label>
|
||||||
<span>마인크래프트 버전</span>
|
<span><%= t('editor.mcVersion') %></span>
|
||||||
<select name="mcVersion" required>
|
<select name="mcVersion" required>
|
||||||
<% releases.forEach(function (release) { %>
|
<% releases.forEach(function (release) { %>
|
||||||
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
|
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>모드 플랫폼</span>
|
<span><%= t('editor.platformType') %></span>
|
||||||
<select name="platformType" id="platformType">
|
<select name="platformType" id="platformType">
|
||||||
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
|
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
|
||||||
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
|
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
|
||||||
@@ -48,62 +48,75 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="fullSpan" id="platformDownloadField">
|
<label class="fullSpan" id="platformDownloadField">
|
||||||
<span>플랫폼 설치파일 URL</span>
|
<span><%= t('editor.platformDownloadUrl') %></span>
|
||||||
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
|
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
|
||||||
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/<파일명></code>으로 해석됩니다.</small>
|
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
|
||||||
</label>
|
</label>
|
||||||
<label class="fullSpan" id="platformLoaderField" hidden>
|
<label class="fullSpan" id="platformLoaderField" hidden>
|
||||||
<span>Fabric Loader 버전</span>
|
<span><%= t('editor.platformLoaderVersion') %></span>
|
||||||
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
|
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
|
||||||
<option value="">불러오는 중...</option>
|
<option value=""><%= t('common.loading') %></option>
|
||||||
</select>
|
</select>
|
||||||
<small class="muted">선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.</small>
|
<small class="muted"><%= t('editor.platformLoaderHint') %></small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>서버 최소 램 (MB)</span>
|
<span><%= t('editor.serverMinRam') %></span>
|
||||||
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
|
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>서버 최대 램 (MB)</span>
|
<span><%= t('editor.serverMaxRam') %></span>
|
||||||
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
|
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>클라이언트 최소 램 (MB)</span>
|
<span><%= t('editor.clientMinRam') %></span>
|
||||||
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
|
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>클라이언트 권장 램 (MB)</span>
|
<span><%= t('editor.clientRecommendedRam') %></span>
|
||||||
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
|
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>맵 파일 (.zip)</span>
|
<span><%= t('editor.mapPath') %></span>
|
||||||
<input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" />
|
<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>
|
||||||
<label>
|
<label>
|
||||||
<span>서버 파일 (.zip)</span>
|
<span><%= t('editor.serverPath') %></span>
|
||||||
<input name="serverPath" value="<%= pack.serverPath %>" placeholder="my-server.zip" pattern=".+\.zip" />
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gridTwo">
|
<div class="gridTwo">
|
||||||
<label>
|
<label>
|
||||||
<span>모드 폴더 이름</span>
|
<span><%= t('editor.modsFolder') %></span>
|
||||||
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
|
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
|
||||||
<small class="muted">/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
|
<small class="muted"><%- t('editor.modsFolderHint') %></small>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>리소스팩 (.zip)</span>
|
<span><%= t('editor.resourcepackPath') %></span>
|
||||||
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
|
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
|
||||||
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small>
|
<small class="muted"><%= t('editor.resourcepackHint') %></small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="primaryButton" type="submit">저장</button>
|
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</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>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var platformSelect = document.getElementById('platformType')
|
var platformSelect = document.getElementById('platformType')
|
||||||
@@ -136,7 +149,7 @@
|
|||||||
|
|
||||||
function populateLoaderOptions(versions, preselect) {
|
function populateLoaderOptions(versions, preselect) {
|
||||||
if (!versions || versions.length === 0) {
|
if (!versions || versions.length === 0) {
|
||||||
loaderSelect.innerHTML = '<option value="">호환 로더 없음</option>'
|
loaderSelect.innerHTML = '<option value="">' + I18N.loaderEmpty + '</option>'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var html = ''
|
var html = ''
|
||||||
@@ -156,7 +169,7 @@
|
|||||||
function loadFabricLoaders() {
|
function loadFabricLoaders() {
|
||||||
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
|
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
|
||||||
if (!mc) {
|
if (!mc) {
|
||||||
loaderSelect.innerHTML = '<option value="">마인크래프트 버전을 먼저 선택하세요</option>'
|
loaderSelect.innerHTML = '<option value="">' + I18N.loaderPickMc + '</option>'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (loaderCache[mc]) {
|
if (loaderCache[mc]) {
|
||||||
@@ -164,7 +177,7 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var seq = ++loaderFetchSeq
|
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))
|
fetch('https://meta.fabricmc.net/v2/versions/loader/' + encodeURIComponent(mc))
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
@@ -181,7 +194,8 @@
|
|||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
if (seq !== loaderFetchSeq) return
|
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 +211,12 @@
|
|||||||
var clientReco = Number(form.clientRecommendedRam.value)
|
var clientReco = Number(form.clientRecommendedRam.value)
|
||||||
if (clientMin > clientReco) {
|
if (clientMin > clientReco) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
|
alert(I18N.ramOrderInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
|
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
alert('Fabric 로더 버전을 선택해 주세요.')
|
alert(I18N.fabricLoaderRequired)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>음악목록 수정</title>
|
<title><%= t('list.browserTitle') %></title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="dashboardHeader">
|
<section class="dashboardHeader">
|
||||||
<div>
|
<div>
|
||||||
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
|
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
|
||||||
<h1 style="margin-top:20px;">음악목록 수정</h1>
|
<h1 style="margin-top:20px;"><%= t('list.title') %></h1>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="cardRow horizontalScroll">
|
<section class="cardRow horizontalScroll">
|
||||||
<% if (items.length === 0) { %>
|
<% if (items.length === 0) { %>
|
||||||
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
<p class="muted"><%= t('site.empty') %></p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<% items.forEach(function (item) { %>
|
<% items.forEach(function (item) { %>
|
||||||
<article class="packCard">
|
<article class="packCard">
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
<p class="muted"><%= item.key %>.json</p>
|
<p class="muted"><%= item.key %>.json</p>
|
||||||
<% if (item.definition) { %>
|
<% if (item.definition) { %>
|
||||||
<ul class="metaList">
|
<ul class="metaList">
|
||||||
<li>MC <%= item.definition.mcVersion %></li>
|
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||||
<li>플랫폼 <%= item.definition.platform.type %></li>
|
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||||
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
|
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||||
</ul>
|
</ul>
|
||||||
<% } %>
|
<% } %>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody">
|
<body class="siteBody">
|
||||||
@@ -12,31 +12,31 @@
|
|||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="dashboardHeader">
|
<section class="dashboardHeader">
|
||||||
<div>
|
<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>
|
<h1 style="margin-top:20px;"><%= pack.name %></h1>
|
||||||
<p class="muted"><%= packKey %>.json</p>
|
<p class="muted"><%= packKey %>.json</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dirtyMark" id="dirty-mark" hidden title="저장되지 않은 변경사항이 있습니다">*</div>
|
<div class="dirtyMark" id="dirty-mark" hidden title="<%= t('listEditor.dirtyTooltip') %>">*</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="tabBar">
|
<div class="tabBar">
|
||||||
<button type="button" class="tabBtn active" data-tab="music">음악목록</button>
|
<button type="button" class="tabBtn active" data-tab="music"><%= t('listEditor.tabMusic') %></button>
|
||||||
<button type="button" class="tabBtn" data-tab="image">사진목록</button>
|
<button type="button" class="tabBtn" data-tab="image"><%= t('listEditor.tabImage') %></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 음악 탭 -->
|
<!-- 음악 탭 -->
|
||||||
<section class="tabPanel" id="tab-music">
|
<section class="tabPanel" id="tab-music">
|
||||||
<div class="listActionsRow">
|
<div class="listActionsRow">
|
||||||
<button type="button" class="primaryButton" data-action="save" 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">목록 초기화</button>
|
<button type="button" class="dangerButton" data-action="clear" data-target="music"><%= t('listEditor.clearList') %></button>
|
||||||
<span class="statusText" id="status-music"></span>
|
<span class="statusText" id="status-music"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="playlistRow">
|
<div class="playlistRow">
|
||||||
<input type="url" class="textInput" id="music-playlist-url"
|
<input type="url" class="textInput" id="music-playlist-url"
|
||||||
placeholder="유튜브 플레이리스트 URL"
|
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
|
||||||
value="<%= list.musicPlaylistUrl %>" />
|
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>
|
</div>
|
||||||
|
|
||||||
<ol class="trackList" id="music-list"></ol>
|
<ol class="trackList" id="music-list"></ol>
|
||||||
@@ -45,17 +45,17 @@
|
|||||||
<!-- 사진 탭 -->
|
<!-- 사진 탭 -->
|
||||||
<section class="tabPanel" id="tab-image" hidden>
|
<section class="tabPanel" id="tab-image" hidden>
|
||||||
<div class="listActionsRow">
|
<div class="listActionsRow">
|
||||||
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</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">목록 초기화</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">음악목록에서 가져오기</button>
|
<button type="button" class="secondaryButton" id="image-from-music"><%= t('listEditor.imageFromMusic') %></button>
|
||||||
<span class="statusText" id="status-image"></span>
|
<span class="statusText" id="status-image"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="playlistRow">
|
<div class="playlistRow">
|
||||||
<input type="url" class="textInput" id="image-playlist-url"
|
<input type="url" class="textInput" id="image-playlist-url"
|
||||||
placeholder="유튜브 플레이리스트 URL"
|
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
|
||||||
value="<%= list.imagePlaylistUrl %>" />
|
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>
|
||||||
|
|
||||||
<div class="imageGrid" id="image-list"></div>
|
<div class="imageGrid" id="image-list"></div>
|
||||||
@@ -64,22 +64,22 @@
|
|||||||
|
|
||||||
<!-- Context menu -->
|
<!-- Context menu -->
|
||||||
<div class="ctxMenu" id="ctxMenu" hidden>
|
<div class="ctxMenu" id="ctxMenu" hidden>
|
||||||
<button type="button" data-ctx="edit">수정</button>
|
<button type="button" data-ctx="edit"><%= t('common.edit') %></button>
|
||||||
<button type="button" data-ctx="delete">삭제</button>
|
<button type="button" data-ctx="delete"><%= t('common.delete') %></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Confirm modal -->
|
<!-- Confirm modal -->
|
||||||
<div class="modalOverlay" id="confirmModal" hidden>
|
<div class="modalOverlay" id="confirmModal" hidden>
|
||||||
<div class="modalCard">
|
<div class="modalCard">
|
||||||
<header><h3 id="confirm-title">확인</h3>
|
<header><h3 id="confirm-title"><%= t('listEditor.modalConfirmTitle') %></h3>
|
||||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="modalBody">
|
<div class="modalBody">
|
||||||
<p id="confirm-message"></p>
|
<p id="confirm-message"></p>
|
||||||
</div>
|
</div>
|
||||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
|
||||||
<button type="button" class="primaryButton" id="confirm-ok">확인</button>
|
<button type="button" class="primaryButton" id="confirm-ok"><%= t('common.ok') %></button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,21 +87,21 @@
|
|||||||
<!-- Edit modal (music) -->
|
<!-- Edit modal (music) -->
|
||||||
<div class="modalOverlay" id="editMusicModal" hidden>
|
<div class="modalOverlay" id="editMusicModal" hidden>
|
||||||
<div class="modalCard">
|
<div class="modalCard">
|
||||||
<header><h3>음악 항목 수정</h3>
|
<header><h3><%= t('listEditor.musicEditTitle') %></h3>
|
||||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="modalBody">
|
<div class="modalBody">
|
||||||
<label>유튜브 영상 주소
|
<label><%= t('listEditor.musicEditUrl') %>
|
||||||
<input type="url" id="edit-music-url" class="textInput" />
|
<input type="url" id="edit-music-url" class="textInput" />
|
||||||
</label>
|
</label>
|
||||||
<p class="muted" style="margin-top:6px;font-size:12px;">
|
<p class="muted" style="margin-top:6px;font-size:12px;">
|
||||||
저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.
|
<%= t('listEditor.musicEditHint') %>
|
||||||
</p>
|
</p>
|
||||||
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
|
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
|
||||||
</div>
|
</div>
|
||||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
|
||||||
<button type="button" class="primaryButton" id="edit-music-save">저장</button>
|
<button type="button" class="primaryButton" id="edit-music-save"><%= t('common.save') %></button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,21 +109,21 @@
|
|||||||
<!-- Edit modal (image) -->
|
<!-- Edit modal (image) -->
|
||||||
<div class="modalOverlay" id="editImageModal" hidden>
|
<div class="modalOverlay" id="editImageModal" hidden>
|
||||||
<div class="modalCard">
|
<div class="modalCard">
|
||||||
<header><h3>사진 항목 수정</h3>
|
<header><h3><%= t('listEditor.imageEditTitle') %></h3>
|
||||||
<button class="modalClose" type="button" data-modal-close>×</button>
|
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="modalBody">
|
<div class="modalBody">
|
||||||
<div class="segmentedRow">
|
<div class="segmentedRow">
|
||||||
<button type="button" class="segBtn active" data-seg="yt">유튜브 주소</button>
|
<button type="button" class="segBtn active" data-seg="yt"><%= t('listEditor.imageSegYt') %></button>
|
||||||
<button type="button" class="segBtn" data-seg="img">이미지 주소</button>
|
<button type="button" class="segBtn" data-seg="img"><%= t('listEditor.imageSegImg') %></button>
|
||||||
</div>
|
</div>
|
||||||
<label>주소
|
<label><%= t('listEditor.imageEditUrl') %>
|
||||||
<input type="url" id="edit-image-url" class="textInput" />
|
<input type="url" id="edit-image-url" class="textInput" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
<footer style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
<button type="button" class="secondaryButton" data-modal-close>취소</button>
|
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
|
||||||
<button type="button" class="primaryButton" id="edit-image-save">저장</button>
|
<button type="button" class="primaryButton" id="edit-image-save"><%= t('common.save') %></button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,6 +131,8 @@
|
|||||||
<script>
|
<script>
|
||||||
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||||
var INITIAL = <%- JSON.stringify(list) %>;
|
var INITIAL = <%- JSON.stringify(list) %>;
|
||||||
|
var I18N = <%- JSON.stringify(localeDict.listEditor) %>;
|
||||||
|
I18N.common = <%- JSON.stringify(localeDict.common) %>;
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/listEditor.js"></script>
|
<script src="/static/listEditor.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -3,21 +3,21 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>관리자 로그인</title>
|
<title><%= t('login.title') %></title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="siteBody centerLayout">
|
<body class="siteBody centerLayout">
|
||||||
<main class="loginCard">
|
<main class="loginCard">
|
||||||
<h1>관리자 로그인</h1>
|
<h1><%= t('login.title') %></h1>
|
||||||
<% if (error) { %>
|
<% if (error) { %>
|
||||||
<p class="errorBanner"><%= error %></p>
|
<p class="errorBanner"><%= error %></p>
|
||||||
<% } %>
|
<% } %>
|
||||||
<form method="post" action="/op" class="loginForm">
|
<form method="post" action="/op" class="loginForm">
|
||||||
<label>
|
<label>
|
||||||
<span>비밀번호</span>
|
<span><%= t('login.password') %></span>
|
||||||
<input name="password" type="password" autocomplete="current-password" required autofocus />
|
<input name="password" type="password" autocomplete="current-password" required autofocus />
|
||||||
</label>
|
</label>
|
||||||
<button class="primaryButton" type="submit">로그인</button>
|
<button class="primaryButton" type="submit"><%= t('login.submit') %></button>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<header class="topNav">
|
<header class="topNav">
|
||||||
<a class="navBrand" href="/op/dashboard">
|
<a class="navBrand" href="/op/dashboard">
|
||||||
<span class="navLogo">🎵</span>
|
<span class="navLogo">🎵</span>
|
||||||
<span class="navTitle">관리자 페이지</span>
|
<span class="navTitle"><%= t('nav.brand') %></span>
|
||||||
</a>
|
</a>
|
||||||
<div class="navUser">
|
<div class="navUser">
|
||||||
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
|
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
|
||||||
<div class="navUserMenu" id="userMenu" hidden>
|
<div class="navUserMenu" id="userMenu" hidden>
|
||||||
<form method="post" action="/op/logout">
|
<form method="post" action="/op/logout">
|
||||||
<button type="submit" class="dangerLink">로그아웃</button>
|
<button type="submit" class="dangerLink"><%= t('nav.logout') %></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user