const state = { catalog: { version: 1, profiles: [] }, meta: null, selectedProfileId: null, dirty: false } const profileList = document.getElementById('profileList') const profileCount = document.getElementById('profileCount') const localCatalogUrl = document.getElementById('localCatalogUrl') const launcherCatalogPath = document.getElementById('launcherCatalogPath') const emptyState = document.getElementById('emptyState') const profileEditorForm = document.getElementById('profileEditorForm') const statusBanner = document.getElementById('statusBanner') const editorHint = document.getElementById('editorHint') const saveCatalogButton = document.getElementById('saveCatalogButton') const duplicateProfileButton = document.getElementById('duplicateProfileButton') const deleteProfileButton = document.getElementById('deleteProfileButton') 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') 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'), 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'), serverJarUrl: document.getElementById('field-serverJarUrl'), serverPort: document.getElementById('field-serverPort'), serverMemoryMb: document.getElementById('field-serverMemoryMb'), serverMaxPlayers: document.getElementById('field-serverMaxPlayers'), serverWhitelistEnabled: document.getElementById('field-serverWhitelistEnabled') } function slugify(value){ return String(value ?? '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') } function isRemoteUrl(value){ return /^https?:\/\//i.test(String(value ?? '').trim()) } function createProfile(){ const timestamp = Date.now() return { id: `profile-${timestamp}`, name: '새 프로필', description: '', details: '', modsEnabled: false, pluginsEnabled: false, serverEnabled: false, distributionUrl: '', worldArchiveUrl: '', worldDirectoryName: '', serverJarUrl: '', serverPort: 25565, serverMemoryMb: 4096, serverMaxPlayers: 20, serverWhitelistEnabled: false } } function getSelectedProfile(){ return state.catalog.profiles.find((profile) => profile.id === state.selectedProfileId) ?? null } function markDirty(nextDirty = true){ state.dirty = nextDirty saveCatalogButton.textContent = nextDirty ? '카탈로그 저장 *' : '카탈로그 저장' } function showStatus(message, tone = 'info'){ statusBanner.hidden = false statusBanner.dataset.tone = tone statusBanner.textContent = message } function clearStatus(){ statusBanner.hidden = true statusBanner.textContent = '' delete statusBanner.dataset.tone } function selectProfile(profileId){ state.selectedProfileId = profileId renderSidebar() 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}개` for(const profile of state.catalog.profiles){ const button = document.createElement('button') button.type = 'button' button.className = 'profileListItem' if(profile.id === state.selectedProfileId){ button.setAttribute('selected', 'true') } const title = document.createElement('div') title.className = 'profileListName' title.textContent = profile.name || profile.id const meta = document.createElement('div') meta.className = 'profileListMeta' meta.innerHTML = describeProfileFeatures(profile) .map((label) => `${label}`) .join('') const description = document.createElement('div') description.className = 'profileListDescription' description.textContent = profile.description || '설명이 없습니다.' button.appendChild(title) button.appendChild(meta) button.appendChild(description) button.addEventListener('click', () => { selectProfile(profile.id) }) profileList.appendChild(button) } } 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){ 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 emptyState.hidden = hasSelection profileEditorForm.hidden = !hasSelection duplicateProfileButton.disabled = !hasSelection deleteProfileButton.disabled = !hasSelection if(!profile){ editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.' syncServerSection(null) updateDistributionEditorHint(null) return } syncFeatureDependencies(profile) editorHint.textContent = '맵은 기본이고, 모드/플러그인/서버를 체크해서 조합하는 프로필입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.' fieldElements.id.value = profile.id ?? '' 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.serverJarUrl.value = profile.serverJarUrl ?? '' fieldElements.serverPort.value = profile.serverPort ?? 25565 fieldElements.serverMemoryMb.value = profile.serverMemoryMb ?? 4096 fieldElements.serverMaxPlayers.value = profile.serverMaxPlayers ?? 20 fieldElements.serverWhitelistEnabled.checked = profile.serverWhitelistEnabled === true syncServerSection(profile) updateDistributionEditorHint(profile) } function updateSelectedProfile(patch){ const profile = getSelectedProfile() if(!profile){ return } Object.assign(profile, patch) syncFeatureDependencies(profile) markDirty(true) renderSidebar() syncServerSection(profile) if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){ updateDistributionEditorHint(profile, patch.distributionUrl) } } function bindTextField(fieldName){ fieldElements[fieldName].addEventListener('input', (event) => { if(fieldName === 'id'){ const profile = getSelectedProfile() if(!profile){ return } const previousId = profile.id profile.id = event.target.value state.selectedProfileId = profile.id markDirty(true) renderSidebar() if(previousId !== profile.id){ showStatus('프로필 ID를 바꿨습니다. 저장하면 이 ID로 반영됩니다.', 'info') } return } updateSelectedProfile({ [fieldName]: event.target.value }) }) } function bindNumberField(fieldName, fallback){ fieldElements[fieldName].addEventListener('input', (event) => { const value = Number.parseInt(event.target.value || String(fallback), 10) updateSelectedProfile({ [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() }) bindTextField('id') bindTextField('name') bindTextField('description') bindTextField('details') bindTextField('distributionUrl') bindTextField('worldArchiveUrl') bindTextField('worldDirectoryName') 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 () => { const targetField = button.dataset.uploadTarget const accept = button.dataset.uploadAccept await uploadIntoField(targetField, accept) }) } } async function uploadIntoField(targetField, accept){ const profile = getSelectedProfile() if(!profile){ return } const picker = document.createElement('input') picker.type = 'file' picker.accept = accept || '' picker.addEventListener('change', async () => { const file = picker.files?.[0] if(!file){ return } const formData = new FormData() formData.append('file', file) try { showStatus(`${file.name} 업로드 중...`, 'info') const response = await fetch('/api/upload', { method: 'POST', body: formData }) const result = await response.json() if(!response.ok || result.ok !== true){ throw new Error(result.message || '업로드에 실패했습니다.') } profile[targetField] = result.file.path 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) showStatus(error instanceof Error ? error.message : '업로드에 실패했습니다.', 'error') } }) 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() state.meta = meta launcherCatalogPath.textContent = meta.launcherCatalogPath localCatalogUrl.textContent = meta.localCatalogUrl 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.map(normalizeLoadedProfile) : [] } if(state.catalog.profiles.length > 0){ state.selectedProfileId = state.catalog.profiles[0].id } else { state.selectedProfileId = null } markDirty(false) renderSidebar() populateEditor() } function addProfile(){ const profile = createProfile() state.catalog.profiles.push(profile) markDirty(true) selectProfile(profile.id) showStatus(`${profile.name} 프로필을 추가했습니다.`, 'success') } function duplicateSelectedProfile(){ const profile = getSelectedProfile() if(!profile){ return } const clonedProfile = structuredClone(profile) clonedProfile.id = `${slugify(profile.id || profile.name) || 'profile'}-copy-${Date.now()}` clonedProfile.name = `${profile.name} 복제본` state.catalog.profiles.push(clonedProfile) markDirty(true) selectProfile(clonedProfile.id) showStatus('복제본을 만들었습니다.', 'success') } function deleteSelectedProfile(){ const profile = getSelectedProfile() if(!profile){ return } const confirmed = window.confirm(`'${profile.name}' 프로필을 삭제할까요?`) if(!confirmed){ return } state.catalog.profiles = state.catalog.profiles.filter((entry) => entry.id !== profile.id) state.selectedProfileId = state.catalog.profiles[0]?.id ?? null markDirty(true) renderSidebar() populateEditor() showStatus('프로필을 삭제했습니다.', 'success') } async function saveCatalog(){ try { clearStatus() saveCatalogButton.disabled = true showStatus('카탈로그 저장 중...', 'info') const response = await fetch('/api/catalog', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(state.catalog) }) const result = await response.json() if(!response.ok || result.ok !== true){ throw new Error(result.message || '카탈로그 저장에 실패했습니다.') } 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 } markDirty(false) renderSidebar() populateEditor() showStatus('카탈로그를 저장했고 런처 카탈로그에도 동기화했습니다.', 'success') } catch (error) { console.error(error) showStatus(error instanceof Error ? error.message : '카탈로그 저장에 실패했습니다.', 'error') } finally { saveCatalogButton.disabled = false } } function bindTopLevelActions(){ addProfileButton.addEventListener('click', () => { addProfile() }) saveCatalogButton.addEventListener('click', async () => { await saveCatalog() }) duplicateProfileButton.addEventListener('click', () => { duplicateSelectedProfile() }) deleteProfileButton.addEventListener('click', () => { deleteSelectedProfile() }) } async function bootstrap(){ bindProfileForm() bindDistributionEditor() bindTopLevelActions() try { await Promise.all([ loadMeta(), loadCatalog() ]) showStatus('관리자 사이트를 불러왔습니다. 저장하면 런처 카탈로그가 같이 갱신됩니다.', 'info') } catch (error) { console.error(error) showStatus(error instanceof Error ? error.message : '관리자 사이트를 불러오지 못했습니다.', 'error') } } bootstrap()