Build music-quiz installer and management site per spec
Implements the full spec described in README.md: Management site (Node + TypeScript + Express + EJS): - Public main page lists packs registered in manifest.json. - /op login (account.json, internal-only), /op/dashboard manages packs with horizontal-scroll cards, add/select-and-delete flow, and the /op/dashboard/:packName editor (Mojang release dropdown, dynamic mods/resourcepacks lists, platform/RAM fields, file rename). - Routes for /manifest.json (public) and /file/* (server pack files). - Middleware blocks /account.json and /manifest/* directory access. Installer (Electron): - Five page renderer driven by IPC (preload contextBridge API): pack pick → single/multi → server install (path no-Korean check, JDK detect, file download, EULA, RAM gating, local web config editor, UPnP/port-forward check) → client install (.mc_custom mods + resourcepacks + launcher_profiles.json gameDir/javaArgs) → finish toggles (server folder, shortcut, server start, launcher start). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
96
views/op/dashboard.ejs
Normal file
96
views/op/dashboard.ejs
Normal file
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 대시보드</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="dashboardHeader">
|
||||
<h1>음악퀴즈 목록</h1>
|
||||
<div class="dashboardActions">
|
||||
<form method="post" action="/op/dashboard/create" class="inlineForm">
|
||||
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
|
||||
</form>
|
||||
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</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>
|
||||
<% } %>
|
||||
<% 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>
|
||||
</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.mods.length %>개</li>
|
||||
</ul>
|
||||
<% } %>
|
||||
</a>
|
||||
</article>
|
||||
<% }) %>
|
||||
</section>
|
||||
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
|
||||
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
|
||||
<button type="submit" class="dangerButton">삭제 확인</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var toggleButton = document.getElementById('deleteToggle')
|
||||
var confirmRow = document.getElementById('deleteConfirm')
|
||||
var cancelButton = document.getElementById('deleteCancel')
|
||||
var checkboxLabels = document.querySelectorAll('.cardCheckbox')
|
||||
var cardLinks = document.querySelectorAll('.cardLink')
|
||||
|
||||
function setSelectMode(active) {
|
||||
confirmRow.hidden = !active
|
||||
checkboxLabels.forEach(function (label) {
|
||||
if (active) label.removeAttribute('hidden')
|
||||
else label.setAttribute('hidden', '')
|
||||
})
|
||||
cardLinks.forEach(function (link) {
|
||||
if (active) {
|
||||
link.setAttribute('data-disabled', 'true')
|
||||
link.addEventListener('click', preventNavigation)
|
||||
} else {
|
||||
link.removeAttribute('data-disabled')
|
||||
link.removeEventListener('click', preventNavigation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function preventNavigation(event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
toggleButton.addEventListener('click', function () {
|
||||
setSelectMode(true)
|
||||
})
|
||||
cancelButton.addEventListener('click', function () {
|
||||
setSelectMode(false)
|
||||
document.querySelectorAll('input[name="targetKey"]').forEach(function (input) {
|
||||
input.checked = false
|
||||
})
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
163
views/op/editor.ejs
Normal file
163
views/op/editor.ejs
Normal file
@@ -0,0 +1,163 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= pack.name %> 편집</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
|
||||
<main class="pageWrap">
|
||||
<section class="editorHeader">
|
||||
<div>
|
||||
<p class="eyebrow">PACK EDITOR</p>
|
||||
<h1><%= pack.name %></h1>
|
||||
</div>
|
||||
<a class="ghostLink" href="/op/dashboard">목록으로</a>
|
||||
</section>
|
||||
|
||||
<form method="post" class="editorForm" id="editorForm">
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>음악퀴즈 이름</span>
|
||||
<input name="displayName" value="<%= pack.name %>" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>JSON 파일 이름 (확장자 제외)</span>
|
||||
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="gridTwo">
|
||||
<label>
|
||||
<span>마인크래프트 버전</span>
|
||||
<select name="mcVersion" required>
|
||||
<% releases.forEach(function (release) { %>
|
||||
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>모드 플랫폼</span>
|
||||
<select name="platformType" id="platformType">
|
||||
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
|
||||
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
|
||||
<% }) %>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fullSpan" id="platformDownloadField">
|
||||
<span>플랫폼 설치파일 URL</span>
|
||||
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="https://example.com/forge-installer.jar" />
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최소 램 (MB)</span>
|
||||
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>서버 최대 램 (MB)</span>
|
||||
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 최소 램 (MB)</span>
|
||||
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>클라이언트 권장 램 (MB)</span>
|
||||
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
|
||||
</label>
|
||||
<label class="fullSpan">
|
||||
<span>packPath (서버 파일 경로, /file/ 이후만)</span>
|
||||
<input name="packPath" value="<%= pack.packPath %>" placeholder="music-quiz/files" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset class="dynamicListFieldset">
|
||||
<legend>모드 (.jar)</legend>
|
||||
<div class="dynamicList" data-list="mods">
|
||||
<% pack.mods.forEach(function (mod) { %>
|
||||
<div class="dynamicRow">
|
||||
<input name="modName" placeholder="모드 이름" value="<%= mod.name %>" />
|
||||
<input name="modUrl" placeholder="다운로드 URL" value="<%= mod.downloadUrl %>" />
|
||||
<button type="button" class="dangerLink dynamicRemove">삭제</button>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<button type="button" class="secondaryButton" data-add="mods">모드 추가</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="dynamicListFieldset">
|
||||
<legend>리소스팩 (.zip)</legend>
|
||||
<div class="dynamicList" data-list="resourcepacks">
|
||||
<% pack.resourcepacks.forEach(function (resourcePack) { %>
|
||||
<div class="dynamicRow">
|
||||
<input name="resourceName" placeholder="리소스팩 이름" value="<%= resourcePack.name %>" />
|
||||
<input name="resourceUrl" placeholder="다운로드 URL" value="<%= resourcePack.downloadUrl %>" />
|
||||
<button type="button" class="dangerLink dynamicRemove">삭제</button>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
<button type="button" class="secondaryButton" data-add="resourcepacks">리소스팩 추가</button>
|
||||
</fieldset>
|
||||
|
||||
<button class="primaryButton" type="submit">저장</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var platformSelect = document.getElementById('platformType')
|
||||
var downloadField = document.getElementById('platformDownloadField')
|
||||
|
||||
function syncPlatformVisibility() {
|
||||
if (platformSelect.value === 'vanilla') {
|
||||
downloadField.setAttribute('hidden', '')
|
||||
downloadField.querySelector('input').value = ''
|
||||
} else {
|
||||
downloadField.removeAttribute('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
platformSelect.addEventListener('change', syncPlatformVisibility)
|
||||
syncPlatformVisibility()
|
||||
|
||||
document.querySelectorAll('[data-add]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var listKey = button.getAttribute('data-add')
|
||||
var list = document.querySelector('[data-list="' + listKey + '"]')
|
||||
if (!list) return
|
||||
var nameField = listKey === 'mods' ? 'modName' : 'resourceName'
|
||||
var urlField = listKey === 'mods' ? 'modUrl' : 'resourceUrl'
|
||||
var placeholder = listKey === 'mods' ? '모드 이름' : '리소스팩 이름'
|
||||
var row = document.createElement('div')
|
||||
row.className = 'dynamicRow'
|
||||
row.innerHTML =
|
||||
'<input name="' + nameField + '" placeholder="' + placeholder + '" />' +
|
||||
'<input name="' + urlField + '" placeholder="다운로드 URL" />' +
|
||||
'<button type="button" class="dangerLink dynamicRemove">삭제</button>'
|
||||
list.appendChild(row)
|
||||
})
|
||||
})
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (!target.classList.contains('dynamicRemove')) return
|
||||
var row = target.closest('.dynamicRow')
|
||||
if (row) row.remove()
|
||||
})
|
||||
|
||||
var form = document.getElementById('editorForm')
|
||||
form.addEventListener('submit', function (event) {
|
||||
var clientMin = Number(form.clientMinRam.value)
|
||||
var clientReco = Number(form.clientRecommendedRam.value)
|
||||
if (clientMin > clientReco) {
|
||||
event.preventDefault()
|
||||
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
|
||||
}
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
28
views/op/login.ejs
Normal file
28
views/op/login.ejs
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>관리자 로그인</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body class="siteBody centerLayout">
|
||||
<main class="loginCard">
|
||||
<h1>관리자 로그인</h1>
|
||||
<% if (error) { %>
|
||||
<p class="errorBanner"><%= error %></p>
|
||||
<% } %>
|
||||
<form method="post" action="/op" class="loginForm">
|
||||
<label>
|
||||
<span>아이디</span>
|
||||
<input name="id" autocomplete="username" required autofocus />
|
||||
</label>
|
||||
<label>
|
||||
<span>비밀번호</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<button class="primaryButton" type="submit">로그인</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user