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:
2026-05-13 03:43:04 +09:00
parent 401d72622e
commit c2fcc2fbbf
15 changed files with 490 additions and 172 deletions

View File

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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>데이터팩 수정</title>
<title><%= t('datapack.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,21 +12,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')
})
})

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> 편집</title>
<title><%= t('editor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,27 +12,27 @@
<main class="pageWrap">
<section class="editorHeader">
<div>
<p class="eyebrow">PACK EDITOR</p>
<p class="eyebrow"><%= t('editor.eyebrow') %></p>
<h1><%= pack.name %></h1>
</div>
<a class="ghostLink" href="/op/dashboard">목록으로</a>
<a class="ghostLink" href="/op/dashboard"><%= t('common.backToList') %></a>
</section>
<form method="post" class="editorForm" id="editorForm">
<div class="gridTwo">
<label>
<span>음악퀴즈 이름</span>
<span><%= t('editor.displayName') %></span>
<input name="displayName" value="<%= pack.name %>" required />
</label>
<label>
<span>JSON 파일 이름 (확장자 제외)</span>
<span><%= t('editor.fileName') %></span>
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
</label>
</div>
<div class="gridTwo">
<label>
<span>마인크래프트 버전</span>
<span><%= t('editor.mcVersion') %></span>
<select name="mcVersion" required>
<% releases.forEach(function (release) { %>
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
@@ -40,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/&lt;파일명&gt;</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/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
<small class="muted"><%- t('editor.modsFolderHint') %></small>
</label>
<label>
<span>리소스팩 (.zip)</span>
<span><%= t('editor.resourcepackPath') %></span>
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small>
<small class="muted"><%= t('editor.resourcepackHint') %></small>
</label>
</div>
<button class="primaryButton" type="submit">저장</button>
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
</form>
</main>
<script>
var I18N = {
ramOrderInvalid: <%- JSON.stringify(t('editor.ramOrderInvalid')) %>,
fabricLoaderRequired: <%- JSON.stringify(t('editor.fabricLoaderRequired')) %>,
loaderEmpty: <%- JSON.stringify(t('editor.platformLoaderEmpty')) %>,
loaderPickMc: <%- JSON.stringify(t('editor.platformLoaderPickMc')) %>,
loaderLoadFailedPrefix: <%- JSON.stringify(t('editor.platformLoaderLoadFailed', { message: '__M__' })) %>,
loading: <%- JSON.stringify(t('common.loading')) %>
}
function formatLoaderLoadFailed(message) {
return I18N.loaderLoadFailedPrefix.replace('__M__', message)
}
</script>
<script>
(function () {
var platformSelect = document.getElementById('platformType')
@@ -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)
}
})
})()

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악목록 수정</title>
<title><%= t('list.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,14 +12,14 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard">← 돌아가기</a>
<h1 style="margin-top:20px;">음악목록 수정</h1>
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('list.title') %></h1>
</div>
</section>
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard">
@@ -28,9 +28,9 @@
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> — 음악/사진 목록</title>
<title><%= t('listEditor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,31 +12,31 @@
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/list">← 돌아가기</a>
<a class="ghostLink" href="/op/list"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= pack.name %></h1>
<p class="muted"><%= packKey %>.json</p>
</div>
<div class="dirtyMark" id="dirty-mark" hidden title="저장되지 않은 변경사항이 있습니다">*</div>
<div class="dirtyMark" id="dirty-mark" hidden title="<%= t('listEditor.dirtyTooltip') %>">*</div>
</section>
<div class="tabBar">
<button type="button" class="tabBtn active" data-tab="music">음악목록</button>
<button type="button" class="tabBtn" data-tab="image">사진목록</button>
<button type="button" class="tabBtn active" data-tab="music"><%= t('listEditor.tabMusic') %></button>
<button type="button" class="tabBtn" data-tab="image"><%= t('listEditor.tabImage') %></button>
</div>
<!-- 음악 탭 -->
<section class="tabPanel" id="tab-music">
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="music">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="music">목록 초기화</button>
<button type="button" class="primaryButton" data-action="save" data-target="music"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="music"><%= t('listEditor.clearList') %></button>
<span class="statusText" id="status-music"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="music-playlist-url"
placeholder="유튜브 플레이리스트 URL"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.musicPlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="music">플레이리스트 불러오기</button>
<button type="button" class="secondaryButton" data-action="fetch" data-target="music"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<ol class="trackList" id="music-list"></ol>
@@ -45,17 +45,17 @@
<!-- 사진 탭 -->
<section class="tabPanel" id="tab-image" hidden>
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
<button type="button" class="secondaryButton" id="image-from-music">음악목록에서 가져오기</button>
<button type="button" class="primaryButton" data-action="save" data-target="image"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="image"><%= t('listEditor.clearList') %></button>
<button type="button" class="secondaryButton" id="image-from-music"><%= t('listEditor.imageFromMusic') %></button>
<span class="statusText" id="status-image"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="image-playlist-url"
placeholder="유튜브 플레이리스트 URL"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.imagePlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="image">플레이리스트 불러오기</button>
<button type="button" class="secondaryButton" data-action="fetch" data-target="image"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<div class="imageGrid" id="image-list"></div>
@@ -64,22 +64,22 @@
<!-- Context menu -->
<div class="ctxMenu" id="ctxMenu" hidden>
<button type="button" data-ctx="edit">수정</button>
<button type="button" data-ctx="delete">삭제</button>
<button type="button" data-ctx="edit"><%= t('common.edit') %></button>
<button type="button" data-ctx="delete"><%= t('common.delete') %></button>
</div>
<!-- Confirm modal -->
<div class="modalOverlay" id="confirmModal" hidden>
<div class="modalCard">
<header><h3 id="confirm-title">확인</h3>
<button class="modalClose" type="button" data-modal-close>×</button>
<header><h3 id="confirm-title"><%= t('listEditor.modalConfirmTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<p id="confirm-message"></p>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button>
<button type="button" class="primaryButton" id="confirm-ok">확인</button>
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="confirm-ok"><%= t('common.ok') %></button>
</footer>
</div>
</div>
@@ -87,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>

View File

@@ -3,21 +3,21 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>관리자 로그인</title>
<title><%= t('login.title') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody centerLayout">
<main class="loginCard">
<h1>관리자 로그인</h1>
<h1><%= t('login.title') %></h1>
<% if (error) { %>
<p class="errorBanner"><%= error %></p>
<% } %>
<form method="post" action="/op" class="loginForm">
<label>
<span>비밀번호</span>
<span><%= t('login.password') %></span>
<input name="password" type="password" autocomplete="current-password" required autofocus />
</label>
<button class="primaryButton" type="submit">로그인</button>
<button class="primaryButton" type="submit"><%= t('login.submit') %></button>
</form>
</main>
</body>