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:
@@ -3,29 +3,29 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>음악퀴즈 목록</title>
|
||||
<title><%= t('site.indexTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<main class="pageWrap">
|
||||
<section class="hero">
|
||||
<h1>마인크래프트 음악퀴즈</h1>
|
||||
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p>
|
||||
<h1><%= t('site.heroTitle') %></h1>
|
||||
<p><%= t('site.heroSubtitle') %></p>
|
||||
</section>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<% if (packs.length === 0) { %>
|
||||
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
|
||||
<p class="muted"><%= t('site.empty') %></p>
|
||||
<% } %>
|
||||
<% packs.forEach(function (entry) { %>
|
||||
<article class="packCard">
|
||||
<h2><%= entry.name %></h2>
|
||||
<p class="muted">파일: <%= entry.file %>.json</p>
|
||||
<p class="muted"><%= t('site.fileLabel', { file: entry.file }) %></p>
|
||||
<% if (entry.definition) { %>
|
||||
<ul class="metaList">
|
||||
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
|
||||
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
|
||||
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li>
|
||||
<li><%= t('site.mcVersion') %> <strong><%= entry.definition.mcVersion %></strong></li>
|
||||
<li><%= t('site.platform') %> <strong><%= entry.definition.platform.type %></strong></li>
|
||||
<li><%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %></li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</article>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 대시보드</title>
|
||||
<title><%= t('dashboard.browserTitle') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
@@ -11,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>
|
||||
|
||||
@@ -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,21 @@
|
||||
<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>
|
||||
|
||||
<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="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,8 +36,8 @@
|
||||
<!-- 음악퀴즈 선택 팝업 -->
|
||||
<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">
|
||||
@@ -47,8 +47,8 @@
|
||||
<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 +58,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
|
||||
// 데이터팩 출력 본문의 "총 N곡" 패턴은 datapackOutput.summary 와 동일.
|
||||
var SUMMARY_PATTERN = <%- JSON.stringify(localeDict.datapackOutput.summary) %>;
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var pickModal = document.getElementById('pickModal')
|
||||
@@ -75,12 +80,10 @@
|
||||
card.addEventListener('click', function () {
|
||||
pickedKey = card.getAttribute('data-key')
|
||||
var name = card.getAttribute('data-name')
|
||||
document.getElementById('pickedLabel').textContent = '선택: ' + name
|
||||
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
|
||||
pickModal.hidden = true
|
||||
document.getElementById('dpActions').hidden = false
|
||||
// 곡 수 미리 가져오기
|
||||
fetch('/op/list/' + encodeURIComponent(pickedKey)).catch(function () {})
|
||||
// 더 직접적으로: generate 호출 시점에 카운트도 나옴. 일단 비워둠.
|
||||
document.getElementById('countLabel').textContent = ''
|
||||
document.getElementById('codeOut').hidden = true
|
||||
})
|
||||
@@ -88,30 +91,30 @@
|
||||
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 = '출력 완료'
|
||||
if (m) document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', m[1])
|
||||
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 () {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,7 +40,7 @@
|
||||
</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>
|
||||
@@ -48,62 +48,75 @@
|
||||
</select>
|
||||
</label>
|
||||
<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" />
|
||||
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/<파일명></code>으로 해석됩니다.</small>
|
||||
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
|
||||
</label>
|
||||
<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 || '' %>">
|
||||
<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/<폴더이름>/ 안의 모든 .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')
|
||||
@@ -136,7 +149,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 +169,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 +177,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 +194,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 +211,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)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,21 +87,21 @@
|
||||
<!-- 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>
|
||||
@@ -109,21 +109,21 @@
|
||||
<!-- 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 +131,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>
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 로그인</title>
|
||||
<title><%= t('login.title') %></title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody centerLayout">
|
||||
<main class="loginCard">
|
||||
<h1>관리자 로그인</h1>
|
||||
<h1><%= t('login.title') %></h1>
|
||||
<% if (error) { %>
|
||||
<p class="errorBanner"><%= error %></p>
|
||||
<% } %>
|
||||
<form method="post" action="/op" class="loginForm">
|
||||
<label>
|
||||
<span>비밀번호</span>
|
||||
<span><%= t('login.password') %></span>
|
||||
<input name="password" type="password" autocomplete="current-password" required autofocus />
|
||||
</label>
|
||||
<button class="primaryButton" type="submit">로그인</button>
|
||||
<button class="primaryButton" type="submit"><%= t('login.submit') %></button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<header class="topNav">
|
||||
<a class="navBrand" href="/op/dashboard">
|
||||
<span class="navLogo">🎵</span>
|
||||
<span class="navTitle">관리자 페이지</span>
|
||||
<span class="navTitle"><%= t('nav.brand') %></span>
|
||||
</a>
|
||||
<div class="navUser">
|
||||
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
|
||||
<div class="navUserMenu" id="userMenu" hidden>
|
||||
<form method="post" action="/op/logout">
|
||||
<button type="submit" class="dangerLink">로그아웃</button>
|
||||
<button type="submit" class="dangerLink"><%= t('nav.logout') %></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user