diff --git a/README.md b/README.md index cbb28f7..de1f7d3 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ npm run admin ``` 설치 페이지용 프로필을 웹 UI로 관리할 수 있습니다. +- `distribution.json`도 사이트 안에서 직접 만들고 수정할 수 있습니다. - 문서: [docs/admin-site.md](docs/admin-site.md) diff --git a/admin/data/catalog.json b/admin/data/catalog.json index 9c3320c..9bff7a3 100644 --- a/admin/data/catalog.json +++ b/admin/data/catalog.json @@ -6,10 +6,8 @@ "name": "Concatenation Lite", "kind": "modpack", "description": "기존 MRS 분배 인덱스를 사용하는 예시 프로필입니다. 프로필 카탈로그 구조가 동작하는지 확인할 때 사용할 수 있습니다.", - "details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution URL과 기본 접속 주소를 유지하거나 교체해서 실서비스용 항목으로 바꿀 수 있습니다.", - "distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json", - "defaultServerAddress": "play.mysticred.space", - "allowCustomServerAddress": true + "details": "Mystic Red Space에서 운영하던 기존 분배 인덱스를 그대로 사용합니다.\n\n사용자는 이 항목을 라이브러리에 추가한 뒤 바로 실행할 수 있습니다.\n관리자는 distribution 파일만 교체해서 실서비스용 항목으로 바꿀 수 있습니다.", + "distributionUrl": "https://cdn.mysticred.space/launcher/distribution.json" }, { "id": "template-original-map", @@ -18,8 +16,6 @@ "description": "오리지널 맵용 템플릿입니다. vanilla 또는 맵 전용 distribution URL과 월드 ZIP 경로를 채우면 싱글플레이 빠른 실행에 사용할 수 있습니다.", "details": "관리자는 이 항목에 월드 ZIP과 클라이언트 distribution URL을 미리 등록합니다.\n\n사용자는 설치 페이지에서 설명을 읽고 라이브러리에 추가한 뒤, 라이브러리에서 맵 자료를 준비하고 바로 실행할 수 있습니다.", "distributionUrl": "https://example.com/launcher/vanilla-map-distribution.json", - "defaultServerAddress": "", - "allowCustomServerAddress": false, "worldArchiveUrl": "https://example.com/maps/original-map.zip", "worldDirectoryName": "Original Map" }, @@ -30,8 +26,6 @@ "description": "플러그인 맵 + 서버 접속용 템플릿입니다. 클라이언트 distribution, 서버 번들 ZIP, 서버 시작 명령, 선택형 터널 명령을 연결하면 됩니다.", "details": "관리자는 클라이언트 배포 파일, 서버 번들, 서버 시작 명령을 미리 등록합니다.\n\n사용자는 설치 페이지에서 상세 내용을 확인하고 라이브러리에 추가한 뒤, 라이브러리에서 직접 서버를 켜거나 수동 주소를 넣어 접속할 수 있습니다.", "distributionUrl": "https://example.com/launcher/server-pack-client-distribution.json", - "defaultServerAddress": "", - "allowCustomServerAddress": true, "serverBundleUrl": "https://example.com/serverpacks/plugin-world-server.zip", "serverDirectoryName": "plugin-world-server", "serverLaunchCommand": "java -jar server.jar nogui", diff --git a/admin/data/distributions/.gitkeep b/admin/data/distributions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/admin/data/distributions/.gitkeep @@ -0,0 +1 @@ + diff --git a/admin/public/app.js b/admin/public/app.js index ace971f..551f51d 100644 --- a/admin/public/app.js +++ b/admin/public/app.js @@ -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 { diff --git a/admin/public/index.html b/admin/public/index.html index fbc3bb5..46b5299 100644 --- a/admin/public/index.html +++ b/admin/public/index.html @@ -99,20 +99,17 @@
프로필에 연결할 distribution.json 내용을 사이트 안에서 직접 관리합니다.
+