Refactor launcher profiles and port automation
This commit is contained in:
@@ -19,8 +19,8 @@ const editorHint = document.getElementById('editorHint')
|
||||
const saveCatalogButton = document.getElementById('saveCatalogButton')
|
||||
const duplicateProfileButton = document.getElementById('duplicateProfileButton')
|
||||
const deleteProfileButton = document.getElementById('deleteProfileButton')
|
||||
const mapSection = document.getElementById('mapSection')
|
||||
const serverPackSection = document.getElementById('serverPackSection')
|
||||
const addProfileButton = document.getElementById('addProfileButton')
|
||||
const serverSection = document.getElementById('serverSection')
|
||||
const editDistributionButton = document.getElementById('editDistributionButton')
|
||||
const createDistributionButton = document.getElementById('createDistributionButton')
|
||||
const distributionEditorModal = document.getElementById('distributionEditorModal')
|
||||
@@ -32,20 +32,20 @@ const saveDistributionFileButton = document.getElementById('saveDistributionFile
|
||||
|
||||
const fieldElements = {
|
||||
id: document.getElementById('field-id'),
|
||||
kind: document.getElementById('field-kind'),
|
||||
name: document.getElementById('field-name'),
|
||||
description: document.getElementById('field-description'),
|
||||
details: document.getElementById('field-details'),
|
||||
modsEnabled: document.getElementById('field-modsEnabled'),
|
||||
pluginsEnabled: document.getElementById('field-pluginsEnabled'),
|
||||
serverEnabled: document.getElementById('field-serverEnabled'),
|
||||
distributionUrl: document.getElementById('field-distributionUrl'),
|
||||
worldArchiveUrl: document.getElementById('field-worldArchiveUrl'),
|
||||
worldDirectoryName: document.getElementById('field-worldDirectoryName'),
|
||||
serverBundleUrl: document.getElementById('field-serverBundleUrl'),
|
||||
serverDirectoryName: document.getElementById('field-serverDirectoryName'),
|
||||
serverLaunchCommand: document.getElementById('field-serverLaunchCommand'),
|
||||
serverWorkingDirectory: document.getElementById('field-serverWorkingDirectory'),
|
||||
serverJarUrl: document.getElementById('field-serverJarUrl'),
|
||||
serverPort: document.getElementById('field-serverPort'),
|
||||
tunnelCommand: document.getElementById('field-tunnelCommand'),
|
||||
tunnelAddressRegex: document.getElementById('field-tunnelAddressRegex')
|
||||
serverMemoryMb: document.getElementById('field-serverMemoryMb'),
|
||||
serverMaxPlayers: document.getElementById('field-serverMaxPlayers'),
|
||||
serverWhitelistEnabled: document.getElementById('field-serverWhitelistEnabled')
|
||||
}
|
||||
|
||||
function slugify(value){
|
||||
@@ -60,24 +60,24 @@ function isRemoteUrl(value){
|
||||
return /^https?:\/\//i.test(String(value ?? '').trim())
|
||||
}
|
||||
|
||||
function createProfile(kind){
|
||||
function createProfile(){
|
||||
const timestamp = Date.now()
|
||||
return {
|
||||
id: `${kind}-${timestamp}`,
|
||||
name: kind === 'map' ? '새 맵 프로필' : kind === 'server-pack' ? '새 서버팩 프로필' : '새 모드팩 프로필',
|
||||
kind,
|
||||
id: `profile-${timestamp}`,
|
||||
name: '새 프로필',
|
||||
description: '',
|
||||
details: '',
|
||||
modsEnabled: false,
|
||||
pluginsEnabled: false,
|
||||
serverEnabled: false,
|
||||
distributionUrl: '',
|
||||
worldArchiveUrl: '',
|
||||
worldDirectoryName: '',
|
||||
serverBundleUrl: '',
|
||||
serverDirectoryName: `${kind}-${timestamp}-server`,
|
||||
serverLaunchCommand: '',
|
||||
serverWorkingDirectory: '',
|
||||
serverJarUrl: '',
|
||||
serverPort: 25565,
|
||||
tunnelCommand: '',
|
||||
tunnelAddressRegex: ''
|
||||
serverMemoryMb: 4096,
|
||||
serverMaxPlayers: 20,
|
||||
serverWhitelistEnabled: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,20 @@ function selectProfile(profileId){
|
||||
populateEditor()
|
||||
}
|
||||
|
||||
function describeProfileFeatures(profile){
|
||||
const badges = ['맵']
|
||||
if(profile.modsEnabled){
|
||||
badges.push('모드')
|
||||
}
|
||||
if(profile.pluginsEnabled){
|
||||
badges.push('플러그인')
|
||||
}
|
||||
if(profile.serverEnabled){
|
||||
badges.push('서버')
|
||||
}
|
||||
return badges
|
||||
}
|
||||
|
||||
function renderSidebar(){
|
||||
profileList.innerHTML = ''
|
||||
profileCount.textContent = `${state.catalog.profiles.length}개`
|
||||
@@ -126,10 +140,9 @@ function renderSidebar(){
|
||||
|
||||
const meta = document.createElement('div')
|
||||
meta.className = 'profileListMeta'
|
||||
meta.innerHTML = `
|
||||
<span class="badge">${profile.kind}</span>
|
||||
${profile.distributionUrl ? '<span class="badge">distribution 연결</span>' : '<span class="badge">distribution 비어있음</span>'}
|
||||
`
|
||||
meta.innerHTML = describeProfileFeatures(profile)
|
||||
.map((label) => `<span class="badge">${label}</span>`)
|
||||
.join('')
|
||||
|
||||
const description = document.createElement('div')
|
||||
description.className = 'profileListDescription'
|
||||
@@ -146,9 +159,18 @@ function renderSidebar(){
|
||||
}
|
||||
}
|
||||
|
||||
function syncKindSections(kind){
|
||||
mapSection.hidden = kind !== 'map'
|
||||
serverPackSection.hidden = kind !== 'server-pack'
|
||||
function syncFeatureDependencies(profile, showMessage = false){
|
||||
if(profile.pluginsEnabled && !profile.serverEnabled){
|
||||
profile.serverEnabled = true
|
||||
if(showMessage){
|
||||
showStatus('플러그인 사용을 켜면 서버 사용도 자동으로 같이 켜집니다.', 'info')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncServerSection(profile){
|
||||
serverSection.hidden = !profile?.serverEnabled
|
||||
fieldElements.serverEnabled.disabled = profile?.pluginsEnabled === true
|
||||
}
|
||||
|
||||
function updateDistributionEditorHint(profile, pathOverride){
|
||||
@@ -183,29 +205,32 @@ function populateEditor(){
|
||||
|
||||
if(!profile){
|
||||
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
|
||||
syncServerSection(null)
|
||||
updateDistributionEditorHint(null)
|
||||
return
|
||||
}
|
||||
|
||||
editorHint.textContent = `${profile.kind} 프로필을 편집 중입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.`
|
||||
syncFeatureDependencies(profile)
|
||||
|
||||
editorHint.textContent = '맵은 기본이고, 모드/플러그인/서버를 체크해서 조합하는 프로필입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.'
|
||||
|
||||
fieldElements.id.value = profile.id ?? ''
|
||||
fieldElements.kind.value = profile.kind ?? 'modpack'
|
||||
fieldElements.name.value = profile.name ?? ''
|
||||
fieldElements.description.value = profile.description ?? ''
|
||||
fieldElements.details.value = profile.details ?? ''
|
||||
fieldElements.modsEnabled.checked = profile.modsEnabled === true
|
||||
fieldElements.pluginsEnabled.checked = profile.pluginsEnabled === true
|
||||
fieldElements.serverEnabled.checked = profile.serverEnabled === true
|
||||
fieldElements.distributionUrl.value = profile.distributionUrl ?? ''
|
||||
fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? ''
|
||||
fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? ''
|
||||
fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? ''
|
||||
fieldElements.serverDirectoryName.value = profile.serverDirectoryName ?? ''
|
||||
fieldElements.serverLaunchCommand.value = profile.serverLaunchCommand ?? ''
|
||||
fieldElements.serverWorkingDirectory.value = profile.serverWorkingDirectory ?? ''
|
||||
fieldElements.serverJarUrl.value = profile.serverJarUrl ?? ''
|
||||
fieldElements.serverPort.value = profile.serverPort ?? 25565
|
||||
fieldElements.tunnelCommand.value = profile.tunnelCommand ?? ''
|
||||
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
|
||||
fieldElements.serverMemoryMb.value = profile.serverMemoryMb ?? 4096
|
||||
fieldElements.serverMaxPlayers.value = profile.serverMaxPlayers ?? 20
|
||||
fieldElements.serverWhitelistEnabled.checked = profile.serverWhitelistEnabled === true
|
||||
|
||||
syncKindSections(profile.kind)
|
||||
syncServerSection(profile)
|
||||
updateDistributionEditorHint(profile)
|
||||
}
|
||||
|
||||
@@ -216,8 +241,10 @@ function updateSelectedProfile(patch){
|
||||
}
|
||||
|
||||
Object.assign(profile, patch)
|
||||
syncFeatureDependencies(profile)
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
syncServerSection(profile)
|
||||
|
||||
if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){
|
||||
updateDistributionEditorHint(profile, patch.distributionUrl)
|
||||
@@ -248,15 +275,27 @@ function bindTextField(fieldName){
|
||||
})
|
||||
}
|
||||
|
||||
function bindNumberField(fieldName){
|
||||
function bindNumberField(fieldName, fallback){
|
||||
fieldElements[fieldName].addEventListener('input', (event) => {
|
||||
const value = Number.parseInt(event.target.value || '25565', 10)
|
||||
const value = Number.parseInt(event.target.value || String(fallback), 10)
|
||||
updateSelectedProfile({
|
||||
[fieldName]: Number.isFinite(value) ? value : 25565
|
||||
[fieldName]: Number.isFinite(value) ? value : fallback
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function bindCheckboxField(fieldName){
|
||||
fieldElements[fieldName].addEventListener('change', (event) => {
|
||||
updateSelectedProfile({
|
||||
[fieldName]: event.target.checked
|
||||
})
|
||||
|
||||
if(fieldName === 'pluginsEnabled' || fieldName === 'serverEnabled'){
|
||||
populateEditor()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function bindProfileForm(){
|
||||
profileEditorForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
@@ -269,30 +308,14 @@ function bindProfileForm(){
|
||||
bindTextField('distributionUrl')
|
||||
bindTextField('worldArchiveUrl')
|
||||
bindTextField('worldDirectoryName')
|
||||
bindTextField('serverBundleUrl')
|
||||
bindTextField('serverDirectoryName')
|
||||
bindTextField('serverLaunchCommand')
|
||||
bindTextField('serverWorkingDirectory')
|
||||
bindNumberField('serverPort')
|
||||
bindTextField('tunnelCommand')
|
||||
bindTextField('tunnelAddressRegex')
|
||||
|
||||
fieldElements.kind.addEventListener('change', (event) => {
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
|
||||
profile.kind = event.target.value
|
||||
|
||||
if(profile.kind === 'server-pack' && !profile.serverDirectoryName){
|
||||
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
|
||||
}
|
||||
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
populateEditor()
|
||||
})
|
||||
bindTextField('serverJarUrl')
|
||||
bindNumberField('serverPort', 25565)
|
||||
bindNumberField('serverMemoryMb', 4096)
|
||||
bindNumberField('serverMaxPlayers', 20)
|
||||
bindCheckboxField('modsEnabled')
|
||||
bindCheckboxField('pluginsEnabled')
|
||||
bindCheckboxField('serverEnabled')
|
||||
bindCheckboxField('serverWhitelistEnabled')
|
||||
|
||||
for(const button of document.querySelectorAll('.uploadButton')){
|
||||
button.addEventListener('click', async () => {
|
||||
@@ -507,12 +530,23 @@ async function loadMeta(){
|
||||
localCatalogUrl.href = meta.localCatalogUrl
|
||||
}
|
||||
|
||||
function normalizeLoadedProfile(profile){
|
||||
return {
|
||||
...createProfile(),
|
||||
...profile,
|
||||
modsEnabled: profile.modsEnabled === true,
|
||||
pluginsEnabled: profile.pluginsEnabled === true,
|
||||
serverEnabled: profile.serverEnabled === true || profile.pluginsEnabled === true,
|
||||
serverWhitelistEnabled: profile.serverWhitelistEnabled === true
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(){
|
||||
const response = await fetch('/api/catalog')
|
||||
const catalog = await response.json()
|
||||
state.catalog = {
|
||||
version: 1,
|
||||
profiles: Array.isArray(catalog.profiles) ? catalog.profiles : []
|
||||
profiles: Array.isArray(catalog.profiles) ? catalog.profiles.map(normalizeLoadedProfile) : []
|
||||
}
|
||||
|
||||
if(state.catalog.profiles.length > 0){
|
||||
@@ -526,8 +560,8 @@ async function loadCatalog(){
|
||||
populateEditor()
|
||||
}
|
||||
|
||||
function addProfile(kind){
|
||||
const profile = createProfile(kind)
|
||||
function addProfile(){
|
||||
const profile = createProfile()
|
||||
state.catalog.profiles.push(profile)
|
||||
markDirty(true)
|
||||
selectProfile(profile.id)
|
||||
@@ -587,7 +621,10 @@ async function saveCatalog(){
|
||||
throw new Error(result.message || '카탈로그 저장에 실패했습니다.')
|
||||
}
|
||||
|
||||
state.catalog = result.catalog
|
||||
state.catalog = {
|
||||
version: 1,
|
||||
profiles: result.catalog.profiles.map(normalizeLoadedProfile)
|
||||
}
|
||||
if(!state.catalog.profiles.some((profile) => profile.id === state.selectedProfileId)){
|
||||
state.selectedProfileId = state.catalog.profiles[0]?.id ?? null
|
||||
}
|
||||
@@ -605,11 +642,9 @@ async function saveCatalog(){
|
||||
}
|
||||
|
||||
function bindTopLevelActions(){
|
||||
for(const button of document.querySelectorAll('[data-add-kind]')){
|
||||
button.addEventListener('click', () => {
|
||||
addProfile(button.dataset.addKind)
|
||||
})
|
||||
}
|
||||
addProfileButton.addEventListener('click', () => {
|
||||
addProfile()
|
||||
})
|
||||
|
||||
saveCatalogButton.addEventListener('click', async () => {
|
||||
await saveCatalog()
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="brandBlock">
|
||||
<span class="eyebrow">Launcher Admin</span>
|
||||
<h1>프로필 관리</h1>
|
||||
<p>설치 페이지에 표시할 실제 프로필을 UI로 관리합니다.</p>
|
||||
<p>설치 페이지에 표시할 실제 프로필을 조합형 UI로 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="metaPanel">
|
||||
@@ -27,9 +27,7 @@
|
||||
</div>
|
||||
|
||||
<div class="addButtons">
|
||||
<button type="button" class="primaryAction" data-add-kind="modpack">모드팩 추가</button>
|
||||
<button type="button" class="secondaryAction" data-add-kind="map">맵 추가</button>
|
||||
<button type="button" class="secondaryAction" data-add-kind="server-pack">서버팩 추가</button>
|
||||
<button type="button" class="primaryAction" id="addProfileButton">프로필 추가</button>
|
||||
</div>
|
||||
|
||||
<div class="profileListHeader">
|
||||
@@ -57,7 +55,7 @@
|
||||
|
||||
<div id="emptyState" class="emptyState">
|
||||
<h3>프로필이 없습니다</h3>
|
||||
<p>왼쪽 버튼으로 새 모드팩, 맵, 서버팩 프로필을 추가하세요.</p>
|
||||
<p>왼쪽 버튼으로 새 프로필을 추가하세요. 맵은 기본이고, 모드/플러그인/서버는 체크해서 조합합니다.</p>
|
||||
</div>
|
||||
|
||||
<form id="profileEditorForm" class="editorForm" hidden>
|
||||
@@ -70,14 +68,6 @@
|
||||
<span>프로필 ID</span>
|
||||
<input id="field-id" type="text" autocomplete="off">
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>종류</span>
|
||||
<select id="field-kind">
|
||||
<option value="modpack">modpack</option>
|
||||
<option value="map">map</option>
|
||||
<option value="server-pack">server-pack</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>표시 이름</span>
|
||||
<input id="field-name" type="text" autocomplete="off">
|
||||
@@ -93,6 +83,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fieldSection">
|
||||
<div class="sectionHeader">
|
||||
<h3>구성 옵션</h3>
|
||||
</div>
|
||||
<div class="fieldGrid">
|
||||
<label class="toggleBlock">
|
||||
<input id="field-modsEnabled" type="checkbox">
|
||||
<span>모드 사용</span>
|
||||
</label>
|
||||
<label class="toggleBlock">
|
||||
<input id="field-pluginsEnabled" type="checkbox">
|
||||
<span>플러그인 사용</span>
|
||||
</label>
|
||||
<label class="toggleBlock">
|
||||
<input id="field-serverEnabled" type="checkbox">
|
||||
<span>서버 사용</span>
|
||||
</label>
|
||||
<div class="fieldHelpText fieldBlockFull">
|
||||
맵은 모든 프로필의 기본입니다. 플러그인을 켜면 서버 사용도 자동으로 같이 켜집니다.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fieldSection">
|
||||
<div class="sectionHeader">
|
||||
<h3>클라이언트 배포</h3>
|
||||
@@ -113,7 +126,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="mapSection" class="fieldSection" hidden>
|
||||
<section class="fieldSection">
|
||||
<div class="sectionHeader">
|
||||
<h3>맵 자료</h3>
|
||||
</div>
|
||||
@@ -132,42 +145,37 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="serverPackSection" class="fieldSection" hidden>
|
||||
<section id="serverSection" class="fieldSection" hidden>
|
||||
<div class="sectionHeader">
|
||||
<h3>서버팩 자료</h3>
|
||||
<h3>서버 자료 / 설정</h3>
|
||||
</div>
|
||||
<div class="fieldGrid">
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>서버 번들 ZIP 경로 또는 URL</span>
|
||||
<span>버킷 JAR 경로 또는 URL</span>
|
||||
<div class="uploadField">
|
||||
<input id="field-serverBundleUrl" type="text" autocomplete="off">
|
||||
<button type="button" class="secondaryAction uploadButton" data-upload-target="serverBundleUrl" data-upload-accept=".zip,application/zip">파일 업로드</button>
|
||||
<input id="field-serverJarUrl" type="text" autocomplete="off">
|
||||
<button type="button" class="secondaryAction uploadButton" data-upload-target="serverJarUrl" data-upload-accept=".jar,application/java-archive">JAR 업로드</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>서버 폴더 이름</span>
|
||||
<input id="field-serverDirectoryName" type="text" autocomplete="off">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>서버 실행 명령</span>
|
||||
<input id="field-serverLaunchCommand" type="text" autocomplete="off" placeholder="java -jar server.jar nogui">
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>작업 디렉터리</span>
|
||||
<input id="field-serverWorkingDirectory" type="text" autocomplete="off" placeholder="빈 값이면 서버 루트">
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>서버 포트</span>
|
||||
<input id="field-serverPort" type="number" min="1" max="65535" step="1">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>터널 명령</span>
|
||||
<input id="field-tunnelCommand" type="text" autocomplete="off" placeholder="예: playit-cli --port ${port}">
|
||||
<label class="fieldBlock">
|
||||
<span>서버 메모리 (MB)</span>
|
||||
<input id="field-serverMemoryMb" type="number" min="512" step="256">
|
||||
</label>
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>터널 주소 추출 정규식</span>
|
||||
<input id="field-tunnelAddressRegex" type="text" autocomplete="off" placeholder="예: ([a-zA-Z0-9.-]+:\\d+)">
|
||||
<label class="fieldBlock">
|
||||
<span>최대 인원수</span>
|
||||
<input id="field-serverMaxPlayers" type="number" min="1" max="200" step="1">
|
||||
</label>
|
||||
<label class="toggleBlock">
|
||||
<input id="field-serverWhitelistEnabled" type="checkbox">
|
||||
<span>화이트리스트 사용</span>
|
||||
</label>
|
||||
<div class="fieldHelpText fieldBlockFull">
|
||||
직접 실행 시 런처가 `eula.txt`, `server.properties`를 자동으로 만들고 업로드한 버킷 JAR을 실행합니다.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -175,7 +183,7 @@
|
||||
<div class="sectionHeader">
|
||||
<h3>운영 메모</h3>
|
||||
</div>
|
||||
<p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 로컬 테스트에는 바로 쓸 수 있고, 외부 배포용으로는 URL로 바꿔도 됩니다.</p>
|
||||
<p>업로드 버튼은 현재 프로젝트 기준 상대 경로를 자동으로 채웁니다. 접속주소는 관리자 사이트가 아니라, 사용자가 라이브러리에서 직접 넣는 구조입니다.</p>
|
||||
</section>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user