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 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'), kind: document.getElementById('field-kind'), name: document.getElementById('field-name'), description: document.getElementById('field-description'), details: document.getElementById('field-details'), distributionUrl: document.getElementById('field-distributionUrl'), worldArchiveUrl: document.getElementById('field-worldArchiveUrl'), worldDirectoryName: document.getElementById('field-worldDirectoryName'), serverBundleUrl: document.getElementById('field-serverBundleUrl'), serverDirectoryName: document.getElementById('field-serverDirectoryName'), serverLaunchCommand: document.getElementById('field-serverLaunchCommand'), serverWorkingDirectory: document.getElementById('field-serverWorkingDirectory'), serverPort: document.getElementById('field-serverPort'), tunnelCommand: document.getElementById('field-tunnelCommand'), tunnelAddressRegex: document.getElementById('field-tunnelAddressRegex') } 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(kind){ const timestamp = Date.now() return { id: `${kind}-${timestamp}`, name: kind === 'map' ? '새 맵 프로필' : kind === 'server-pack' ? '새 서버팩 프로필' : '새 모드팩 프로필', kind, description: '', details: '', distributionUrl: '', worldArchiveUrl: '', worldDirectoryName: '', serverBundleUrl: '', serverDirectoryName: `${kind}-${timestamp}-server`, serverLaunchCommand: '', serverWorkingDirectory: '', serverPort: 25565, tunnelCommand: '', tunnelAddressRegex: '' } } 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 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 = ` ${profile.kind} ${profile.distributionUrl ? 'distribution 연결' : 'distribution 비어있음'} ` 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 syncKindSections(kind){ mapSection.hidden = kind !== 'map' 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 emptyState.hidden = hasSelection profileEditorForm.hidden = !hasSelection duplicateProfileButton.disabled = !hasSelection deleteProfileButton.disabled = !hasSelection if(!profile){ editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.' updateDistributionEditorHint(null) return } editorHint.textContent = `${profile.kind} 프로필을 편집 중입니다. 저장하면 런처 카탈로그도 같이 갱신됩니다.` fieldElements.id.value = profile.id ?? '' fieldElements.kind.value = profile.kind ?? 'modpack' fieldElements.name.value = profile.name ?? '' fieldElements.description.value = profile.description ?? '' fieldElements.details.value = profile.details ?? '' fieldElements.distributionUrl.value = profile.distributionUrl ?? '' fieldElements.worldArchiveUrl.value = profile.worldArchiveUrl ?? '' fieldElements.worldDirectoryName.value = profile.worldDirectoryName ?? '' fieldElements.serverBundleUrl.value = profile.serverBundleUrl ?? '' fieldElements.serverDirectoryName.value = profile.serverDirectoryName ?? '' fieldElements.serverLaunchCommand.value = profile.serverLaunchCommand ?? '' fieldElements.serverWorkingDirectory.value = profile.serverWorkingDirectory ?? '' fieldElements.serverPort.value = profile.serverPort ?? 25565 fieldElements.tunnelCommand.value = profile.tunnelCommand ?? '' fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? '' syncKindSections(profile.kind) updateDistributionEditorHint(profile) } function updateSelectedProfile(patch){ const profile = getSelectedProfile() if(!profile){ return } Object.assign(profile, patch) markDirty(true) renderSidebar() 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){ fieldElements[fieldName].addEventListener('input', (event) => { const value = Number.parseInt(event.target.value || '25565', 10) updateSelectedProfile({ [fieldName]: Number.isFinite(value) ? value : 25565 }) }) } function bindProfileForm(){ profileEditorForm.addEventListener('submit', (event) => { event.preventDefault() }) bindTextField('id') bindTextField('name') bindTextField('description') bindTextField('details') bindTextField('distributionUrl') bindTextField('worldArchiveUrl') bindTextField('worldDirectoryName') bindTextField('serverBundleUrl') bindTextField('serverDirectoryName') bindTextField('serverLaunchCommand') bindTextField('serverWorkingDirectory') bindNumberField('serverPort') bindTextField('tunnelCommand') bindTextField('tunnelAddressRegex') fieldElements.kind.addEventListener('change', (event) => { const profile = getSelectedProfile() if(!profile){ return } profile.kind = event.target.value if(profile.kind === 'server-pack' && !profile.serverDirectoryName){ profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server` } markDirty(true) renderSidebar() populateEditor() }) 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 } 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 : [] } if(state.catalog.profiles.length > 0){ state.selectedProfileId = state.catalog.profiles[0].id } else { state.selectedProfileId = null } markDirty(false) renderSidebar() populateEditor() } function addProfile(kind){ const profile = createProfile(kind) 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 = result.catalog 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(){ for(const button of document.querySelectorAll('[data-add-kind]')){ button.addEventListener('click', () => { addProfile(button.dataset.addKind) }) } 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()