Add admin distribution editor
This commit is contained in:
@@ -80,6 +80,7 @@ npm run admin
|
|||||||
```
|
```
|
||||||
|
|
||||||
설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다.
|
설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다.
|
||||||
|
- `distribution.json`도 사이트 안에서 직접 만들고 수정할 수 있습니다.
|
||||||
|
|
||||||
- 문서: [docs/admin-site.md](docs/admin-site.md)
|
- 문서: [docs/admin-site.md](docs/admin-site.md)
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
"name": "Concatenation Lite",
|
"name": "Concatenation Lite",
|
||||||
"kind": "modpack",
|
"kind": "modpack",
|
||||||
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
|
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
|
||||||
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
|
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution 파일만 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
|
||||||
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json",
|
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json"
|
||||||
"defaultServerAddress": "play.mysticred.space",
|
|
||||||
"allowCustomServerAddress": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "template-original-map",
|
"id": "template-original-map",
|
||||||
@@ -18,8 +16,6 @@
|
|||||||
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
|
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
|
||||||
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
|
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
|
||||||
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
||||||
"defaultServerAddress": "",
|
|
||||||
"allowCustomServerAddress": false,
|
|
||||||
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
|
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
|
||||||
"worldDirectoryName": "Original Map"
|
"worldDirectoryName": "Original Map"
|
||||||
},
|
},
|
||||||
@@ -30,8 +26,6 @@
|
|||||||
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
|
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
|
||||||
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
|
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
|
||||||
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
||||||
"defaultServerAddress": "",
|
|
||||||
"allowCustomServerAddress": true,
|
|
||||||
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
|
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
|
||||||
"serverDirectoryName": "plugin-world-server",
|
"serverDirectoryName": "plugin-world-server",
|
||||||
"serverLaunchCommand": "java -jar server.jar nogui",
|
"serverLaunchCommand": "java -jar server.jar nogui",
|
||||||
|
|||||||
1
admin/data/distributions/.gitkeep
Normal file
1
admin/data/distributions/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -21,6 +21,14 @@ const duplicateProfileButton = document.getElementById('duplicateProfileButton')
|
|||||||
const deleteProfileButton = document.getElementById('deleteProfileButton')
|
const deleteProfileButton = document.getElementById('deleteProfileButton')
|
||||||
const mapSection = document.getElementById('mapSection')
|
const mapSection = document.getElementById('mapSection')
|
||||||
const serverPackSection = document.getElementById('serverPackSection')
|
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 = {
|
const fieldElements = {
|
||||||
id: document.getElementById('field-id'),
|
id: document.getElementById('field-id'),
|
||||||
@@ -29,8 +37,6 @@ const fieldElements = {
|
|||||||
description: document.getElementById('field-description'),
|
description: document.getElementById('field-description'),
|
||||||
details: document.getElementById('field-details'),
|
details: document.getElementById('field-details'),
|
||||||
distributionUrl: document.getElementById('field-distributionUrl'),
|
distributionUrl: document.getElementById('field-distributionUrl'),
|
||||||
defaultServerAddress: document.getElementById('field-defaultServerAddress'),
|
|
||||||
allowCustomServerAddress: document.getElementById('field-allowCustomServerAddress'),
|
|
||||||
worldArchiveUrl: document.getElementById('field-worldArchiveUrl'),
|
worldArchiveUrl: document.getElementById('field-worldArchiveUrl'),
|
||||||
worldDirectoryName: document.getElementById('field-worldDirectoryName'),
|
worldDirectoryName: document.getElementById('field-worldDirectoryName'),
|
||||||
serverBundleUrl: document.getElementById('field-serverBundleUrl'),
|
serverBundleUrl: document.getElementById('field-serverBundleUrl'),
|
||||||
@@ -50,6 +56,10 @@ function slugify(value){
|
|||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRemoteUrl(value){
|
||||||
|
return /^https?:\/\//i.test(String(value ?? '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
function createProfile(kind){
|
function createProfile(kind){
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
return {
|
return {
|
||||||
@@ -59,8 +69,6 @@ function createProfile(kind){
|
|||||||
description: '',
|
description: '',
|
||||||
details: '',
|
details: '',
|
||||||
distributionUrl: '',
|
distributionUrl: '',
|
||||||
defaultServerAddress: '',
|
|
||||||
allowCustomServerAddress: kind !== 'map',
|
|
||||||
worldArchiveUrl: '',
|
worldArchiveUrl: '',
|
||||||
worldDirectoryName: '',
|
worldDirectoryName: '',
|
||||||
serverBundleUrl: '',
|
serverBundleUrl: '',
|
||||||
@@ -143,6 +151,27 @@ function syncKindSections(kind){
|
|||||||
serverPackSection.hidden = kind !== 'server-pack'
|
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(){
|
function populateEditor(){
|
||||||
const profile = getSelectedProfile()
|
const profile = getSelectedProfile()
|
||||||
const hasSelection = profile != null
|
const hasSelection = profile != null
|
||||||
@@ -154,6 +183,7 @@ function populateEditor(){
|
|||||||
|
|
||||||
if(!profile){
|
if(!profile){
|
||||||
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
|
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
|
||||||
|
updateDistributionEditorHint(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +195,6 @@ function populateEditor(){
|
|||||||
fieldElements.description.value = profile.description ?? ''
|
fieldElements.description.value = profile.description ?? ''
|
||||||
fieldElements.details.value = profile.details ?? ''
|
fieldElements.details.value = profile.details ?? ''
|
||||||
fieldElements.distributionUrl.value = profile.distributionUrl ?? ''
|
fieldElements.distributionUrl.value = profile.distributionUrl ?? ''
|
||||||
fieldElements.defaultServerAddress.value = profile.defaultServerAddress ?? ''
|
|
||||||
fieldElements.allowCustomServerAddress.checked = profile.allowCustomServerAddress === true
|
|
||||||
fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? ''
|
fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? ''
|
||||||
fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? ''
|
fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? ''
|
||||||
fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? ''
|
fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? ''
|
||||||
@@ -178,6 +206,7 @@ function populateEditor(){
|
|||||||
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
|
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
|
||||||
|
|
||||||
syncKindSections(profile.kind)
|
syncKindSections(profile.kind)
|
||||||
|
updateDistributionEditorHint(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectedProfile(patch){
|
function updateSelectedProfile(patch){
|
||||||
@@ -185,9 +214,14 @@ function updateSelectedProfile(patch){
|
|||||||
if(!profile){
|
if(!profile){
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(profile, patch)
|
Object.assign(profile, patch)
|
||||||
markDirty(true)
|
markDirty(true)
|
||||||
renderSidebar()
|
renderSidebar()
|
||||||
|
|
||||||
|
if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){
|
||||||
|
updateDistributionEditorHint(profile, patch.distributionUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindTextField(fieldName){
|
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){
|
function bindNumberField(fieldName){
|
||||||
fieldElements[fieldName].addEventListener('input', (event) => {
|
fieldElements[fieldName].addEventListener('input', (event) => {
|
||||||
const value = Number.parseInt(event.target.value || '25565', 10)
|
const value = Number.parseInt(event.target.value || '25565', 10)
|
||||||
@@ -232,13 +258,15 @@ function bindNumberField(fieldName){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindProfileForm(){
|
function bindProfileForm(){
|
||||||
|
profileEditorForm.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
})
|
||||||
|
|
||||||
bindTextField('id')
|
bindTextField('id')
|
||||||
bindTextField('name')
|
bindTextField('name')
|
||||||
bindTextField('description')
|
bindTextField('description')
|
||||||
bindTextField('details')
|
bindTextField('details')
|
||||||
bindTextField('distributionUrl')
|
bindTextField('distributionUrl')
|
||||||
bindTextField('defaultServerAddress')
|
|
||||||
bindCheckboxField('allowCustomServerAddress')
|
|
||||||
bindTextField('worldArchiveUrl')
|
bindTextField('worldArchiveUrl')
|
||||||
bindTextField('worldDirectoryName')
|
bindTextField('worldDirectoryName')
|
||||||
bindTextField('serverBundleUrl')
|
bindTextField('serverBundleUrl')
|
||||||
@@ -257,10 +285,7 @@ function bindProfileForm(){
|
|||||||
|
|
||||||
profile.kind = event.target.value
|
profile.kind = event.target.value
|
||||||
|
|
||||||
if(profile.kind === 'map'){
|
if(profile.kind === 'server-pack' && !profile.serverDirectoryName){
|
||||||
profile.allowCustomServerAddress = false
|
|
||||||
}
|
|
||||||
if(profile.kind !== 'server-pack' && !profile.serverDirectoryName){
|
|
||||||
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
|
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +337,11 @@ async function uploadIntoField(targetField, accept){
|
|||||||
fieldElements[targetField].value = result.file.path
|
fieldElements[targetField].value = result.file.path
|
||||||
markDirty(true)
|
markDirty(true)
|
||||||
renderSidebar()
|
renderSidebar()
|
||||||
|
|
||||||
|
if(targetField === 'distributionUrl'){
|
||||||
|
updateDistributionEditorHint(profile, result.file.path)
|
||||||
|
}
|
||||||
|
|
||||||
showStatus(`업로드 완료: ${result.file.path}`, 'success')
|
showStatus(`업로드 완료: ${result.file.path}`, 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -322,6 +352,152 @@ async function uploadIntoField(targetField, accept){
|
|||||||
picker.click()
|
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(){
|
async function loadMeta(){
|
||||||
const response = await fetch('/api/meta')
|
const response = await fetch('/api/meta')
|
||||||
const meta = await response.json()
|
const meta = await response.json()
|
||||||
@@ -450,6 +626,7 @@ function bindTopLevelActions(){
|
|||||||
|
|
||||||
async function bootstrap(){
|
async function bootstrap(){
|
||||||
bindProfileForm()
|
bindProfileForm()
|
||||||
|
bindDistributionEditor()
|
||||||
bindTopLevelActions()
|
bindTopLevelActions()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -99,20 +99,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="fieldGrid">
|
<div class="fieldGrid">
|
||||||
<label class="fieldBlock fieldBlockFull">
|
<label class="fieldBlock fieldBlockFull">
|
||||||
<span>distribution.json 경로 또는 URL</span>
|
<span>distribution 파일</span>
|
||||||
<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">파일 업로드</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">JSON 편집</button>
|
||||||
|
<button type="button" id="createDistributionButton" class="secondaryAction">새로 만들기</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="fieldBlock">
|
<div class="fieldHelpText fieldBlockFull">
|
||||||
<span>기본 접속 주소</span>
|
distribution.json은 여기서 직접 업로드하거나 JSON 편집기로 새로 만들 수 있습니다.
|
||||||
<input id="field-defaultServerAddress" type="text" autocomplete="off" placeholder="example.com:25565">
|
</div>
|
||||||
</label>
|
|
||||||
<label class="toggleBlock">
|
|
||||||
<input id="field-allowCustomServerAddress" type="checkbox">
|
|
||||||
<span>사용자가 라이브러리에서 주소를 바꿀 수 있음</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -184,6 +181,24 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="distributionEditorModal" class="modalBackdrop" hidden>
|
||||||
|
<div class="modalPanel">
|
||||||
|
<div class="modalHeader">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Distribution Editor</span>
|
||||||
|
<h3>distribution.json 편집</h3>
|
||||||
|
<p id="distributionEditorHint">프로필에 연결할 distribution.json 내용을 사이트 안에서 직접 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="closeDistributionEditorButton" class="secondaryAction">닫기</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="distributionEditorTextarea" class="distributionTextarea" spellcheck="false"></textarea>
|
||||||
|
<div class="modalActions">
|
||||||
|
<button type="button" id="loadDistributionTemplateButton" class="secondaryAction">샘플 불러오기</button>
|
||||||
|
<button type="button" id="saveDistributionFileButton" class="primaryAction">distribution 저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="./app.js"></script>
|
<script src="./app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -278,11 +278,17 @@ textarea {
|
|||||||
|
|
||||||
.uploadField {
|
.uploadField {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fieldHelpText {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.primaryAction,
|
.primaryAction,
|
||||||
.secondaryAction,
|
.secondaryAction,
|
||||||
.dangerAction {
|
.dangerAction {
|
||||||
@@ -346,6 +352,53 @@ textarea {
|
|||||||
background: rgba(240, 191, 87, 0.1);
|
background: rgba(240, 191, 87, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modalBackdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(5, 6, 6, 0.72);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalPanel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #111412;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distributionTextarea {
|
||||||
|
min-height: 420px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.adminShell {
|
.adminShell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
108
admin/server.js
108
admin/server.js
@@ -8,9 +8,11 @@ const PORT = Number.parseInt(process.env.LAUNCHER_ADMIN_PORT || '8787', 10)
|
|||||||
const PROJECT_ROOT = path.resolve(__dirname, '..')
|
const PROJECT_ROOT = path.resolve(__dirname, '..')
|
||||||
const RUNTIME_DATA_DIR = path.join(__dirname, 'data')
|
const RUNTIME_DATA_DIR = path.join(__dirname, 'data')
|
||||||
const UPLOADS_DIR = path.join(RUNTIME_DATA_DIR, 'uploads')
|
const UPLOADS_DIR = path.join(RUNTIME_DATA_DIR, 'uploads')
|
||||||
|
const DISTRIBUTIONS_DIR = path.join(RUNTIME_DATA_DIR, 'distributions')
|
||||||
const RUNTIME_CATALOG_PATH = path.join(RUNTIME_DATA_DIR, 'catalog.json')
|
const RUNTIME_CATALOG_PATH = path.join(RUNTIME_DATA_DIR, 'catalog.json')
|
||||||
const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher', 'catalog.json')
|
const LAUNCHER_CATALOG_PATH = path.join(PROJECT_ROOT, 'app', 'assets', 'launcher', 'catalog.json')
|
||||||
const PUBLIC_DIR = path.join(__dirname, 'public')
|
const PUBLIC_DIR = path.join(__dirname, 'public')
|
||||||
|
const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json')
|
||||||
|
|
||||||
const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack'])
|
const PROFILE_KINDS = new Set(['modpack', 'map', 'server-pack'])
|
||||||
|
|
||||||
@@ -24,10 +26,6 @@ function normalizeMultilineText(value){
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBoolean(value){
|
|
||||||
return value === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePort(value){
|
function normalizePort(value){
|
||||||
const port = Number.parseInt(String(value ?? ''), 10)
|
const port = Number.parseInt(String(value ?? ''), 10)
|
||||||
if(Number.isFinite(port) && port >= 1 && port <= 65535){
|
if(Number.isFinite(port) && port >= 1 && port <= 65535){
|
||||||
@@ -36,6 +34,25 @@ function normalizePort(value){
|
|||||||
return 25565
|
return 25565
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSafeProjectPath(relativePath){
|
||||||
|
const resolvedPath = path.resolve(PROJECT_ROOT, relativePath)
|
||||||
|
if(!resolvedPath.startsWith(PROJECT_ROOT + path.sep) && resolvedPath !== PROJECT_ROOT){
|
||||||
|
throw new Error('허용되지 않은 경로입니다.')
|
||||||
|
}
|
||||||
|
return resolvedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDistributionFileName(profileId){
|
||||||
|
const safeId = String(profileId ?? 'distribution')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^[-_.]+|[-_.]+$/g, '')
|
||||||
|
|
||||||
|
return `${safeId.length > 0 ? safeId : 'distribution'}.distribution.json`
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeProfile(rawProfile, index){
|
function sanitizeProfile(rawProfile, index){
|
||||||
const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack'
|
const kind = PROFILE_KINDS.has(rawProfile?.kind) ? rawProfile.kind : 'modpack'
|
||||||
const sanitized = {
|
const sanitized = {
|
||||||
@@ -44,9 +61,7 @@ function sanitizeProfile(rawProfile, index){
|
|||||||
kind,
|
kind,
|
||||||
description: normalizeText(rawProfile?.description),
|
description: normalizeText(rawProfile?.description),
|
||||||
details: normalizeMultilineText(rawProfile?.details),
|
details: normalizeMultilineText(rawProfile?.details),
|
||||||
distributionUrl: normalizeText(rawProfile?.distributionUrl),
|
distributionUrl: normalizeText(rawProfile?.distributionUrl)
|
||||||
defaultServerAddress: normalizeText(rawProfile?.defaultServerAddress),
|
|
||||||
allowCustomServerAddress: normalizeBoolean(rawProfile?.allowCustomServerAddress)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(kind === 'map'){
|
if(kind === 'map'){
|
||||||
@@ -87,6 +102,7 @@ function toProjectRelativePath(targetPath){
|
|||||||
async function ensureRuntimeCatalog(){
|
async function ensureRuntimeCatalog(){
|
||||||
await fs.ensureDir(RUNTIME_DATA_DIR)
|
await fs.ensureDir(RUNTIME_DATA_DIR)
|
||||||
await fs.ensureDir(UPLOADS_DIR)
|
await fs.ensureDir(UPLOADS_DIR)
|
||||||
|
await fs.ensureDir(DISTRIBUTIONS_DIR)
|
||||||
|
|
||||||
if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){
|
if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){
|
||||||
if(await fs.pathExists(LAUNCHER_CATALOG_PATH)){
|
if(await fs.pathExists(LAUNCHER_CATALOG_PATH)){
|
||||||
@@ -143,6 +159,7 @@ async function start(){
|
|||||||
|
|
||||||
app.use(express.json({ limit: '5mb' }))
|
app.use(express.json({ limit: '5mb' }))
|
||||||
app.use('/uploads', express.static(UPLOADS_DIR))
|
app.use('/uploads', express.static(UPLOADS_DIR))
|
||||||
|
app.use('/admin/data/distributions', express.static(DISTRIBUTIONS_DIR))
|
||||||
|
|
||||||
app.get('/api/meta', async (_req, res) => {
|
app.get('/api/meta', async (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -150,10 +167,53 @@ async function start(){
|
|||||||
port: PORT,
|
port: PORT,
|
||||||
runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH),
|
runtimeCatalogPath: toProjectRelativePath(RUNTIME_CATALOG_PATH),
|
||||||
launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH),
|
launcherCatalogPath: toProjectRelativePath(LAUNCHER_CATALOG_PATH),
|
||||||
localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`
|
localCatalogUrl: `http://${HOST}:${PORT}/catalog.json`,
|
||||||
|
distributionsPath: toProjectRelativePath(DISTRIBUTIONS_DIR)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get('/api/distribution/template', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(SAMPLE_DISTRIBUTION_PATH, 'utf8')
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
content
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/distribution/content', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const requestedPath = normalizeText(req.query.path)
|
||||||
|
if(requestedPath.length === 0){
|
||||||
|
res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
message: '불러올 distribution 경로가 없습니다.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if(/^https?:\/\//i.test(requestedPath)){
|
||||||
|
res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
message: '원격 URL은 사이트에서 직접 수정할 수 없습니다. 업로드하거나 새로 생성하세요.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = resolveSafeProjectPath(requestedPath)
|
||||||
|
const content = await fs.readFile(resolvedPath, 'utf8')
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
content
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/api/catalog', async (_req, res, next) => {
|
app.get('/api/catalog', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json(await readCatalog())
|
res.json(await readCatalog())
|
||||||
@@ -201,6 +261,38 @@ async function start(){
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/api/distribution/save', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const profileId = normalizeText(req.body?.profileId)
|
||||||
|
const rawContent = typeof req.body?.content === 'string' ? req.body.content : ''
|
||||||
|
|
||||||
|
if(profileId.length === 0){
|
||||||
|
res.status(400).json({
|
||||||
|
ok: false,
|
||||||
|
message: '프로필 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(rawContent)
|
||||||
|
const fileName = createDistributionFileName(profileId)
|
||||||
|
const targetPath = path.join(DISTRIBUTIONS_DIR, fileName)
|
||||||
|
const relativePath = toProjectRelativePath(targetPath)
|
||||||
|
|
||||||
|
await fs.writeFile(targetPath, JSON.stringify(parsed, null, 2) + '\n', 'utf8')
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
file: {
|
||||||
|
path: relativePath,
|
||||||
|
localUrl: `http://${HOST}:${PORT}/${relativePath}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.get('/catalog.json', async (_req, res, next) => {
|
app.get('/catalog.json', async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.sendFile(RUNTIME_CATALOG_PATH)
|
res.sendFile(RUNTIME_CATALOG_PATH)
|
||||||
|
|||||||
@@ -6,10 +6,8 @@
|
|||||||
"name": "Concatenation Lite",
|
"name": "Concatenation Lite",
|
||||||
"kind": "modpack",
|
"kind": "modpack",
|
||||||
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
|
"description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.",
|
||||||
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
|
"details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution 파일만 교체해서 실서비스용 항목으로 바꿀 수 있습니다.",
|
||||||
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json",
|
"distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json"
|
||||||
"defaultServerAddress": "play.mysticred.space",
|
|
||||||
"allowCustomServerAddress": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "template-original-map",
|
"id": "template-original-map",
|
||||||
@@ -18,8 +16,6 @@
|
|||||||
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
|
"description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.",
|
||||||
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
|
"details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.",
|
||||||
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
||||||
"defaultServerAddress": "",
|
|
||||||
"allowCustomServerAddress": false,
|
|
||||||
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
|
"worldArchiveUrl": "https://example.com/maps/original-map.zip",
|
||||||
"worldDirectoryName": "Original Map"
|
"worldDirectoryName": "Original Map"
|
||||||
},
|
},
|
||||||
@@ -30,8 +26,6 @@
|
|||||||
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
|
"description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.",
|
||||||
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
|
"details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.",
|
||||||
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
||||||
"defaultServerAddress": "",
|
|
||||||
"allowCustomServerAddress": true,
|
|
||||||
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
|
"serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip",
|
||||||
"serverDirectoryName": "plugin-world-server",
|
"serverDirectoryName": "plugin-world-server",
|
||||||
"serverLaunchCommand": "java -jar server.jar nogui",
|
"serverLaunchCommand": "java -jar server.jar nogui",
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ npm run admin
|
|||||||
|
|
||||||
- 프로필 추가 / 수정 / 삭제 / 복제
|
- 프로필 추가 / 수정 / 삭제 / 복제
|
||||||
- `modpack`, `map`, `server-pack` 종류별 입력 폼
|
- `modpack`, `map`, `server-pack` 종류별 입력 폼
|
||||||
- `distribution.json`, 맵 ZIP, 서버 번들 ZIP 업로드
|
- `distribution.json` 업로드 / 새로 만들기 / 직접 편집
|
||||||
|
- 맵 ZIP, 서버 번들 ZIP 업로드
|
||||||
- 저장 시 아래 두 파일을 동시에 갱신
|
- 저장 시 아래 두 파일을 동시에 갱신
|
||||||
- `admin/data/catalog.json`
|
- `admin/data/catalog.json`
|
||||||
- `app/assets/launcher/catalog.json`
|
- `app/assets/launcher/catalog.json`
|
||||||
|
|
||||||
|
## distribution 편집
|
||||||
|
|
||||||
|
- `distribution 파일` 칸에서 JSON 업로드 가능
|
||||||
|
- `JSON 편집` 버튼으로 현재 연결된 로컬 `distribution.json` 수정 가능
|
||||||
|
- `새로 만들기` 버튼으로 샘플 템플릿에서 새 `distribution.json` 생성 가능
|
||||||
|
- 저장된 distribution 파일은 아래에 생성됩니다.
|
||||||
|
|
||||||
|
- `admin/data/distributions/`
|
||||||
|
|
||||||
|
- 접속주소 필드는 관리자 사이트에서 다루지 않습니다. 런처 내부 접속 흐름과 분리해서 프로필 자료만 관리합니다.
|
||||||
|
|
||||||
## 업로드 동작
|
## 업로드 동작
|
||||||
|
|
||||||
업로드 버튼으로 올린 파일은 아래에 저장됩니다.
|
업로드 버튼으로 올린 파일은 아래에 저장됩니다.
|
||||||
@@ -43,6 +55,6 @@ npm run admin
|
|||||||
|
|
||||||
## 추천 운영 방식
|
## 추천 운영 방식
|
||||||
|
|
||||||
1. 관리자 사이트에서 프로필과 자료 경로를 입력
|
1. 관리자 사이트에서 프로필과 자료 파일을 입력
|
||||||
2. 로컬 런처에서 실제 표시와 실행 확인
|
2. 로컬 런처에서 실제 표시와 실행 확인
|
||||||
3. 이후 필요하면 업로드 경로를 공개 URL 기반으로 확장
|
3. 이후 필요하면 업로드 경로를 공개 URL 기반으로 확장
|
||||||
|
|||||||
@@ -19,9 +19,7 @@
|
|||||||
"kind": "modpack",
|
"kind": "modpack",
|
||||||
"description": "설명",
|
"description": "설명",
|
||||||
"details": "설치 페이지 상세 패널에 표시할 긴 설명",
|
"details": "설치 페이지 상세 패널에 표시할 긴 설명",
|
||||||
"distributionUrl": "https://example.com/launcher/distribution.json",
|
"distributionUrl": "admin/data/distributions/my-modpack.distribution.json"
|
||||||
"defaultServerAddress": "example.com:25565",
|
|
||||||
"allowCustomServerAddress": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "my-map",
|
"id": "my-map",
|
||||||
@@ -29,10 +27,9 @@
|
|||||||
"kind": "map",
|
"kind": "map",
|
||||||
"description": "싱글플레이 월드",
|
"description": "싱글플레이 월드",
|
||||||
"details": "월드와 플레이 방식에 대한 상세 설명",
|
"details": "월드와 플레이 방식에 대한 상세 설명",
|
||||||
"distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json",
|
"distributionUrl": "admin/data/distributions/my-map.distribution.json",
|
||||||
"worldArchiveUrl": "https://example.com/worlds/my-map.zip",
|
"worldArchiveUrl": "https://example.com/worlds/my-map.zip",
|
||||||
"worldDirectoryName": "My Map",
|
"worldDirectoryName": "My Map"
|
||||||
"allowCustomServerAddress": false
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "my-server-pack",
|
"id": "my-server-pack",
|
||||||
@@ -40,14 +37,13 @@
|
|||||||
"kind": "server-pack",
|
"kind": "server-pack",
|
||||||
"description": "클라이언트 + 로컬 서버 번들",
|
"description": "클라이언트 + 로컬 서버 번들",
|
||||||
"details": "서버 실행 방법, 권장 인원, 접속 방식 등 상세 설명",
|
"details": "서버 실행 방법, 권장 인원, 접속 방식 등 상세 설명",
|
||||||
"distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json",
|
"distributionUrl": "admin/data/distributions/my-server-pack.distribution.json",
|
||||||
"serverBundleUrl": "https://example.com/serverpacks/my-server-pack.zip",
|
"serverBundleUrl": "https://example.com/serverpacks/my-server-pack.zip",
|
||||||
"serverDirectoryName": "my-server-pack",
|
"serverDirectoryName": "my-server-pack",
|
||||||
"serverLaunchCommand": "java -jar server.jar nogui",
|
"serverLaunchCommand": "java -jar server.jar nogui",
|
||||||
"serverPort": 25565,
|
"serverPort": 25565,
|
||||||
"tunnelCommand": "playit-cli --port ${port}",
|
"tunnelCommand": "playit-cli --port ${port}",
|
||||||
"tunnelAddressRegex": "([a-zA-Z0-9.-]+:\\d+)",
|
"tunnelAddressRegex": "([a-zA-Z0-9.-]+:\\d+)"
|
||||||
"allowCustomServerAddress": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -60,9 +56,7 @@
|
|||||||
- `kind`: `modpack`, `map`, `server-pack`
|
- `kind`: `modpack`, `map`, `server-pack`
|
||||||
- `description`: 표시 설명
|
- `description`: 표시 설명
|
||||||
- `details`: 설치 페이지 상세 패널에 표시할 긴 설명
|
- `details`: 설치 페이지 상세 패널에 표시할 긴 설명
|
||||||
- `distributionUrl`: Helios distribution.json URL 또는 로컬 경로
|
- `distributionUrl`: Helios distribution.json URL 또는 로컬 경로. 관리자 사이트에서 직접 만들거나 업로드 가능
|
||||||
- `defaultServerAddress`: 기본 자동 접속 주소
|
|
||||||
- `allowCustomServerAddress`: 사용자가 라이브러리에서 주소를 덮어쓸 수 있는지 여부
|
|
||||||
- `worldArchiveUrl`: `kind: map` 일 때 사용할 월드 ZIP 또는 로컬 경로
|
- `worldArchiveUrl`: `kind: map` 일 때 사용할 월드 ZIP 또는 로컬 경로
|
||||||
- `worldDirectoryName`: 게임 `saves/` 아래에 설치될 월드 폴더 이름
|
- `worldDirectoryName`: 게임 `saves/` 아래에 설치될 월드 폴더 이름
|
||||||
- `serverBundleUrl`: `kind: server-pack` 일 때 사용할 서버 ZIP 또는 로컬 디렉터리/경로
|
- `serverBundleUrl`: `kind: server-pack` 일 때 사용할 서버 ZIP 또는 로컬 디렉터리/경로
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user