Add admin distribution editor
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-05 19:16:09 +09:00
parent c4cdd0ceba
commit e266387784
11 changed files with 417 additions and 1652 deletions

View File

@@ -21,6 +21,14 @@ const duplicateProfileButton = document.getElementById('duplicateProfileButton')
const deleteProfileButton = document.getElementById('deleteProfileButton')
const mapSection = document.getElementById('mapSection')
const serverPackSection = document.getElementById('serverPackSection')
const editDistributionButton = document.getElementById('editDistributionButton')
const createDistributionButton = document.getElementById('createDistributionButton')
const distributionEditorModal = document.getElementById('distributionEditorModal')
const distributionEditorHint = document.getElementById('distributionEditorHint')
const distributionEditorTextarea = document.getElementById('distributionEditorTextarea')
const closeDistributionEditorButton = document.getElementById('closeDistributionEditorButton')
const loadDistributionTemplateButton = document.getElementById('loadDistributionTemplateButton')
const saveDistributionFileButton = document.getElementById('saveDistributionFileButton')
const fieldElements = {
id: document.getElementById('field-id'),
@@ -29,8 +37,6 @@ const fieldElements = {
description: document.getElementById('field-description'),
details: document.getElementById('field-details'),
distributionUrl: document.getElementById('field-distributionUrl'),
defaultServerAddress: document.getElementById('field-defaultServerAddress'),
allowCustomServerAddress: document.getElementById('field-allowCustomServerAddress'),
worldArchiveUrl: document.getElementById('field-worldArchiveUrl'),
worldDirectoryName: document.getElementById('field-worldDirectoryName'),
serverBundleUrl: document.getElementById('field-serverBundleUrl'),
@@ -50,6 +56,10 @@ function slugify(value){
.replace(/^-+|-+$/g, '')
}
function isRemoteUrl(value){
return /^https?:\/\//i.test(String(value ?? '').trim())
}
function createProfile(kind){
const timestamp = Date.now()
return {
@@ -59,8 +69,6 @@ function createProfile(kind){
description: '',
details: '',
distributionUrl: '',
defaultServerAddress: '',
allowCustomServerAddress: kind !== 'map',
worldArchiveUrl: '',
worldDirectoryName: '',
serverBundleUrl: '',
@@ -143,6 +151,27 @@ function syncKindSections(kind){
serverPackSection.hidden = kind !== 'server-pack'
}
function updateDistributionEditorHint(profile, pathOverride){
if(!profile){
distributionEditorHint.textContent = '프로필에 연결할 distribution.json 내용을 사이트 안에서 직접 관리합니다.'
return
}
const currentPath = String(pathOverride ?? profile.distributionUrl ?? '').trim()
if(currentPath.length === 0){
distributionEditorHint.textContent = '연결된 distribution 파일이 없습니다. 샘플을 불러오거나 새로 저장해서 이 프로필에 연결하세요.'
return
}
if(isRemoteUrl(currentPath)){
distributionEditorHint.textContent = `현재 값은 원격 URL입니다: ${currentPath} | 원격 URL은 여기서 직접 수정할 수 없습니다. 샘플을 불러온 뒤 새 파일로 저장하세요.`
return
}
distributionEditorHint.textContent = `현재 연결된 distribution 파일: ${currentPath}`
}
function populateEditor(){
const profile = getSelectedProfile()
const hasSelection = profile != null
@@ -154,6 +183,7 @@ function populateEditor(){
if(!profile){
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
updateDistributionEditorHint(null)
return
}
@@ -165,8 +195,6 @@ function populateEditor(){
fieldElements.description.value = profile.description ?? ''
fieldElements.details.value = profile.details ?? ''
fieldElements.distributionUrl.value = profile.distributionUrl ?? ''
fieldElements.defaultServerAddress.value = profile.defaultServerAddress ?? ''
fieldElements.allowCustomServerAddress.checked = profile.allowCustomServerAddress === true
fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? ''
fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? ''
fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? ''
@@ -178,6 +206,7 @@ function populateEditor(){
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
syncKindSections(profile.kind)
updateDistributionEditorHint(profile)
}
function updateSelectedProfile(patch){
@@ -185,9 +214,14 @@ function updateSelectedProfile(patch){
if(!profile){
return
}
Object.assign(profile, patch)
markDirty(true)
renderSidebar()
if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){
updateDistributionEditorHint(profile, patch.distributionUrl)
}
}
function bindTextField(fieldName){
@@ -214,14 +248,6 @@ function bindTextField(fieldName){
})
}
function bindCheckboxField(fieldName){
fieldElements[fieldName].addEventListener('change', (event) => {
updateSelectedProfile({
[fieldName]: event.target.checked
})
})
}
function bindNumberField(fieldName){
fieldElements[fieldName].addEventListener('input', (event) => {
const value = Number.parseInt(event.target.value || '25565', 10)
@@ -232,13 +258,15 @@ function bindNumberField(fieldName){
}
function bindProfileForm(){
profileEditorForm.addEventListener('submit', (event) => {
event.preventDefault()
})
bindTextField('id')
bindTextField('name')
bindTextField('description')
bindTextField('details')
bindTextField('distributionUrl')
bindTextField('defaultServerAddress')
bindCheckboxField('allowCustomServerAddress')
bindTextField('worldArchiveUrl')
bindTextField('worldDirectoryName')
bindTextField('serverBundleUrl')
@@ -257,10 +285,7 @@ function bindProfileForm(){
profile.kind = event.target.value
if(profile.kind === 'map'){
profile.allowCustomServerAddress = false
}
if(profile.kind !== 'server-pack' && !profile.serverDirectoryName){
if(profile.kind === 'server-pack' && !profile.serverDirectoryName){
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
}
@@ -312,6 +337,11 @@ async function uploadIntoField(targetField, accept){
fieldElements[targetField].value = result.file.path
markDirty(true)
renderSidebar()
if(targetField === 'distributionUrl'){
updateDistributionEditorHint(profile, result.file.path)
}
showStatus(`업로드 완료: ${result.file.path}`, 'success')
} catch (error) {
console.error(error)
@@ -322,6 +352,152 @@ async function uploadIntoField(targetField, accept){
picker.click()
}
function openDistributionEditorModal(){
distributionEditorModal.hidden = false
document.body.style.overflow = 'hidden'
}
function closeDistributionEditorModal(){
distributionEditorModal.hidden = true
document.body.style.overflow = ''
}
async function loadDistributionTemplate(){
const response = await fetch('/api/distribution/template')
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || 'distribution 샘플을 불러오지 못했습니다.')
}
distributionEditorTextarea.value = result.content
}
async function openDistributionEditor(mode){
const profile = getSelectedProfile()
if(!profile){
showStatus('먼저 프로필을 선택하세요.', 'error')
return
}
openDistributionEditorModal()
distributionEditorTextarea.value = ''
updateDistributionEditorHint(profile)
try {
const currentPath = String(profile.distributionUrl ?? '').trim()
if(mode === 'create' || currentPath.length === 0){
await loadDistributionTemplate()
updateDistributionEditorHint(profile, '')
showStatus('distribution 템플릿을 불러왔습니다.', 'success')
return
}
if(isRemoteUrl(currentPath)){
await loadDistributionTemplate()
updateDistributionEditorHint(profile, currentPath)
showStatus('원격 URL은 직접 수정할 수 없어 샘플을 대신 불러왔습니다.', 'info')
return
}
showStatus('distribution 파일을 불러오는 중...', 'info')
const response = await fetch(`/api/distribution/content?path=${encodeURIComponent(currentPath)}`)
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || 'distribution 파일을 불러오지 못했습니다.')
}
distributionEditorTextarea.value = result.content
updateDistributionEditorHint(profile, currentPath)
showStatus('distribution 파일을 불러왔습니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : 'distribution 파일을 불러오지 못했습니다.', 'error')
}
}
async function saveDistributionFile(){
const profile = getSelectedProfile()
if(!profile){
showStatus('먼저 프로필을 선택하세요.', 'error')
return
}
try {
saveDistributionFileButton.disabled = true
showStatus('distribution 파일 저장 중...', 'info')
const response = await fetch('/api/distribution/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
profileId: profile.id,
content: distributionEditorTextarea.value
})
})
const result = await response.json()
if(!response.ok || result.ok !== true){
throw new Error(result.message || 'distribution 파일 저장에 실패했습니다.')
}
profile.distributionUrl = result.file.path
fieldElements.distributionUrl.value = result.file.path
markDirty(true)
renderSidebar()
populateEditor()
closeDistributionEditorModal()
showStatus('distribution 파일을 저장했습니다. 카탈로그 저장을 누르면 런처에 반영됩니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : 'distribution 파일 저장에 실패했습니다.', 'error')
} finally {
saveDistributionFileButton.disabled = false
}
}
function bindDistributionEditor(){
editDistributionButton.addEventListener('click', async () => {
await openDistributionEditor('edit')
})
createDistributionButton.addEventListener('click', async () => {
await openDistributionEditor('create')
})
closeDistributionEditorButton.addEventListener('click', () => {
closeDistributionEditorModal()
})
loadDistributionTemplateButton.addEventListener('click', async () => {
try {
await loadDistributionTemplate()
updateDistributionEditorHint(getSelectedProfile(), '')
showStatus('distribution 템플릿을 다시 불러왔습니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : 'distribution 템플릿을 불러오지 못했습니다.', 'error')
}
})
saveDistributionFileButton.addEventListener('click', async () => {
await saveDistributionFile()
})
distributionEditorModal.addEventListener('click', (event) => {
if(event.target === distributionEditorModal){
closeDistributionEditorModal()
}
})
document.addEventListener('keydown', (event) => {
if(event.key === 'Escape' && distributionEditorModal.hidden === false){
closeDistributionEditorModal()
}
})
}
async function loadMeta(){
const response = await fetch('/api/meta')
const meta = await response.json()
@@ -450,6 +626,7 @@ function bindTopLevelActions(){
async function bootstrap(){
bindProfileForm()
bindDistributionEditor()
bindTopLevelActions()
try {