diff --git a/admin/data/catalog.json b/admin/data/catalog.json index 795d3db..e8a0f7e 100644 --- a/admin/data/catalog.json +++ b/admin/data/catalog.json @@ -9,7 +9,7 @@ "modsEnabled": false, "pluginsEnabled": false, "serverEnabled": false, - "distributionUrl": "https://example.com/launcher/map-base.distribution.json", + "distributionUrl": "admin/data/distributions/template-map-base.distribution.json", "worldArchiveUrl": "https://example.com/worlds/map-base.zip", "worldDirectoryName": "Map Base" }, @@ -21,7 +21,7 @@ "modsEnabled": true, "pluginsEnabled": false, "serverEnabled": false, - "distributionUrl": "https://example.com/launcher/map-mods.distribution.json", + "distributionUrl": "admin/data/distributions/template-map-mods.distribution.json", "worldArchiveUrl": "https://example.com/worlds/map-mods.zip", "worldDirectoryName": "Map Mods" }, @@ -33,7 +33,7 @@ "modsEnabled": false, "pluginsEnabled": true, "serverEnabled": true, - "distributionUrl": "https://example.com/launcher/map-plugin-server.distribution.json", + "distributionUrl": "admin/data/distributions/template-map-plugin-server.distribution.json", "worldArchiveUrl": "https://example.com/worlds/plugin-map.zip", "worldDirectoryName": "Plugin Map", "serverJarUrl": "https://example.com/server/paper.jar", diff --git a/admin/data/distributions/template-map-base.distribution.json b/admin/data/distributions/template-map-base.distribution.json new file mode 100644 index 0000000..8ded224 --- /dev/null +++ b/admin/data/distributions/template-map-base.distribution.json @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "rss": "", + "servers": [] +} diff --git a/admin/data/distributions/template-map-mods.distribution.json b/admin/data/distributions/template-map-mods.distribution.json new file mode 100644 index 0000000..05ffe30 --- /dev/null +++ b/admin/data/distributions/template-map-mods.distribution.json @@ -0,0 +1,16 @@ +{ + "version": "1.0.0", + "rss": "", + "servers": [ + { + "id": "template-map-mods", + "name": "Map + Mods Template", + "description": "맵과 모드를 함께 쓰는 프로필용 샘플 distribution입니다.", + "version": "1.20.1", + "minecraftVersion": "1.20.1", + "mainServer": true, + "autoconnect": false, + "modules": [] + } + ] +} diff --git a/admin/data/distributions/template-map-plugin-server.distribution.json b/admin/data/distributions/template-map-plugin-server.distribution.json new file mode 100644 index 0000000..73a67c1 --- /dev/null +++ b/admin/data/distributions/template-map-plugin-server.distribution.json @@ -0,0 +1,16 @@ +{ + "version": "1.0.0", + "rss": "", + "servers": [ + { + "id": "template-map-plugin-server", + "name": "Map + Plugin Server Template", + "description": "맵, 플러그인, 서버를 함께 쓰는 프로필용 샘플 distribution입니다.", + "version": "1.20.1", + "minecraftVersion": "1.20.1", + "mainServer": true, + "autoconnect": false, + "modules": [] + } + ] +} diff --git a/admin/public/app.js b/admin/public/app.js index f865e0c..0ba0974 100644 --- a/admin/public/app.js +++ b/admin/public/app.js @@ -25,6 +25,7 @@ const editDistributionButton = document.getElementById('editDistributionButton') const createDistributionButton = document.getElementById('createDistributionButton') const distributionEditorModal = document.getElementById('distributionEditorModal') const distributionEditorHint = document.getElementById('distributionEditorHint') +const distributionEditorStatus = document.getElementById('distributionEditorStatus') const distributionEditorTextarea = document.getElementById('distributionEditorTextarea') const closeDistributionEditorButton = document.getElementById('closeDistributionEditorButton') const loadDistributionTemplateButton = document.getElementById('loadDistributionTemplateButton') @@ -102,6 +103,18 @@ function clearStatus(){ delete statusBanner.dataset.tone } +function showDistributionEditorStatus(message, tone = 'info'){ + distributionEditorStatus.hidden = false + distributionEditorStatus.dataset.tone = tone + distributionEditorStatus.textContent = message +} + +function clearDistributionEditorStatus(){ + distributionEditorStatus.hidden = true + distributionEditorStatus.textContent = '' + delete distributionEditorStatus.dataset.tone +} + function selectProfile(profileId){ state.selectedProfileId = profileId renderSidebar() @@ -187,7 +200,7 @@ function updateDistributionEditorHint(profile, pathOverride){ } if(isRemoteUrl(currentPath)){ - distributionEditorHint.textContent = `현재 값은 원격 URL입니다: ${currentPath} | 원격 URL은 여기서 직접 수정할 수 없습니다. 샘플을 불러온 뒤 새 파일로 저장하세요.` + distributionEditorHint.textContent = `현재 값은 원격 URL입니다: ${currentPath} | 내용을 불러와 수정할 수 있고, 저장하면 이 프로젝트 안의 로컬 distribution 파일로 복사됩니다.` return } @@ -378,11 +391,13 @@ async function uploadIntoField(targetField, accept){ function openDistributionEditorModal(){ distributionEditorModal.hidden = false document.body.style.overflow = 'hidden' + clearDistributionEditorStatus() } function closeDistributionEditorModal(){ distributionEditorModal.hidden = true document.body.style.overflow = '' + clearDistributionEditorStatus() } async function loadDistributionTemplate(){ @@ -395,6 +410,16 @@ async function loadDistributionTemplate(){ distributionEditorTextarea.value = result.content } +async function loadDistributionContent(requestedPath){ + const response = await fetch(`/api/distribution/content?path=${encodeURIComponent(requestedPath)}`) + 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){ @@ -405,6 +430,7 @@ async function openDistributionEditor(mode){ openDistributionEditorModal() distributionEditorTextarea.value = '' updateDistributionEditorHint(profile) + showDistributionEditorStatus('distribution 내용을 준비하는 중...', 'info') try { const currentPath = String(profile.distributionUrl ?? '').trim() @@ -413,29 +439,27 @@ async function openDistributionEditor(mode){ await loadDistributionTemplate() updateDistributionEditorHint(profile, '') showStatus('distribution 템플릿을 불러왔습니다.', 'success') + showDistributionEditorStatus('샘플을 불러왔습니다. 바로 수정한 뒤 저장하면 됩니다.', 'success') return } if(isRemoteUrl(currentPath)){ - await loadDistributionTemplate() + await loadDistributionContent(currentPath) updateDistributionEditorHint(profile, currentPath) - showStatus('원격 URL은 직접 수정할 수 없어 샘플을 대신 불러왔습니다.', 'info') + showStatus('원격 distribution 내용을 불러왔습니다.', 'success') + showDistributionEditorStatus('원격 distribution 내용을 불러왔습니다. 저장하면 로컬 파일로 복사됩니다.', 'success') 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 + await loadDistributionContent(currentPath) updateDistributionEditorHint(profile, currentPath) showStatus('distribution 파일을 불러왔습니다.', 'success') + showDistributionEditorStatus('현재 연결된 distribution 파일을 불러왔습니다.', 'success') } catch (error) { console.error(error) showStatus(error instanceof Error ? error.message : 'distribution 파일을 불러오지 못했습니다.', 'error') + showDistributionEditorStatus(error instanceof Error ? error.message : 'distribution 파일을 불러오지 못했습니다.', 'error') } } @@ -449,6 +473,7 @@ async function saveDistributionFile(){ try { saveDistributionFileButton.disabled = true showStatus('distribution 파일 저장 중...', 'info') + showDistributionEditorStatus('distribution 파일 저장 중...', 'info') const response = await fetch('/api/distribution/save', { method: 'POST', @@ -475,6 +500,7 @@ async function saveDistributionFile(){ } catch (error) { console.error(error) showStatus(error instanceof Error ? error.message : 'distribution 파일 저장에 실패했습니다.', 'error') + showDistributionEditorStatus(error instanceof Error ? error.message : 'distribution 파일 저장에 실패했습니다.', 'error') } finally { saveDistributionFileButton.disabled = false } @@ -498,9 +524,11 @@ function bindDistributionEditor(){ await loadDistributionTemplate() updateDistributionEditorHint(getSelectedProfile(), '') showStatus('distribution 템플릿을 다시 불러왔습니다.', 'success') + showDistributionEditorStatus('샘플을 다시 불러왔습니다.', 'success') } catch (error) { console.error(error) showStatus(error instanceof Error ? error.message : 'distribution 템플릿을 불러오지 못했습니다.', 'error') + showDistributionEditorStatus(error instanceof Error ? error.message : 'distribution 템플릿을 불러오지 못했습니다.', 'error') } }) diff --git a/admin/public/index.html b/admin/public/index.html index 394c6ed..4577ca1 100644 --- a/admin/public/index.html +++ b/admin/public/index.html @@ -199,6 +199,7 @@ +
diff --git a/admin/public/styles.css b/admin/public/styles.css index cba6021..f05743a 100644 --- a/admin/public/styles.css +++ b/admin/public/styles.css @@ -375,6 +375,11 @@ textarea { border-radius: 24px; background: #111412; box-shadow: var(--shadow); + pointer-events: auto; +} + +#distributionEditorStatus { + margin-top: -4px; } .modalHeader { diff --git a/admin/server.js b/admin/server.js index 8fc098b..e38c74a 100644 --- a/admin/server.js +++ b/admin/server.js @@ -13,6 +13,7 @@ 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 PUBLIC_DIR = path.join(__dirname, 'public') const SAMPLE_DISTRIBUTION_PATH = path.join(PROJECT_ROOT, 'docs', 'sample_distribution.json') +const EXAMPLE_DISTRIBUTION_PREFIX = 'https://example.com/launcher/' function normalizeText(value){ return typeof value === 'string' ? value.trim() : '' @@ -163,6 +164,53 @@ async function ensureRuntimeCatalog(){ await fs.writeJson(RUNTIME_CATALOG_PATH, { version: 1, profiles: [] }, { spaces: 2 }) } } + + await migrateExampleCatalog() +} + +async function ensureLocalSampleDistribution(profileId, profileName){ + const fileName = createDistributionFileName(profileId) + const targetPath = path.join(DISTRIBUTIONS_DIR, fileName) + + if(!(await fs.pathExists(targetPath))){ + const template = JSON.parse(await fs.readFile(SAMPLE_DISTRIBUTION_PATH, 'utf8')) + template.servers = [ + { + id: profileId, + name: profileName || profileId, + description: '관리자 사이트 로컬 샘플 distribution입니다.', + version: '1.20.1', + minecraftVersion: '1.20.1', + mainServer: true, + autoconnect: false, + modules: [] + } + ] + await fs.writeFile(targetPath, JSON.stringify(template, null, 2) + '\n', 'utf8') + } + + return toProjectRelativePath(targetPath) +} + +async function migrateExampleCatalog(){ + if(!(await fs.pathExists(RUNTIME_CATALOG_PATH))){ + return + } + + const runtimeCatalog = sanitizeCatalog(await fs.readJson(RUNTIME_CATALOG_PATH)) + let changed = false + + for(const profile of runtimeCatalog.profiles){ + if(normalizeText(profile.distributionUrl).startsWith(EXAMPLE_DISTRIBUTION_PREFIX)){ + profile.distributionUrl = await ensureLocalSampleDistribution(profile.id, profile.name) + changed = true + } + } + + if(changed){ + await fs.writeJson(RUNTIME_CATALOG_PATH, runtimeCatalog, { spaces: 2 }) + await fs.writeJson(LAUNCHER_CATALOG_PATH, runtimeCatalog, { spaces: 2 }) + } } async function readCatalog(){ @@ -248,9 +296,18 @@ async function start(){ } if(/^https?:\/\//i.test(requestedPath)){ - res.status(400).json({ - ok: false, - message: '원격 URL은 사이트에서 직접 수정할 수 없습니다. 업로드하거나 새로 생성하세요.' + const response = await fetch(requestedPath, { + signal: AbortSignal.timeout(10000) + }) + if(!response.ok){ + throw new Error(`원격 distribution을 불러오지 못했습니다. (${response.status})`) + } + + const content = await response.text() + JSON.parse(content) + res.json({ + ok: true, + content }) return } diff --git a/app/assets/launcher/catalog.json b/app/assets/launcher/catalog.json index 795d3db..e8a0f7e 100644 --- a/app/assets/launcher/catalog.json +++ b/app/assets/launcher/catalog.json @@ -9,7 +9,7 @@ "modsEnabled": false, "pluginsEnabled": false, "serverEnabled": false, - "distributionUrl": "https://example.com/launcher/map-base.distribution.json", + "distributionUrl": "admin/data/distributions/template-map-base.distribution.json", "worldArchiveUrl": "https://example.com/worlds/map-base.zip", "worldDirectoryName": "Map Base" }, @@ -21,7 +21,7 @@ "modsEnabled": true, "pluginsEnabled": false, "serverEnabled": false, - "distributionUrl": "https://example.com/launcher/map-mods.distribution.json", + "distributionUrl": "admin/data/distributions/template-map-mods.distribution.json", "worldArchiveUrl": "https://example.com/worlds/map-mods.zip", "worldDirectoryName": "Map Mods" }, @@ -33,7 +33,7 @@ "modsEnabled": false, "pluginsEnabled": true, "serverEnabled": true, - "distributionUrl": "https://example.com/launcher/map-plugin-server.distribution.json", + "distributionUrl": "admin/data/distributions/template-map-plugin-server.distribution.json", "worldArchiveUrl": "https://example.com/worlds/plugin-map.zip", "worldDirectoryName": "Plugin Map", "serverJarUrl": "https://example.com/server/paper.jar",