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()