Add admin distribution editor
This commit is contained in:
@@ -21,6 +21,14 @@ 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'),
|
||||
@@ -29,8 +37,6 @@ const fieldElements = {
|
||||
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'),
|
||||
@@ -50,6 +56,10 @@ function slugify(value){
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function isRemoteUrl(value){
|
||||
return /^https?:\/\//i.test(String(value ?? '').trim())
|
||||
}
|
||||
|
||||
function createProfile(kind){
|
||||
const timestamp = Date.now()
|
||||
return {
|
||||
@@ -59,8 +69,6 @@ function createProfile(kind){
|
||||
description: '',
|
||||
details: '',
|
||||
distributionUrl: '',
|
||||
defaultServerAddress: '',
|
||||
allowCustomServerAddress: kind !== 'map',
|
||||
worldArchiveUrl: '',
|
||||
worldDirectoryName: '',
|
||||
serverBundleUrl: '',
|
||||
@@ -143,6 +151,27 @@ function syncKindSections(kind){
|
||||
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
|
||||
@@ -154,6 +183,7 @@ function populateEditor(){
|
||||
|
||||
if(!profile){
|
||||
editorHint.textContent = '왼쪽에서 프로필을 선택하거나 새 프로필을 추가하세요.'
|
||||
updateDistributionEditorHint(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -165,8 +195,6 @@ function populateEditor(){
|
||||
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 ?? ''
|
||||
@@ -178,6 +206,7 @@ function populateEditor(){
|
||||
fieldElements.tunnelAddressRegex.value = profile.tunnelAddressRegex ?? ''
|
||||
|
||||
syncKindSections(profile.kind)
|
||||
updateDistributionEditorHint(profile)
|
||||
}
|
||||
|
||||
function updateSelectedProfile(patch){
|
||||
@@ -185,9 +214,14 @@ function updateSelectedProfile(patch){
|
||||
if(!profile){
|
||||
return
|
||||
}
|
||||
|
||||
Object.assign(profile, patch)
|
||||
markDirty(true)
|
||||
renderSidebar()
|
||||
|
||||
if(Object.prototype.hasOwnProperty.call(patch, 'distributionUrl')){
|
||||
updateDistributionEditorHint(profile, patch.distributionUrl)
|
||||
}
|
||||
}
|
||||
|
||||
function bindTextField(fieldName){
|
||||
@@ -214,14 +248,6 @@ function bindTextField(fieldName){
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -232,13 +258,15 @@ function bindNumberField(fieldName){
|
||||
}
|
||||
|
||||
function bindProfileForm(){
|
||||
profileEditorForm.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
bindTextField('id')
|
||||
bindTextField('name')
|
||||
bindTextField('description')
|
||||
bindTextField('details')
|
||||
bindTextField('distributionUrl')
|
||||
bindTextField('defaultServerAddress')
|
||||
bindCheckboxField('allowCustomServerAddress')
|
||||
bindTextField('worldArchiveUrl')
|
||||
bindTextField('worldDirectoryName')
|
||||
bindTextField('serverBundleUrl')
|
||||
@@ -257,10 +285,7 @@ function bindProfileForm(){
|
||||
|
||||
profile.kind = event.target.value
|
||||
|
||||
if(profile.kind === 'map'){
|
||||
profile.allowCustomServerAddress = false
|
||||
}
|
||||
if(profile.kind !== 'server-pack' && !profile.serverDirectoryName){
|
||||
if(profile.kind === 'server-pack' && !profile.serverDirectoryName){
|
||||
profile.serverDirectoryName = `${slugify(profile.id || profile.name) || 'profile'}-server`
|
||||
}
|
||||
|
||||
@@ -312,6 +337,11 @@ async function uploadIntoField(targetField, accept){
|
||||
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)
|
||||
@@ -322,6 +352,152 @@ async function uploadIntoField(targetField, accept){
|
||||
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()
|
||||
@@ -450,6 +626,7 @@ function bindTopLevelActions(){
|
||||
|
||||
async function bootstrap(){
|
||||
bindProfileForm()
|
||||
bindDistributionEditor()
|
||||
bindTopLevelActions()
|
||||
|
||||
try {
|
||||
|
||||
@@ -99,20 +99,17 @@
|
||||
</div>
|
||||
<div class="fieldGrid">
|
||||
<label class="fieldBlock fieldBlockFull">
|
||||
<span>distribution.json 경로 또는 URL</span>
|
||||
<span>distribution 파일</span>
|
||||
<div class="uploadField">
|
||||
<input id="field-distributionUrl" type="text" autocomplete="off">
|
||||
<button type="button" class="secondaryAction uploadButton" data-upload-target="distributionUrl" data-upload-accept=".json,application/json">파일 업로드</button>
|
||||
<button type="button" class="secondaryAction uploadButton" data-upload-target="distributionUrl" data-upload-accept=".json,application/json">JSON 업로드</button>
|
||||
<button type="button" id="editDistributionButton" class="secondaryAction">JSON 편집</button>
|
||||
<button type="button" id="createDistributionButton" class="secondaryAction">새로 만들기</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="fieldBlock">
|
||||
<span>기본 접속 주소</span>
|
||||
<input id="field-defaultServerAddress" type="text" autocomplete="off" placeholder="example.com:25565">
|
||||
</label>
|
||||
<label class="toggleBlock">
|
||||
<input id="field-allowCustomServerAddress" type="checkbox">
|
||||
<span>사용자가 라이브러리에서 주소를 바꿀 수 있음</span>
|
||||
</label>
|
||||
<div class="fieldHelpText fieldBlockFull">
|
||||
distribution.json은 여기서 직접 업로드하거나 JSON 편집기로 새로 만들 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -184,6 +181,24 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="distributionEditorModal" class="modalBackdrop" hidden>
|
||||
<div class="modalPanel">
|
||||
<div class="modalHeader">
|
||||
<div>
|
||||
<span class="eyebrow">Distribution Editor</span>
|
||||
<h3>distribution.json 편집</h3>
|
||||
<p id="distributionEditorHint">프로필에 연결할 distribution.json 내용을 사이트 안에서 직접 관리합니다.</p>
|
||||
</div>
|
||||
<button type="button" id="closeDistributionEditorButton" class="secondaryAction">닫기</button>
|
||||
</div>
|
||||
<textarea id="distributionEditorTextarea" class="distributionTextarea" spellcheck="false"></textarea>
|
||||
<div class="modalActions">
|
||||
<button type="button" id="loadDistributionTemplateButton" class="secondaryAction">샘플 불러오기</button>
|
||||
<button type="button" id="saveDistributionFileButton" class="primaryAction">distribution 저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -278,11 +278,17 @@ textarea {
|
||||
|
||||
.uploadField {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fieldHelpText {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction,
|
||||
.dangerAction {
|
||||
@@ -346,6 +352,53 @@ textarea {
|
||||
background: rgba(240, 191, 87, 0.1);
|
||||
}
|
||||
|
||||
.modalBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(5, 6, 6, 0.72);
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modalPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: min(1100px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background: #111412;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.distributionTextarea {
|
||||
min-height: 420px;
|
||||
resize: vertical;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.adminShell {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user