Replace distribution JSON editor with form UI
Some checks failed
Build / release (macos-latest) (push) Has been cancelled
Build / release (ubuntu-latest) (push) Has been cancelled
Build / release (windows-latest) (push) Has been cancelled
Windows Smoke Test / windows-smoke (push) Has been cancelled

This commit is contained in:
2026-05-06 03:15:30 +09:00
parent 7c7357e44e
commit 9ab8d88fd5
5 changed files with 228 additions and 17 deletions

View File

@@ -28,7 +28,7 @@ npm run admin
``` ```
- 기본 주소: `http://127.0.0.1:8787` - 기본 주소: `http://127.0.0.1:8787`
- `distribution.json` 업로드 / 새로 만들기 / 직접 편집 가능 - `distribution.json` 업로드 / 새로 만들기 / 입력 폼 편집 가능
- 월드 ZIP 업로드 가능 - 월드 ZIP 업로드 가능
- 서버용 버킷 JAR 업로드 가능 - 서버용 버킷 JAR 업로드 가능
- 서버 메모리, 최대 인원수, 화이트리스트, 포트 설정 가능 - 서버 메모리, 최대 인원수, 화이트리스트, 포트 설정 가능

View File

@@ -5,7 +5,10 @@ const state = {
}, },
meta: null, meta: null,
selectedProfileId: null, selectedProfileId: null,
dirty: false dirty: false,
distributionEditor: {
document: null
}
} }
const profileList = document.getElementById('profileList') const profileList = document.getElementById('profileList')
@@ -26,7 +29,9 @@ const createDistributionButton = document.getElementById('createDistributionButt
const distributionEditorModal = document.getElementById('distributionEditorModal') const distributionEditorModal = document.getElementById('distributionEditorModal')
const distributionEditorHint = document.getElementById('distributionEditorHint') const distributionEditorHint = document.getElementById('distributionEditorHint')
const distributionEditorStatus = document.getElementById('distributionEditorStatus') const distributionEditorStatus = document.getElementById('distributionEditorStatus')
const distributionEditorTextarea = document.getElementById('distributionEditorTextarea') const distributionEditorForm = document.getElementById('distributionEditorForm')
const distributionModuleCount = document.getElementById('distributionModuleCount')
const distributionAdditionalServerCount = document.getElementById('distributionAdditionalServerCount')
const closeDistributionEditorButton = document.getElementById('closeDistributionEditorButton') const closeDistributionEditorButton = document.getElementById('closeDistributionEditorButton')
const loadDistributionTemplateButton = document.getElementById('loadDistributionTemplateButton') const loadDistributionTemplateButton = document.getElementById('loadDistributionTemplateButton')
const saveDistributionFileButton = document.getElementById('saveDistributionFileButton') const saveDistributionFileButton = document.getElementById('saveDistributionFileButton')
@@ -49,6 +54,18 @@ const fieldElements = {
serverWhitelistEnabled: document.getElementById('field-serverWhitelistEnabled') serverWhitelistEnabled: document.getElementById('field-serverWhitelistEnabled')
} }
const distributionFieldElements = {
version: document.getElementById('distribution-field-version'),
rss: document.getElementById('distribution-field-rss'),
serverId: document.getElementById('distribution-field-serverId'),
serverName: document.getElementById('distribution-field-serverName'),
serverDescription: document.getElementById('distribution-field-serverDescription'),
serverVersion: document.getElementById('distribution-field-serverVersion'),
minecraftVersion: document.getElementById('distribution-field-minecraftVersion'),
mainServer: document.getElementById('distribution-field-mainServer'),
autoconnect: document.getElementById('distribution-field-autoconnect')
}
function slugify(value){ function slugify(value){
return String(value ?? '') return String(value ?? '')
.trim() .trim()
@@ -61,6 +78,94 @@ function isRemoteUrl(value){
return /^https?:\/\//i.test(String(value ?? '').trim()) return /^https?:\/\//i.test(String(value ?? '').trim())
} }
function deepClone(value){
return value == null ? value : JSON.parse(JSON.stringify(value))
}
function getDistributionTemplateProfileDefaults(profile){
const safeId = slugify(profile?.id || profile?.name || 'example-profile') || 'example-profile'
return {
version: '1.0.0',
rss: '',
servers: [
{
id: safeId,
name: profile?.name || '새 프로필',
description: profile?.description || '관리자 사이트에서 만든 distribution 템플릿입니다.',
version: '1.0.0',
minecraftVersion: '1.20.1',
mainServer: true,
autoconnect: false,
modules: []
}
]
}
}
function normalizeDistributionDocument(rawDocument, profile){
const baseDocument = deepClone(rawDocument) ?? {}
const defaultDocument = getDistributionTemplateProfileDefaults(profile)
const servers = Array.isArray(baseDocument.servers) && baseDocument.servers.length > 0
? baseDocument.servers
: defaultDocument.servers
const primaryServer = {
...defaultDocument.servers[0],
...(servers[0] ?? {})
}
baseDocument.version = typeof baseDocument.version === 'string' && baseDocument.version.trim().length > 0
? baseDocument.version.trim()
: defaultDocument.version
baseDocument.rss = typeof baseDocument.rss === 'string' ? baseDocument.rss.trim() : ''
baseDocument.servers = [primaryServer, ...servers.slice(1)]
return baseDocument
}
function populateDistributionEditorForm(documentData, profile){
const normalized = normalizeDistributionDocument(documentData, profile)
const primaryServer = normalized.servers[0]
state.distributionEditor.document = normalized
distributionFieldElements.version.value = normalized.version ?? ''
distributionFieldElements.rss.value = normalized.rss ?? ''
distributionFieldElements.serverId.value = primaryServer.id ?? ''
distributionFieldElements.serverName.value = primaryServer.name ?? ''
distributionFieldElements.serverDescription.value = primaryServer.description ?? ''
distributionFieldElements.serverVersion.value = primaryServer.version ?? ''
distributionFieldElements.minecraftVersion.value = primaryServer.minecraftVersion ?? ''
distributionFieldElements.mainServer.checked = primaryServer.mainServer !== false
distributionFieldElements.autoconnect.checked = primaryServer.autoconnect === true
const moduleCount = Array.isArray(primaryServer.modules) ? primaryServer.modules.length : 0
const additionalServerCount = Math.max(0, normalized.servers.length - 1)
distributionModuleCount.textContent = `${moduleCount}`
distributionAdditionalServerCount.textContent = `${additionalServerCount}`
}
function buildDistributionDocumentFromForm(profile){
const baseDocument = normalizeDistributionDocument(state.distributionEditor.document, profile)
const primaryServer = {
...baseDocument.servers[0]
}
baseDocument.version = distributionFieldElements.version.value.trim() || '1.0.0'
baseDocument.rss = distributionFieldElements.rss.value.trim()
primaryServer.id = distributionFieldElements.serverId.value.trim() || slugify(profile?.id || profile?.name || 'example-profile') || 'example-profile'
primaryServer.name = distributionFieldElements.serverName.value.trim() || profile?.name || '새 프로필'
primaryServer.description = distributionFieldElements.serverDescription.value.trim()
primaryServer.version = distributionFieldElements.serverVersion.value.trim() || '1.0.0'
primaryServer.minecraftVersion = distributionFieldElements.minecraftVersion.value.trim() || '1.20.1'
primaryServer.mainServer = distributionFieldElements.mainServer.checked
primaryServer.autoconnect = distributionFieldElements.autoconnect.checked
primaryServer.modules = Array.isArray(primaryServer.modules) ? primaryServer.modules : []
baseDocument.servers = [primaryServer, ...baseDocument.servers.slice(1)]
state.distributionEditor.document = baseDocument
return baseDocument
}
function createProfile(){ function createProfile(){
const timestamp = Date.now() const timestamp = Date.now()
return { return {
@@ -415,7 +520,7 @@ async function loadDistributionTemplate(){
throw new Error(result.message || 'distribution 샘플을 불러오지 못했습니다.') throw new Error(result.message || 'distribution 샘플을 불러오지 못했습니다.')
} }
distributionEditorTextarea.value = result.content return JSON.parse(result.content)
} }
async function loadDistributionContent(requestedPath){ async function loadDistributionContent(requestedPath){
@@ -425,7 +530,7 @@ async function loadDistributionContent(requestedPath){
throw new Error(result.message || 'distribution 파일을 불러오지 못했습니다.') throw new Error(result.message || 'distribution 파일을 불러오지 못했습니다.')
} }
distributionEditorTextarea.value = result.content return JSON.parse(result.content)
} }
async function openDistributionEditor(mode){ async function openDistributionEditor(mode){
@@ -436,7 +541,8 @@ async function openDistributionEditor(mode){
} }
openDistributionEditorModal() openDistributionEditorModal()
distributionEditorTextarea.value = '' distributionEditorForm.reset()
state.distributionEditor.document = null
updateDistributionEditorHint(profile) updateDistributionEditorHint(profile)
showDistributionEditorStatus('distribution 내용을 준비하는 중...', 'info') showDistributionEditorStatus('distribution 내용을 준비하는 중...', 'info')
@@ -444,7 +550,8 @@ async function openDistributionEditor(mode){
const currentPath = String(profile.distributionUrl ?? '').trim() const currentPath = String(profile.distributionUrl ?? '').trim()
if(mode === 'create' || currentPath.length === 0){ if(mode === 'create' || currentPath.length === 0){
await loadDistributionTemplate() const templateDocument = await loadDistributionTemplate()
populateDistributionEditorForm(templateDocument, profile)
updateDistributionEditorHint(profile, '') updateDistributionEditorHint(profile, '')
showStatus('distribution 템플릿을 불러왔습니다.', 'success') showStatus('distribution 템플릿을 불러왔습니다.', 'success')
showDistributionEditorStatus('샘플을 불러왔습니다. 바로 수정한 뒤 저장하면 됩니다.', 'success') showDistributionEditorStatus('샘플을 불러왔습니다. 바로 수정한 뒤 저장하면 됩니다.', 'success')
@@ -452,7 +559,8 @@ async function openDistributionEditor(mode){
} }
if(isRemoteUrl(currentPath)){ if(isRemoteUrl(currentPath)){
await loadDistributionContent(currentPath) const remoteDocument = await loadDistributionContent(currentPath)
populateDistributionEditorForm(remoteDocument, profile)
updateDistributionEditorHint(profile, currentPath) updateDistributionEditorHint(profile, currentPath)
showStatus('원격 distribution 내용을 불러왔습니다.', 'success') showStatus('원격 distribution 내용을 불러왔습니다.', 'success')
showDistributionEditorStatus('원격 distribution 내용을 불러왔습니다. 저장하면 로컬 파일로 복사됩니다.', 'success') showDistributionEditorStatus('원격 distribution 내용을 불러왔습니다. 저장하면 로컬 파일로 복사됩니다.', 'success')
@@ -460,7 +568,8 @@ async function openDistributionEditor(mode){
} }
showStatus('distribution 파일을 불러오는 중...', 'info') showStatus('distribution 파일을 불러오는 중...', 'info')
await loadDistributionContent(currentPath) const localDocument = await loadDistributionContent(currentPath)
populateDistributionEditorForm(localDocument, profile)
updateDistributionEditorHint(profile, currentPath) updateDistributionEditorHint(profile, currentPath)
showStatus('distribution 파일을 불러왔습니다.', 'success') showStatus('distribution 파일을 불러왔습니다.', 'success')
showDistributionEditorStatus('현재 연결된 distribution 파일을 불러왔습니다.', 'success') showDistributionEditorStatus('현재 연결된 distribution 파일을 불러왔습니다.', 'success')
@@ -479,6 +588,7 @@ async function saveDistributionFile(){
} }
try { try {
const distributionDocument = buildDistributionDocumentFromForm(profile)
saveDistributionFileButton.disabled = true saveDistributionFileButton.disabled = true
showStatus('distribution 파일 저장 중...', 'info') showStatus('distribution 파일 저장 중...', 'info')
showDistributionEditorStatus('distribution 파일 저장 중...', 'info') showDistributionEditorStatus('distribution 파일 저장 중...', 'info')
@@ -490,7 +600,7 @@ async function saveDistributionFile(){
}, },
body: JSON.stringify({ body: JSON.stringify({
profileId: profile.id, profileId: profile.id,
content: distributionEditorTextarea.value content: JSON.stringify(distributionDocument, null, 2)
}) })
}) })
const result = await response.json() const result = await response.json()
@@ -515,6 +625,10 @@ async function saveDistributionFile(){
} }
function bindDistributionEditor(){ function bindDistributionEditor(){
distributionEditorForm.addEventListener('submit', (event) => {
event.preventDefault()
})
distributionEditorModal.addEventListener('click', (event) => { distributionEditorModal.addEventListener('click', (event) => {
if(event.target === distributionEditorModal){ if(event.target === distributionEditorModal){
closeDistributionEditorModal() closeDistributionEditorModal()
@@ -538,7 +652,8 @@ window.__launcherAdminCloseDistributionEditor = () => {
window.__launcherAdminLoadDistributionTemplate = async () => { window.__launcherAdminLoadDistributionTemplate = async () => {
try { try {
await loadDistributionTemplate() const templateDocument = await loadDistributionTemplate()
populateDistributionEditorForm(templateDocument, getSelectedProfile())
updateDistributionEditorHint(getSelectedProfile(), '') updateDistributionEditorHint(getSelectedProfile(), '')
showStatus('distribution 템플릿을 다시 불러왔습니다.', 'success') showStatus('distribution 템플릿을 다시 불러왔습니다.', 'success')
showDistributionEditorStatus('샘플을 다시 불러왔습니다.', 'success') showDistributionEditorStatus('샘플을 다시 불러왔습니다.', 'success')

View File

@@ -116,12 +116,12 @@
<div class="uploadField"> <div class="uploadField">
<input id="field-distributionUrl" type="text" autocomplete="off"> <input id="field-distributionUrl" type="text" autocomplete="off">
<button type="button" class="secondaryAction uploadButton" data-upload-target="distributionUrl" data-upload-accept=".json,application/json">JSON 업로드</button> <button type="button" class="secondaryAction uploadButton" data-upload-target="distributionUrl" data-upload-accept=".json,application/json">JSON 업로드</button>
<button type="button" id="editDistributionButton" class="secondaryAction" onclick="window.__launcherAdminOpenDistributionEditor && window.__launcherAdminOpenDistributionEditor('edit')">JSON 편집</button> <button type="button" id="editDistributionButton" class="secondaryAction" onclick="window.__launcherAdminOpenDistributionEditor && window.__launcherAdminOpenDistributionEditor('edit')"> 편집</button>
<button type="button" id="createDistributionButton" class="secondaryAction" onclick="window.__launcherAdminOpenDistributionEditor && window.__launcherAdminOpenDistributionEditor('create')">새로 만들기</button> <button type="button" id="createDistributionButton" class="secondaryAction" onclick="window.__launcherAdminOpenDistributionEditor && window.__launcherAdminOpenDistributionEditor('create')">새로 만들기</button>
</div> </div>
</label> </label>
<div class="fieldHelpText fieldBlockFull"> <div class="fieldHelpText fieldBlockFull">
distribution.json은 여기서 직접 업로드하거나 JSON 편집기로 새로 만들 수 있습니다. distribution.json은 여기서 직접 업로드하거나, 아래 편집기에서 설명을 보며 입력 폼으로 만들 수 있습니다.
</div> </div>
</div> </div>
</section> </section>
@@ -195,12 +195,88 @@
<div> <div>
<span class="eyebrow">Distribution Editor</span> <span class="eyebrow">Distribution Editor</span>
<h3>distribution.json 편집</h3> <h3>distribution.json 편집</h3>
<p id="distributionEditorHint">프로필에 연결할 distribution.json 내용을 사이트 안에서 직접 관리합니다.</p> <p id="distributionEditorHint">프로필에 연결할 distribution.json 내용을 사이트 안에서 입력 폼으로 관리합니다.</p>
</div> </div>
<button type="button" id="closeDistributionEditorButton" class="secondaryAction" onclick="window.__launcherAdminCloseDistributionEditor && window.__launcherAdminCloseDistributionEditor()">닫기</button> <button type="button" id="closeDistributionEditorButton" class="secondaryAction" onclick="window.__launcherAdminCloseDistributionEditor && window.__launcherAdminCloseDistributionEditor()">닫기</button>
</div> </div>
<div id="distributionEditorStatus" class="statusBanner" hidden></div> <div id="distributionEditorStatus" class="statusBanner" hidden></div>
<textarea id="distributionEditorTextarea" class="distributionTextarea" spellcheck="false"></textarea> <div id="distributionEditorSummary" class="fieldSection distributionSummary">
<div class="sectionHeader">
<h3>현재 보존되는 고급 정보</h3>
</div>
<div class="fieldGrid">
<div class="fieldBlock">
<span>모듈 수</span>
<div id="distributionModuleCount" class="distributionSummaryValue">0개</div>
</div>
<div class="fieldBlock">
<span>추가 서버 수</span>
<div id="distributionAdditionalServerCount" class="distributionSummaryValue">0개</div>
</div>
<div class="fieldHelpText fieldBlockFull">
이 편집기는 첫 번째 서버의 기본 정보만 수정합니다. 기존 모듈 목록과 추가 서버 정보는 저장할 때 그대로 보존됩니다.
</div>
</div>
</div>
<form id="distributionEditorForm" class="editorForm distributionEditorForm">
<section class="fieldSection">
<div class="sectionHeader">
<h3>기본 정보</h3>
</div>
<div class="fieldGrid">
<label class="fieldBlock">
<span>distribution 버전</span>
<input id="distribution-field-version" type="text" autocomplete="off" placeholder="1.0.0">
<div class="fieldHelpText">distribution 파일 자체 버전입니다.</div>
</label>
<label class="fieldBlock">
<span>뉴스 RSS 주소</span>
<input id="distribution-field-rss" type="text" autocomplete="off" placeholder="https://example.com/rss.xml">
<div class="fieldHelpText">선택값입니다. 비워두면 뉴스 피드를 사용하지 않습니다.</div>
</label>
</div>
</section>
<section class="fieldSection">
<div class="sectionHeader">
<h3>서버 기본 정보</h3>
</div>
<div class="fieldGrid">
<label class="fieldBlock">
<span>서버 ID</span>
<input id="distribution-field-serverId" type="text" autocomplete="off" placeholder="my-profile">
<div class="fieldHelpText">영문 식별자입니다. 보통 프로필 ID와 비슷하게 맞춥니다.</div>
</label>
<label class="fieldBlock">
<span>서버 이름</span>
<input id="distribution-field-serverName" type="text" autocomplete="off" placeholder="My Profile">
<div class="fieldHelpText">런처 안에 표시될 이름입니다.</div>
</label>
<label class="fieldBlock fieldBlockFull">
<span>서버 설명</span>
<textarea id="distribution-field-serverDescription" rows="4" placeholder="이 distribution이 어떤 클라이언트인지 설명합니다."></textarea>
<div class="fieldHelpText">런처와 설치 흐름에서 사용할 설명입니다.</div>
</label>
<label class="fieldBlock">
<span>배포 버전</span>
<input id="distribution-field-serverVersion" type="text" autocomplete="off" placeholder="1.0.0">
<div class="fieldHelpText">이 프로필의 배포 버전 표기입니다.</div>
</label>
<label class="fieldBlock">
<span>마인크래프트 버전</span>
<input id="distribution-field-minecraftVersion" type="text" autocomplete="off" placeholder="1.20.1">
<div class="fieldHelpText">실제 마크 버전입니다.</div>
</label>
<label class="toggleBlock">
<input id="distribution-field-mainServer" type="checkbox">
<span>기본 서버로 사용</span>
</label>
<label class="toggleBlock">
<input id="distribution-field-autoconnect" type="checkbox">
<span>자동 접속 사용</span>
</label>
</div>
</section>
</form>
<div class="modalActions"> <div class="modalActions">
<button type="button" id="loadDistributionTemplateButton" class="secondaryAction" onclick="window.__launcherAdminLoadDistributionTemplate && window.__launcherAdminLoadDistributionTemplate()">샘플 불러오기</button> <button type="button" id="loadDistributionTemplateButton" class="secondaryAction" onclick="window.__launcherAdminLoadDistributionTemplate && window.__launcherAdminLoadDistributionTemplate()">샘플 불러오기</button>
<button type="button" id="saveDistributionFileButton" class="primaryAction" onclick="window.__launcherAdminSaveDistributionFile && window.__launcherAdminSaveDistributionFile()">distribution 저장</button> <button type="button" id="saveDistributionFileButton" class="primaryAction" onclick="window.__launcherAdminSaveDistributionFile && window.__launcherAdminSaveDistributionFile()">distribution 저장</button>

View File

@@ -376,12 +376,30 @@ textarea {
background: #111412; background: #111412;
box-shadow: var(--shadow); box-shadow: var(--shadow);
pointer-events: auto; pointer-events: auto;
overflow-y: auto;
} }
#distributionEditorStatus { #distributionEditorStatus {
margin-top: -4px; margin-top: -4px;
} }
.distributionSummary {
padding-bottom: 16px;
}
.distributionSummaryValue {
width: 100%;
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 14px;
background: var(--panel-strong);
color: var(--text);
}
.distributionEditorForm {
gap: 16px;
}
.modalHeader { .modalHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -21,7 +21,7 @@ npm run admin
- 프로필 추가 / 수정 / 삭제 / 복제 - 프로필 추가 / 수정 / 삭제 / 복제
- `맵` 기본 + `모드`, `플러그인`, `서버` 체크 조합 - `맵` 기본 + `모드`, `플러그인`, `서버` 체크 조합
- `distribution.json` 업로드 / 새로 만들기 / 직접 편집 - `distribution.json` 업로드 / 새로 만들기 / 입력 폼 편집
- 월드 ZIP 업로드 - 월드 ZIP 업로드
- 서버용 버킷 JAR 업로드 - 서버용 버킷 JAR 업로드
- 서버 포트 / 메모리 / 최대 인원수 / 화이트리스트 설정 - 서버 포트 / 메모리 / 최대 인원수 / 화이트리스트 설정
@@ -32,8 +32,10 @@ npm run admin
## distribution 편집 ## distribution 편집
- `distribution 파일` 칸에서 JSON 업로드 가능 - `distribution 파일` 칸에서 JSON 업로드 가능
- `JSON 편집` 버튼으로 현재 연결된 로컬 `distribution.json` 수정 가능 - ` 편집` 버튼으로 현재 연결된 `distribution.json`을 입력 폼으로 수정 가능
- `새로 만들기` 버튼으로 샘플 템플릿에서 새 `distribution.json` 생성 가능 - `새로 만들기` 버튼으로 샘플 템플릿에서 새 `distribution.json` 생성 가능
- 편집기에서는 버전, RSS, 서버 이름, 마인크래프트 버전 같은 기본 정보를 설명과 함께 입력합니다.
- 기존 모듈 목록과 추가 서버 정보는 저장 시 그대로 보존됩니다.
- 저장된 distribution 파일은 아래에 생성됩니다. - 저장된 distribution 파일은 아래에 생성됩니다.
- `admin/data/distributions/` - `admin/data/distributions/`