Files
minecraft_launcher/admin/public/app.js
claude-bot 9e8fd9e74b
Some checks failed
Build / release (macos-latest) (push) Has been cancelled
Build / release (ubuntu-latest) (push) Has been cancelled
Build / release (windows-latest) (push) Has been cancelled
Windows Smoke Test / windows-smoke (push) Has been cancelled
Improve admin distribution editing flow
2026-05-05 22:32:55 +09:00

708 lines
24 KiB
JavaScript

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 distributionEditorStatus = document.getElementById('distributionEditorStatus')
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 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()
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) => `<span class="badge">${label}</span>`)
.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} | 내용을 불러와 수정할 수 있고, 저장하면 이 프로젝트 안의 로컬 distribution 파일로 복사됩니다.`
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'
clearDistributionEditorStatus()
}
function closeDistributionEditorModal(){
distributionEditorModal.hidden = true
document.body.style.overflow = ''
clearDistributionEditorStatus()
}
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 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){
showStatus('먼저 프로필을 선택하세요.', 'error')
return
}
openDistributionEditorModal()
distributionEditorTextarea.value = ''
updateDistributionEditorHint(profile)
showDistributionEditorStatus('distribution 내용을 준비하는 중...', 'info')
try {
const currentPath = String(profile.distributionUrl ?? '').trim()
if(mode === 'create' || currentPath.length === 0){
await loadDistributionTemplate()
updateDistributionEditorHint(profile, '')
showStatus('distribution 템플릿을 불러왔습니다.', 'success')
showDistributionEditorStatus('샘플을 불러왔습니다. 바로 수정한 뒤 저장하면 됩니다.', 'success')
return
}
if(isRemoteUrl(currentPath)){
await loadDistributionContent(currentPath)
updateDistributionEditorHint(profile, currentPath)
showStatus('원격 distribution 내용을 불러왔습니다.', 'success')
showDistributionEditorStatus('원격 distribution 내용을 불러왔습니다. 저장하면 로컬 파일로 복사됩니다.', 'success')
return
}
showStatus('distribution 파일을 불러오는 중...', 'info')
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')
}
}
async function saveDistributionFile(){
const profile = getSelectedProfile()
if(!profile){
showStatus('먼저 프로필을 선택하세요.', 'error')
return
}
try {
saveDistributionFileButton.disabled = true
showStatus('distribution 파일 저장 중...', 'info')
showDistributionEditorStatus('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')
showDistributionEditorStatus(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')
showDistributionEditorStatus('샘플을 다시 불러왔습니다.', 'success')
} catch (error) {
console.error(error)
showStatus(error instanceof Error ? error.message : 'distribution 템플릿을 불러오지 못했습니다.', 'error')
showDistributionEditorStatus(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()