Add launcher admin catalog site
This commit is contained in:
467
admin/public/app.js
Normal file
467
admin/public/app.js
Normal file
@@ -0,0 +1,467 @@
|
||||
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 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'),
|
||||
defaultServerAddress: document.getElementById('field-defaultServerAddress'),
|
||||
allowCustomServerAddress: document.getElementById('field-allowCustomServerAddress'),
|
||||
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 createProfile(kind){
|
||||
const timestamp = Date.now()
|
||||
return {
|
||||
id: `${kind}-${timestamp}`,
|
||||
name: kind === 'map' ? '새 맵 프로필' : kind === 'server-pack' ? '새 서버팩 프로필' : '새 모드팩 프로필',
|
||||
kind,
|
||||
description: '',
|
||||
details: '',
|
||||
distributionUrl: '',
|
||||
defaultServerAddress: '',
|
||||
allowCustomServerAddress: kind !== 'map',
|
||||
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 = `
|
||||
<span class="badge">${profile.kind}</span>
|
||||
${profile.distributionUrl ? '<span class="badge">distribution 연결</span>' : '<span class="badge">distribution 비어있음</span>'}
|
||||
`
|
||||
|
||||
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 populateEditor(){
|
||||
const profile = getSelectedProfile()
|
||||
const hasSelection = profile != null
|
||||
|
||||
emptyState.hidden = hasSelection
|
||||
profileEditorForm.hidden = !hasSelection
|
||||
duplicateProfileButton.disabled = !hasSelection
|
||||
deleteProfileButton.disabled = !hasSelection
|
||||
|
||||
if(!profile){
|
||||
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
|
||||
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.defaultServerAddress.value = profile.defaultServerAddress ?? ''
|
||||
fieldElements.allowCustomServerAddress.checked = profile.allowCustomServerAddress === true
|
||||
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)
|
||||
}
|
||||
|
||||
function updateSelectedProfile(patch){
|
||||
const profile = getSelectedProfile()
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
Object.assign(profile, patch)
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
}
|
||||
|
||||
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 bindCheckboxField(fieldName){
|
||||
fieldElements[fieldName].addEventListener('change', (event) => {
|
||||
updateSelectedProfile({
|
||||
[fieldName]: event.target.checked
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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(){
|
||||
bindTextField('id')
|
||||
bindTextField('name')
|
||||
bindTextField('description')
|
||||
bindTextField('details')
|
||||
bindTextField('distributionUrl')
|
||||
bindTextField('defaultServerAddress')
|
||||
bindCheckboxField('allowCustomServerAddress')
|
||||
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 === 'map'){
|
||||
profile.allowCustomServerAddress = false
|
||||
}
|
||||
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()
|
||||
showStatus(`업로드 완료: ${result.file.path}`, 'success')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showStatus(error instanceof Error ? error.message : '업로드에 실패했습니다.', 'error')
|
||||
}
|
||||
})
|
||||
|
||||
picker.click()
|
||||
}
|
||||
|
||||
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()
|
||||
bindTopLevelActions()
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
loadMeta(),
|
||||
loadCatalog()
|
||||
])
|
||||
showStatus('관리자 사이트를 불러왔습니다. 저장하면 런처 카탈로그가 같이 갱신됩니다.', 'info')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
showStatus(error instanceof Error ? error.message : '관리자 사이트를 불러오지 못했습니다.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
Reference in New Issue
Block a user