Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c580a50fd4 | |||
| 38df72e4f6 | |||
| 6447b1cb78 | |||
| 9ba5dc6b7b | |||
| 05dc9d7166 | |||
| 25977d894b | |||
| c14b0507c7 |
@@ -141,14 +141,55 @@ function renderStep1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
// 약관 동의 페이지: 1단계 직후, 2단계 설치 진입 전에 노출.
|
||||||
// rp 인스톨러는 리소스팩·설치기 두 약관만 확인·동의하면 된다.
|
// v0.3.4~ : 사이트의 visibility 토글에 따라 표시할 약관이 결정된다. 명시적으로 빈 목록(terms:[])
|
||||||
|
// 정상 응답일 때만 단계를 건너뛰고, 네트워크/서버 오류는 차단 후 다시 시도 UI를 보여준다.
|
||||||
function renderAgreement() {
|
function renderAgreement() {
|
||||||
setActiveStep(1)
|
setActiveStep(1)
|
||||||
clearPage()
|
clearPage()
|
||||||
var KINDS = [
|
var loadingSection = document.createElement('section')
|
||||||
{ id: 'resourcepack', tab: tt('agreement.tabResourcepack') },
|
loadingSection.className = 'page'
|
||||||
{ id: 'installer-rp', tab: tt('agreement.tabInstaller') }
|
loadingSection.innerHTML = '<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||||
]
|
'<p class="formMessage">' + escapeHtml(tt('agreement.loading')) + '</p>'
|
||||||
|
pageHost.appendChild(loadingSection)
|
||||||
|
|
||||||
|
api.getTermsList().then(function (res) {
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
showAgreementError((res && res.message) || 'unknown')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var terms = (res.terms || []).map(function (t) {
|
||||||
|
return { id: t.kind, tab: t.label }
|
||||||
|
})
|
||||||
|
if (terms.length === 0) {
|
||||||
|
renderStep2()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearPage()
|
||||||
|
renderAgreementWithKinds(terms)
|
||||||
|
}).catch(function (err) {
|
||||||
|
showAgreementError(err && err.message ? err.message : 'unknown')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도 옵션. 동의 없이 설치 단계로
|
||||||
|
// 자동 진입하지 않도록 next 버튼을 두지 않는다.
|
||||||
|
function showAgreementError(message) {
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>' + escapeHtml(tt('agreement.heading')) + '</h2>' +
|
||||||
|
'<p class="formMessage error">' + escapeHtml(tt('agreement.listLoadFailed', { message: message })) + '</p>' +
|
||||||
|
'<div class="actionRow">' +
|
||||||
|
'<button class="secondaryBtn" id="back">' + escapeHtml(tt('common.back')) + '</button>' +
|
||||||
|
'<button class="primaryBtn" id="retry">' + escapeHtml(tt('agreement.retry')) + '</button>' +
|
||||||
|
'</div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||||
|
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgreementWithKinds(KINDS) {
|
||||||
var section = document.createElement('section')
|
var section = document.createElement('section')
|
||||||
section.className = 'page'
|
section.className = 'page'
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
|
|||||||
@@ -149,15 +149,58 @@ function renderStep1() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
|
// 약관 동의 페이지: 음악퀴즈 선택 직후, 싱글/멀티 선택(step2) 진입 전에 노출.
|
||||||
// 메인 설치기는 맵·모드·설치기 세 약관을 모두 확인·동의해야 다음 단계로 갈 수 있다.
|
// v0.3.4~ : 어떤 약관을 표시할지는 사이트(/manifest/terms/<pack>/index.json) 가
|
||||||
|
// 결정. 메인 인스톨러용으로 표시 토글된 항목만 받아 탭을 만든다. 목록이 비어 있는 (terms:[])
|
||||||
|
// 정상 응답일 때만 단계 자체를 건너뛴다. 네트워크 오류/404/서버 오류는 사용자가 약관 동의
|
||||||
|
// 없이 설치로 넘어가는 것을 막기 위해 오류 화면 + 다시 시도 버튼으로 차단한다.
|
||||||
function renderAgreement() {
|
function renderAgreement() {
|
||||||
setActiveStep(1)
|
setActiveStep(1)
|
||||||
clearPage()
|
clearPage()
|
||||||
var KINDS = [
|
var loadingSection = document.createElement('section')
|
||||||
{ id: 'map', tab: tt('agreement.tabMap') },
|
loadingSection.className = 'page'
|
||||||
{ id: 'mod', tab: tt('agreement.tabMod') },
|
loadingSection.innerHTML = '<h2>' + tt('agreement.heading') + '</h2>' +
|
||||||
{ id: 'installer', tab: tt('agreement.tabInstaller') }
|
'<p class="formMessage">' + tt('agreement.loading') + '</p>'
|
||||||
]
|
pageHost.appendChild(loadingSection)
|
||||||
|
|
||||||
|
installerApi.getTermsList().then(function (res) {
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
showAgreementError((res && res.message) || 'unknown')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var terms = (res.terms || []).map(function (t) {
|
||||||
|
return { id: t.kind, tab: t.label }
|
||||||
|
})
|
||||||
|
if (terms.length === 0) {
|
||||||
|
// 명시적으로 표시 대상이 0개라고 서버가 알려준 정상 응답 → 약관 단계 스킵.
|
||||||
|
renderStep2()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clearPage()
|
||||||
|
renderAgreementWithKinds(terms)
|
||||||
|
}).catch(function (err) {
|
||||||
|
showAgreementError(err && err.message ? err.message : 'unknown')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 약관 목록을 못 받아왔을 때: 사용자에게 오류 + 다시 시도/뒤로 가기 옵션을 보여준다.
|
||||||
|
// 동의 없이 설치 단계로 넘어가지 않도록 next 버튼을 두지 않는다.
|
||||||
|
function showAgreementError(message) {
|
||||||
|
clearPage()
|
||||||
|
var section = document.createElement('section')
|
||||||
|
section.className = 'page'
|
||||||
|
section.innerHTML =
|
||||||
|
'<h2>' + tt('agreement.heading') + '</h2>' +
|
||||||
|
'<p class="formMessage error">' + tt('agreement.listLoadFailed', { message: message }) + '</p>' +
|
||||||
|
'<div class="actionRow">' +
|
||||||
|
'<button class="secondaryBtn" id="back">' + tt('common.back') + '</button>' +
|
||||||
|
'<button class="primaryBtn" id="retry">' + tt('agreement.retry') + '</button>' +
|
||||||
|
'</div>'
|
||||||
|
pageHost.appendChild(section)
|
||||||
|
section.querySelector('#back').addEventListener('click', renderStep1)
|
||||||
|
section.querySelector('#retry').addEventListener('click', renderAgreement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAgreementWithKinds(KINDS) {
|
||||||
var section = document.createElement('section')
|
var section = document.createElement('section')
|
||||||
section.className = 'page'
|
section.className = 'page'
|
||||||
section.innerHTML =
|
section.innerHTML =
|
||||||
@@ -165,7 +208,7 @@ function renderAgreement() {
|
|||||||
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
|
'<p class="formMessage">' + tt('agreement.intro') + '</p>' +
|
||||||
'<div class="tabBar" id="agTabs">' +
|
'<div class="tabBar" id="agTabs">' +
|
||||||
KINDS.map(function (k, i) {
|
KINDS.map(function (k, i) {
|
||||||
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + k.id + '">' + k.tab + '</button>'
|
return '<button type="button" class="tabBtn' + (i === 0 ? ' active' : '') + '" data-ag="' + escapeHtml(k.id) + '">' + escapeHtml(k.tab) + '</button>'
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
|
'<div class="agreementBody" id="agBody">' + tt('agreement.loading') + '</div>' +
|
||||||
@@ -917,6 +960,12 @@ function renderStep5() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : '''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
|
||||||
;(async function () {
|
;(async function () {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
"tabInstaller": "리소스팩 설치기 약관",
|
"tabInstaller": "리소스팩 설치기 약관",
|
||||||
"loading": "약관을 불러오는 중...",
|
"loading": "약관을 불러오는 중...",
|
||||||
"loadFailed": "약관 로드 실패: {{message}}",
|
"loadFailed": "약관 로드 실패: {{message}}",
|
||||||
"agreeAll": "위 모든 약관(리소스팩·설치기)에 동의합니다.",
|
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||||
|
"retry": "다시 시도",
|
||||||
|
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
|
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다.",
|
||||||
"cancelling": "취소 중…"
|
"cancelling": "취소 중…"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
"tabInstaller": "설치기 약관",
|
"tabInstaller": "설치기 약관",
|
||||||
"loading": "약관을 불러오는 중...",
|
"loading": "약관을 불러오는 중...",
|
||||||
"loadFailed": "약관 로드 실패: {{message}}",
|
"loadFailed": "약관 로드 실패: {{message}}",
|
||||||
"agreeAll": "위 모든 약관(맵·모드·설치기)에 동의합니다.",
|
"listLoadFailed": "약관 표시에 실패하여 설치를 진행할 수 없습니다.\n사유: {{message}}\n네트워크 상태를 확인하고 다시 시도하거나, 처음 단계로 돌아가 주세요.",
|
||||||
|
"retry": "다시 시도",
|
||||||
|
"agreeAll": "위 모든 약관에 동의합니다.",
|
||||||
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
|
"agreeRequired": "약관에 동의해야 다음 단계로 진행할 수 있습니다."
|
||||||
},
|
},
|
||||||
"step1": {
|
"step1": {
|
||||||
|
|||||||
@@ -136,6 +136,9 @@
|
|||||||
"terms": {
|
"terms": {
|
||||||
"browserTitle": "약관 수정",
|
"browserTitle": "약관 수정",
|
||||||
"title": "약관 수정",
|
"title": "약관 수정",
|
||||||
|
"pickPackHint": "약관을 수정할 음악퀴즈를 선택하세요. 각 음악퀴즈마다 약관을 따로 보관합니다.",
|
||||||
|
"packBrowserTitle": "{{name}} — 약관 수정",
|
||||||
|
"packTitle": "{{name}} 약관 수정",
|
||||||
"hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.",
|
"hint": "수정할 약관을 선택하세요. 사이트에서 저장한 내용은 인스톨러가 약관 동의 화면에서 사용합니다.",
|
||||||
"editorBrowserTitle": "{{label}} 편집",
|
"editorBrowserTitle": "{{label}} 편집",
|
||||||
"editorTitle": "{{label}}",
|
"editorTitle": "{{label}}",
|
||||||
@@ -156,7 +159,33 @@
|
|||||||
"slashDivider": "구분선",
|
"slashDivider": "구분선",
|
||||||
"slashQuote": "인용",
|
"slashQuote": "인용",
|
||||||
"slashCode": "코드",
|
"slashCode": "코드",
|
||||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
|
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
|
||||||
|
"visibilityHeading": "표시 대상 (중복 선택 가능)",
|
||||||
|
"visibilityInstaller": "설치기에 표시",
|
||||||
|
"visibilityInstallerRp": "리소스팩 설치기에 표시",
|
||||||
|
"visibilityInstallerShort": "설치기",
|
||||||
|
"visibilityInstallerRpShort": "리소스팩",
|
||||||
|
"addHeading": "약관 추가",
|
||||||
|
"kindLabel": "식별자",
|
||||||
|
"kindPlaceholder": "예: privacy",
|
||||||
|
"kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.",
|
||||||
|
"labelLabel": "표시 이름",
|
||||||
|
"labelPlaceholder": "예: 개인정보 처리방침",
|
||||||
|
"addButton": "추가",
|
||||||
|
"deleteButton": "삭제",
|
||||||
|
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
|
||||||
|
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
|
||||||
|
"createFailed": "약관 추가 실패",
|
||||||
|
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.",
|
||||||
|
"importHeading": "다른 음악퀴즈에서 불러오기",
|
||||||
|
"importSourceLabel": "가져올 음악퀴즈",
|
||||||
|
"importSourcePlaceholder": "음악퀴즈를 선택하세요",
|
||||||
|
"importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.",
|
||||||
|
"importButton": "불러오기",
|
||||||
|
"importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.",
|
||||||
|
"importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?",
|
||||||
|
"importFailed": "약관 불러오기 실패",
|
||||||
|
"invalidImportSource": "올바르지 않은 음악퀴즈입니다."
|
||||||
},
|
},
|
||||||
"datapack": {
|
"datapack": {
|
||||||
"browserTitle": "데이터팩 수정",
|
"browserTitle": "데이터팩 수정",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "minecraft-music-quiz-installer",
|
"name": "minecraft-music-quiz-installer",
|
||||||
"version": "0.3.0",
|
"version": "0.3.4",
|
||||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||||
"main": "dist/installer/main.js",
|
"main": "dist/installer/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* Notion 스타일 약관 편집기 전용 스타일.
|
/* Notion 스타일 약관 편집기 전용 스타일.
|
||||||
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
|
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
|
||||||
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
|
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
|
||||||
* 절대 위치로 띄운다. */
|
* 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */
|
||||||
|
|
||||||
.termsEditorWrap {
|
.termsEditorWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -12,9 +12,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
border: 1px solid #d5d5d5;
|
border: 1px solid var(--border, #30363d);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fff;
|
background: var(--bg-card, #1f242c);
|
||||||
|
color: var(--text, #e6edf3);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
@@ -22,19 +23,21 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
caret-color: var(--accent, #58a6ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.termsEditor:focus {
|
.termsEditor:focus {
|
||||||
border-color: #5b8def;
|
border-color: var(--accent, #58a6ff);
|
||||||
box-shadow: 0 0 0 2px rgba(91, 141, 239, 0.2);
|
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.termsPreview {
|
.termsPreview {
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
border: 1px solid #d5d5d5;
|
border: 1px solid var(--border, #30363d);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #fafafa;
|
background: var(--bg-alt, #161b22);
|
||||||
|
color: var(--text, #e6edf3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -46,23 +49,30 @@
|
|||||||
.termsPreview p { margin: 6px 0; }
|
.termsPreview p { margin: 6px 0; }
|
||||||
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
|
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
|
||||||
.termsPreview li { margin: 2px 0; }
|
.termsPreview li { margin: 2px 0; }
|
||||||
.termsPreview hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; }
|
.termsPreview hr { border: none; border-top: 1px solid var(--border, #30363d); margin: 12px 0; }
|
||||||
.termsPreview blockquote {
|
.termsPreview blockquote {
|
||||||
margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ddd; color: #555;
|
margin: 8px 0; padding: 4px 12px;
|
||||||
|
border-left: 3px solid var(--border, #30363d);
|
||||||
|
color: var(--text-muted, #8b949e);
|
||||||
}
|
}
|
||||||
.termsPreview code {
|
.termsPreview code {
|
||||||
background: #eee; padding: 1px 5px; border-radius: 4px;
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 1px 5px; border-radius: 4px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.termsPreview pre {
|
.termsPreview pre {
|
||||||
background: #f0f0f0; padding: 10px 12px; border-radius: 6px; overflow: auto;
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
padding: 10px 12px; border-radius: 6px; overflow: auto;
|
||||||
}
|
}
|
||||||
.termsPreview pre code { background: transparent; padding: 0; }
|
.termsPreview pre code { background: transparent; padding: 0; }
|
||||||
.termsPreview a { color: #2664d8; text-decoration: underline; word-break: break-all; }
|
.termsPreview a { color: var(--accent, #58a6ff); text-decoration: underline; word-break: break-all; }
|
||||||
.termsPreview details {
|
.termsPreview details {
|
||||||
margin: 6px 0; border: 1px solid #e0e0e0; border-radius: 6px;
|
margin: 6px 0;
|
||||||
background: #fff; padding: 4px 10px;
|
border: 1px solid var(--border, #30363d);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card, #1f242c);
|
||||||
|
padding: 4px 10px;
|
||||||
}
|
}
|
||||||
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
|
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
|
||||||
|
|
||||||
@@ -73,10 +83,11 @@
|
|||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #fff;
|
background: var(--bg-alt, #161b22);
|
||||||
border: 1px solid #ccc;
|
color: var(--text, #e6edf3);
|
||||||
|
border: 1px solid var(--border, #30363d);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -89,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
.slashMenu .slashItem:hover,
|
.slashMenu .slashItem:hover,
|
||||||
.slashMenu .slashItem.active {
|
.slashMenu .slashItem.active {
|
||||||
background: #eef2ff;
|
background: var(--bg-card, #1f242c);
|
||||||
}
|
}
|
||||||
.slashMenu .slashItem strong { font-size: 13px; }
|
.slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); }
|
||||||
.slashMenu .slashItem span { color: #888; font-size: 11px; }
|
.slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; }
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
var dirtyMark = document.getElementById('dirty-mark')
|
var dirtyMark = document.getElementById('dirty-mark')
|
||||||
var saveBtn = document.getElementById('saveBtn')
|
var saveBtn = document.getElementById('saveBtn')
|
||||||
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
||||||
|
var visInstaller = document.getElementById('visInstaller')
|
||||||
|
var visInstallerRp = document.getElementById('visInstallerRp')
|
||||||
|
|
||||||
editor.value = INITIAL || ''
|
editor.value = INITIAL || ''
|
||||||
var dirty = false
|
var dirty = false
|
||||||
@@ -23,6 +25,10 @@
|
|||||||
dirtyMark.hidden = !v
|
dirtyMark.hidden = !v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 토글이 바뀌어도 dirty 표시. 저장 시 함께 전송된다.
|
||||||
|
if (visInstaller) visInstaller.addEventListener('change', function () { setDirty(true) })
|
||||||
|
if (visInstallerRp) visInstallerRp.addEventListener('change', function () { setDirty(true) })
|
||||||
|
|
||||||
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
||||||
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
||||||
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
||||||
@@ -162,10 +168,13 @@
|
|||||||
function save() {
|
function save() {
|
||||||
status.classList.remove('error')
|
status.classList.remove('error')
|
||||||
status.textContent = I18N.saving
|
status.textContent = I18N.saving
|
||||||
fetch('/op/agreement/' + encodeURIComponent(TERM_KIND), {
|
var payload = { content: editor.value }
|
||||||
|
if (visInstaller) payload.showInInstaller = !!visInstaller.checked
|
||||||
|
if (visInstallerRp) payload.showInInstallerRp = !!visInstallerRp.checked
|
||||||
|
fetch('/op/agreement/' + encodeURIComponent(PACK_KEY) + '/' + encodeURIComponent(TERM_KIND), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: editor.value })
|
body: JSON.stringify(payload)
|
||||||
}).then(function (r) {
|
}).then(function (r) {
|
||||||
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
||||||
}).then(function (res) {
|
}).then(function (res) {
|
||||||
|
|||||||
@@ -252,14 +252,16 @@ ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
|
|||||||
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
ipcMain.handle('rp:i18n:dict', () => localeDict)
|
||||||
|
|
||||||
// ── IPC: 약관 다운로드 ──────────────────────────────
|
// ── IPC: 약관 다운로드 ──────────────────────────────
|
||||||
// 사이트가 /manifest/terms/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
|
// v0.3.4~ : 사이트에서 임의 kind 가 만들어질 수 있으니 5종 화이트리스트 대신
|
||||||
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
|
// kind 형식만 검증한다. 어떤 약관을 rp 인스톨러에 보여줄지는 사이트의 visibility 토글이 결정.
|
||||||
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
|
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
|
||||||
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
||||||
if (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown term kind' }
|
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
|
||||||
|
return { ok: false, message: 'invalid term kind' }
|
||||||
|
}
|
||||||
|
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||||
try {
|
try {
|
||||||
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(kind)}.md`
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${encodeURIComponent(kind)}.md`
|
||||||
const buf = await fetchBuffer(url)
|
const buf = await fetchBuffer(url)
|
||||||
return { ok: true, content: buf.toString('utf8') }
|
return { ok: true, content: buf.toString('utf8') }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -267,6 +269,31 @@ ipcMain.handle('rp:terms:get', async (_event, kind: string) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// rp 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
|
||||||
|
// showInInstallerRp=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
|
||||||
|
ipcMain.handle('rp:terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
|
||||||
|
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||||
|
try {
|
||||||
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
|
||||||
|
const buf = await fetchBuffer(url)
|
||||||
|
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
|
||||||
|
const items = Array.isArray(parsed.terms) ? parsed.terms : []
|
||||||
|
const terms: Array<{ kind: string; label: string }> = []
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it || typeof it !== 'object') continue
|
||||||
|
const entry = it as Record<string, unknown>
|
||||||
|
if (entry.showInInstallerRp !== true) continue
|
||||||
|
const kind = typeof entry.kind === 'string' ? entry.kind : ''
|
||||||
|
const label = typeof entry.label === 'string' ? entry.label : ''
|
||||||
|
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
|
||||||
|
terms.push({ kind, label })
|
||||||
|
}
|
||||||
|
return { ok: true, terms }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: (error as Error).message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── IPC: 2단계 설치 ──────────────────────────────────
|
// ── IPC: 2단계 설치 ──────────────────────────────────
|
||||||
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
|
||||||
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ const api = {
|
|||||||
selectPack: (packKey: string): Promise<void> =>
|
selectPack: (packKey: string): Promise<void> =>
|
||||||
ipcRenderer.invoke('rp:packs:select', packKey),
|
ipcRenderer.invoke('rp:packs:select', packKey),
|
||||||
|
|
||||||
/** 약관(Markdown) 다운로드. kind: 'resourcepack' | 'installer-rp'. */
|
/** 약관(Markdown) 다운로드. v0.3.4~ : 임의 kind 허용 (사이트에서 설정). */
|
||||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||||
ipcRenderer.invoke('rp:terms:get', kind),
|
ipcRenderer.invoke('rp:terms:get', kind),
|
||||||
|
|
||||||
|
/** rp 인스톨러에 표시할 약관 목록 (사이트의 visibility 토글로 필터링). */
|
||||||
|
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
|
||||||
|
ipcRenderer.invoke('rp:terms:list'),
|
||||||
|
|
||||||
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
|
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
|
||||||
startInstall: (): Promise<{ resourcepackPath: string }> =>
|
startInstall: (): Promise<{ resourcepackPath: string }> =>
|
||||||
ipcRenderer.invoke('rp:install:start'),
|
ipcRenderer.invoke('rp:install:start'),
|
||||||
|
|||||||
@@ -154,15 +154,18 @@ ipcMain.handle('packs:load', async (_event, manifestUrlInput?: string): Promise<
|
|||||||
return results
|
return results
|
||||||
})
|
})
|
||||||
|
|
||||||
// 약관(Markdown) 을 사이트(/manifest/terms/<kind>.md) 에서 받아와 그대로 돌려준다.
|
// 약관(Markdown) 을 사이트(/manifest/terms/<packKey>/<kind>.md) 에서 받아와 그대로 돌려준다.
|
||||||
// 화이트리스트로 5종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
|
// v0.3.4~ : 사이트에서 임의 kind 등록 가능 → 하드코딩 5종 화이트리스트 대신 kind 형식만 검증.
|
||||||
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
|
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||||
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
|
ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean; content?: string; message?: string }> => {
|
||||||
if (!TERM_KIND_WHITELIST.has(kind)) {
|
if (typeof kind !== 'string' || !TERM_KIND_RE.test(kind)) {
|
||||||
return { ok: false, message: 'unknown term kind' }
|
return { ok: false, message: 'invalid term kind' }
|
||||||
|
}
|
||||||
|
if (!state.selectedKey) {
|
||||||
|
return { ok: false, message: 'pack not selected' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const url = `${state.baseUrl}/manifest/terms/${kind}.md`
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/${kind}.md`
|
||||||
const buf = await fetchBuffer(url)
|
const buf = await fetchBuffer(url)
|
||||||
return { ok: true, content: buf.toString('utf8') }
|
return { ok: true, content: buf.toString('utf8') }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -170,6 +173,31 @@ ipcMain.handle('terms:get', async (_event, kind: string): Promise<{ ok: boolean;
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 메인 인스톨러용 약관 목록. /manifest/terms/<packKey>/index.json 을 받아
|
||||||
|
// showInInstaller=true 인 항목만 추려 반환. 비어 있으면 렌더러가 약관 단계를 건너뛴다.
|
||||||
|
ipcMain.handle('terms:list', async (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> => {
|
||||||
|
if (!state.selectedKey) return { ok: false, message: 'pack not selected' }
|
||||||
|
try {
|
||||||
|
const url = `${state.baseUrl}/manifest/terms/${encodeURIComponent(state.selectedKey)}/index.json`
|
||||||
|
const buf = await fetchBuffer(url)
|
||||||
|
const parsed = JSON.parse(buf.toString('utf8')) as { terms?: unknown }
|
||||||
|
const items = Array.isArray(parsed.terms) ? parsed.terms : []
|
||||||
|
const terms: Array<{ kind: string; label: string }> = []
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it || typeof it !== 'object') continue
|
||||||
|
const entry = it as Record<string, unknown>
|
||||||
|
if (entry.showInInstaller !== true) continue
|
||||||
|
const kind = typeof entry.kind === 'string' ? entry.kind : ''
|
||||||
|
const label = typeof entry.label === 'string' ? entry.label : ''
|
||||||
|
if (!TERM_KIND_RE.test(kind) || label.length === 0) continue
|
||||||
|
terms.push({ kind, label })
|
||||||
|
}
|
||||||
|
return { ok: true, terms }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: (error as Error).message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
ipcMain.handle('packs:select', async (_event, packKey: string) => {
|
||||||
if (!state.packs.has(packKey)) {
|
if (!state.packs.has(packKey)) {
|
||||||
throw new Error(t('errors.packNotFound'))
|
throw new Error(t('errors.packNotFound'))
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const api = {
|
|||||||
// 약관(Markdown) 다운로드
|
// 약관(Markdown) 다운로드
|
||||||
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
getTerm: (kind: string): Promise<{ ok: boolean; content?: string; message?: string }> =>
|
||||||
ipcRenderer.invoke('terms:get', kind),
|
ipcRenderer.invoke('terms:get', kind),
|
||||||
|
// 메인 인스톨러용 약관 목록 (사이트의 visibility 토글에 따라 필터링됨)
|
||||||
|
getTermsList: (): Promise<{ ok: boolean; terms?: Array<{ kind: string; label: string }>; message?: string }> =>
|
||||||
|
ipcRenderer.invoke('terms:list'),
|
||||||
|
|
||||||
// 3-1
|
// 3-1
|
||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke('dialog:pickFolder'),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {
|
|||||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||||
fileDirPath, viewsDirPath, publicDirPath
|
fileDirPath, viewsDirPath, publicDirPath
|
||||||
} from '../shared/paths.js'
|
} from '../shared/paths.js'
|
||||||
|
import {
|
||||||
|
ensurePackTermsDir, isPublicTermsFile, listTermsWithLabels, loadPackDefinition
|
||||||
|
} from '../shared/store.js'
|
||||||
import { loadEnv } from '../shared/env.js'
|
import { loadEnv } from '../shared/env.js'
|
||||||
import { t, localeDict } from './i18n.js'
|
import { t, localeDict } from './i18n.js'
|
||||||
import { indexRouter } from './routes/index.js'
|
import { indexRouter } from './routes/index.js'
|
||||||
@@ -62,19 +65,58 @@ app.get('/manifest.json', (_req, res) => {
|
|||||||
res.sendFile(manifestRootPath)
|
res.sendFile(manifestRootPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 설치기에서 약관(markdown) 을 가져갈 수 있도록 화이트리스트 파일명만 허용.
|
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||||
app.get('/manifest/terms/:fileName', (req, res) => {
|
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
||||||
const fileName = req.params.fileName
|
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||||
// 화이트리스트: map.md, resourcepack.md, mod.md, installer.md, installer-rp.md
|
//
|
||||||
if (!/^(map|resourcepack|mod|installer|installer-rp)\.md$/.test(fileName)) {
|
// fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
|
||||||
res.status(404).send('Not Found')
|
// 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1
|
||||||
return
|
// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가
|
||||||
|
// 생성되는 것은 loadPackDefinition 으로 차단.
|
||||||
|
// 설치기가 자기에게 표시할 약관 목록을 받아갈 수 있도록 packKey 별 index.json.
|
||||||
|
// 응답: [{ kind, label, showInInstaller, showInInstallerRp }]. v0.3.4~ builtin 개념이
|
||||||
|
// 없어졌으므로 인스톨러는 이 목록을 받아 자기 인스톨러용(`showInInstaller` / `showInInstallerRp`)
|
||||||
|
// 으로 필터링해서 탭을 만든다.
|
||||||
|
app.get('/manifest/terms/:packKey/index.json', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { packKey } = req.params
|
||||||
|
if (!/^[a-zA-Z0-9_\-]+$/.test(packKey)) {
|
||||||
|
res.status(404).json({ terms: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pack = await loadPackDefinition(packKey)
|
||||||
|
if (!pack) {
|
||||||
|
res.status(404).json({ terms: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const terms = await listTermsWithLabels(packKey)
|
||||||
|
res.json({ terms })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/manifest/terms/:packKey/:fileName', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { packKey, fileName } = req.params
|
||||||
|
if (!isPublicTermsFile(packKey, fileName)) {
|
||||||
|
res.status(404).send('Not Found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const pack = await loadPackDefinition(packKey)
|
||||||
|
if (!pack) {
|
||||||
|
res.status(404).send('Not Found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await ensurePackTermsDir(packKey)
|
||||||
|
res.type('text/markdown; charset=utf-8')
|
||||||
|
res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
|
||||||
|
if (!err || res.headersSent) return
|
||||||
|
res.status(404).send('Not Found')
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
}
|
}
|
||||||
res.type('text/markdown; charset=utf-8')
|
|
||||||
res.sendFile(path.join(manifestTermsDirPath, fileName), (err) => {
|
|
||||||
if (!err || res.headersSent) return
|
|
||||||
res.status(404).send('Not Found')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { Router } from 'express'
|
|||||||
import archiver from 'archiver'
|
import archiver from 'archiver'
|
||||||
import {
|
import {
|
||||||
createPack,
|
createPack,
|
||||||
|
createTerm,
|
||||||
deletePackKeys,
|
deletePackKeys,
|
||||||
|
deleteTerm,
|
||||||
|
getTermEntry,
|
||||||
|
importTerms,
|
||||||
|
isTermKind,
|
||||||
listPackKeys,
|
listPackKeys,
|
||||||
|
listTermsWithLabels,
|
||||||
loadPackDefinition,
|
loadPackDefinition,
|
||||||
loadPackList,
|
loadPackList,
|
||||||
loadTerm,
|
loadTerm,
|
||||||
@@ -14,10 +20,8 @@ import {
|
|||||||
sanitizePackKey,
|
sanitizePackKey,
|
||||||
saveTerm,
|
saveTerm,
|
||||||
savePackList,
|
savePackList,
|
||||||
isTermKind,
|
setTermVisibility
|
||||||
TERM_KINDS
|
|
||||||
} from '../../shared/store.js'
|
} from '../../shared/store.js'
|
||||||
import type { TermKind } from '../../shared/store.js'
|
|
||||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||||
import { requireAuth } from '../middleware/auth.js'
|
import { requireAuth } from '../middleware/auth.js'
|
||||||
@@ -301,33 +305,147 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||||
// 약관(Markdown) 5종 편집기. 사이트에서는 노션 스타일의 슬래시 명령으로
|
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
||||||
// 마크다운을 작성하고, 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
|
// 5종 기본 약관(map/mod/installer/resourcepack/installer-rp) 은 첫 접근 시 시드되지만
|
||||||
const TERM_LABELS: Record<TermKind, string> = {
|
// 사용자가 자유롭게 삭제/추가/표시 대상 변경할 수 있다 (v0.3.4~). 인스톨러는
|
||||||
'map': '맵 약관',
|
// /manifest/terms/<packKey>/index.json 으로 자신에게 표시할 약관 목록을 받는다.
|
||||||
'resourcepack': '리소스팩 약관',
|
|
||||||
'mod': '모드 약관',
|
|
||||||
'installer': '설치기 약관',
|
|
||||||
'installer-rp': '리소스팩 설치기 약관'
|
|
||||||
}
|
|
||||||
|
|
||||||
opRouter.get('/op/agreement', requireAuth, (req, res) => {
|
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
||||||
const items = TERM_KINDS.map((kind) => ({ kind, label: TERM_LABELS[kind] }))
|
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
||||||
res.render('op/terms', { userId: req.session.userId, items })
|
try {
|
||||||
|
const keys = await listPackKeys()
|
||||||
|
const items = await Promise.all(keys.map(async (key) => ({
|
||||||
|
key,
|
||||||
|
definition: await loadPackDefinition(key)
|
||||||
|
})))
|
||||||
|
res.render('op/terms', { userId: req.session.userId, items })
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
// /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가/불러오기/삭제.
|
||||||
|
opRouter.get('/op/agreement/:packName', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const definition = await loadPackDefinition(packKey)
|
||||||
|
if (!definition) {
|
||||||
|
res.status(404).send(t('errors.packNotFound'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const items = await listTermsWithLabels(packKey)
|
||||||
|
// 불러오기 source 후보: 현재 pack 을 제외한 나머지.
|
||||||
|
const allKeys = await listPackKeys()
|
||||||
|
const sourceCandidates = await Promise.all(
|
||||||
|
allKeys
|
||||||
|
.filter((k) => k !== packKey)
|
||||||
|
.map(async (k) => ({ key: k, definition: await loadPackDefinition(k) }))
|
||||||
|
)
|
||||||
|
res.render('op/terms-pack', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
packKey,
|
||||||
|
pack: definition,
|
||||||
|
items,
|
||||||
|
sourceCandidates
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/agreement/:packName/create', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const definition = await loadPackDefinition(packKey)
|
||||||
|
if (!definition) {
|
||||||
|
res.status(404).send(t('errors.packNotFound'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const kindInput = pickFirstValue(req.body.kind).trim().toLowerCase()
|
||||||
|
const label = pickFirstValue(req.body.label)
|
||||||
|
if (!isTermKind(kindInput)) {
|
||||||
|
res.status(400).send(t('terms.invalidKind'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await createTerm(packKey, kindInput, label)
|
||||||
|
res.redirect(`/op/agreement/${packKey}/${kindInput}`)
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send((error as Error).message || t('terms.createFailed'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/agreement/:packName/import', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const definition = await loadPackDefinition(packKey)
|
||||||
|
if (!definition) {
|
||||||
|
res.status(404).send(t('errors.packNotFound'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sourceKey = sanitizePackKey(pickFirstValue(req.body.source))
|
||||||
|
if (!sourceKey || sourceKey === packKey) {
|
||||||
|
res.status(400).send(t('terms.invalidImportSource'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sourceDefinition = await loadPackDefinition(sourceKey)
|
||||||
|
if (!sourceDefinition) {
|
||||||
|
res.status(404).send(t('terms.invalidImportSource'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await importTerms(packKey, sourceKey)
|
||||||
|
res.redirect(`/op/agreement/${packKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send((error as Error).message || t('terms.importFailed'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.post('/op/agreement/:packName/:kind/delete', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const definition = await loadPackDefinition(packKey)
|
||||||
|
if (!definition) {
|
||||||
|
res.status(404).send(t('errors.packNotFound'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const kind = pickFirstValue(req.params.kind)
|
||||||
|
if (!isTermKind(kind)) {
|
||||||
|
res.status(400).send(t('terms.invalidKind'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await deleteTerm(packKey, kind)
|
||||||
|
res.redirect(`/op/agreement/${packKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
opRouter.get('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const definition = await loadPackDefinition(packKey)
|
||||||
|
if (!definition) {
|
||||||
|
res.status(404).send(t('errors.packNotFound'))
|
||||||
|
return
|
||||||
|
}
|
||||||
const kind = pickFirstValue(req.params.kind)
|
const kind = pickFirstValue(req.params.kind)
|
||||||
if (!isTermKind(kind)) {
|
if (!isTermKind(kind)) {
|
||||||
res.status(404).send(t('errors.unknown'))
|
res.status(404).send(t('errors.unknown'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const content = await loadTerm(kind)
|
const entry = await getTermEntry(packKey, kind)
|
||||||
|
if (!entry) {
|
||||||
|
res.status(404).send(t('errors.unknown'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const content = await loadTerm(packKey, kind)
|
||||||
res.render('op/termsEditor', {
|
res.render('op/termsEditor', {
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
|
packKey,
|
||||||
|
pack: definition,
|
||||||
kind,
|
kind,
|
||||||
label: TERM_LABELS[kind],
|
label: entry.label,
|
||||||
|
showInInstaller: entry.showInInstaller,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp,
|
||||||
content
|
content
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -335,15 +453,32 @@ opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
opRouter.post('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
opRouter.post('/op/agreement/:packName/:kind', requireAuth, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
|
const definition = await loadPackDefinition(packKey)
|
||||||
|
if (!definition) {
|
||||||
|
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
|
||||||
|
return
|
||||||
|
}
|
||||||
const kind = pickFirstValue(req.params.kind)
|
const kind = pickFirstValue(req.params.kind)
|
||||||
if (!isTermKind(kind)) {
|
if (!isTermKind(kind)) {
|
||||||
res.status(404).json({ ok: false, message: t('errors.unknown') })
|
res.status(404).json({ ok: false, message: t('errors.unknown') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
||||||
await saveTerm(kind, content)
|
await saveTerm(packKey, kind, content)
|
||||||
|
// visibility 토글이 함께 전송되면 동시에 갱신. 두 값이 모두 false 면 어디에도
|
||||||
|
// 표시되지 않지만 사용자가 의도적으로 선택한 결과이므로 그대로 저장한다.
|
||||||
|
if (
|
||||||
|
typeof req.body?.showInInstaller === 'boolean'
|
||||||
|
|| typeof req.body?.showInInstallerRp === 'boolean'
|
||||||
|
) {
|
||||||
|
await setTermVisibility(packKey, kind, {
|
||||||
|
showInInstaller: req.body.showInInstaller === true,
|
||||||
|
showInInstallerRp: req.body.showInInstallerRp === true
|
||||||
|
})
|
||||||
|
}
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error)
|
next(error)
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ export async function deletePackKeys(keys: string[]): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
|
// pack 이 삭제되면 약관 폴더도 함께 정리한다. 동일 packKey 로 재생성될 때
|
||||||
|
// 옛 약관이 부활하는 것을 막기 위함.
|
||||||
|
const termsDir = path.join(manifestTermsDirPath, key)
|
||||||
|
try {
|
||||||
|
await fsp.rm(termsDir, { recursive: true, force: true })
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
await syncManifestWith(key, '', 'remove')
|
await syncManifestWith(key, '', 'remove')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +206,19 @@ export async function renamePack(oldKey: string, newKey: string, pack: PackDefin
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
}
|
}
|
||||||
|
// 약관 폴더도 함께 이름을 바꾼다 (있는 경우만). pack 이름이 바뀌었는데 약관이
|
||||||
|
// 옛 폴더에 남아 있으면 인스톨러가 새 packKey 로 약관을 받지 못한다.
|
||||||
|
const oldTermsDir = path.join(manifestTermsDirPath, oldKey)
|
||||||
|
const newTermsDir = path.join(manifestTermsDirPath, safeNew)
|
||||||
|
try {
|
||||||
|
await fsp.rename(oldTermsDir, newTermsDir)
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
// 옛 약관 폴더가 없으면 그대로 둔다. 새 폴더가 이미 있어 충돌하면 그것도 그냥 둔다
|
||||||
|
// (renamePack 단계에서 사용자에게 보낼 마땅한 UX 가 없고, 다음 약관 접근 때
|
||||||
|
// 새 폴더 내용이 정상적으로 사용된다).
|
||||||
|
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') throw error
|
||||||
|
}
|
||||||
await syncManifestWith(oldKey, '', 'remove')
|
await syncManifestWith(oldKey, '', 'remove')
|
||||||
}
|
}
|
||||||
await syncManifestWith(safeNew, pack.name, 'upsert')
|
await syncManifestWith(safeNew, pack.name, 'upsert')
|
||||||
@@ -296,18 +317,313 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
|||||||
|
|
||||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||||
// 화이트리스트로 5종만 허용한다.
|
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
|
||||||
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp'
|
// - 각 약관(.md) 은 `_meta.json` 의 `terms.<kind>` 엔트리로 라벨/표시 대상이 관리된다.
|
||||||
export const TERM_KINDS: readonly TermKind[] = [
|
// 엔트리: { label, showInInstaller, showInInstallerRp }
|
||||||
'map', 'resourcepack', 'mod', 'installer', 'installer-rp'
|
// - 모든 약관은 추가/삭제 가능. builtin 같은 보호 개념은 더 이상 없음 (v0.3.4~).
|
||||||
|
// 인스톨러는 하드코딩 5종 대신 `index.json` 에서 자기 인스톨러용 약관 목록을 받는다.
|
||||||
|
// - 첫 접근 시 5개 기본 약관(map/mod/installer + resourcepack/installer-rp) 을 시드.
|
||||||
|
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||||
|
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
|
||||||
|
export type TermKind = string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 처음 pack 폴더를 만들 때 시드되는 기본 약관 5종 + 기본 표시 대상.
|
||||||
|
* 사용자는 이후 자유롭게 삭제하거나 표시 대상을 바꿀 수 있다.
|
||||||
|
*/
|
||||||
|
const DEFAULT_TERM_SEEDS: Array<{
|
||||||
|
kind: string
|
||||||
|
label: string
|
||||||
|
showInInstaller: boolean
|
||||||
|
showInInstallerRp: boolean
|
||||||
|
}> = [
|
||||||
|
{ kind: 'map', label: '맵 약관', showInInstaller: true, showInInstallerRp: false },
|
||||||
|
{ kind: 'mod', label: '모드 약관', showInInstaller: true, showInInstallerRp: false },
|
||||||
|
{ kind: 'installer', label: '설치기 약관', showInInstaller: true, showInInstallerRp: false },
|
||||||
|
{ kind: 'resourcepack', label: '리소스팩 약관', showInInstaller: false, showInInstallerRp: true },
|
||||||
|
{ kind: 'installer-rp', label: '리소스팩 설치기 약관', showInInstaller: false, showInInstallerRp: true }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||||
|
|
||||||
export function isTermKind(value: unknown): value is TermKind {
|
export function isTermKind(value: unknown): value is TermKind {
|
||||||
return typeof value === 'string' && (TERM_KINDS as readonly string[]).includes(value)
|
return typeof value === 'string' && TERM_KIND_RE.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadTerm(kind: TermKind): Promise<string> {
|
export interface TermEntry {
|
||||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
label: string
|
||||||
|
showInInstaller: boolean
|
||||||
|
showInInstallerRp: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TermsMeta {
|
||||||
|
terms: Record<string, TermEntry>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMS_META_FILE = '_meta.json'
|
||||||
|
|
||||||
|
function termsDirForPack(packKey: string): string {
|
||||||
|
return path.join(manifestTermsDirPath, packKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPackKey(packKey: string): boolean {
|
||||||
|
return typeof packKey === 'string'
|
||||||
|
&& packKey.length > 0
|
||||||
|
&& /^[a-zA-Z0-9_\-]+$/.test(packKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 pack 폴더가 없으면 만든다. 이전 버전(v0.3.1) 의 전역 `manifest/terms/*.md`
|
||||||
|
* 파일이 남아 있는 경우 첫 접근 시 그 내용을 그대로 새 폴더에 복사해 시드한다.
|
||||||
|
* 시드는 한 번만 발생: 폴더가 이미 있으면 아무것도 안 한다.
|
||||||
|
*
|
||||||
|
* 공개 라우트(`/manifest/terms/<packKey>/<file>`) 에서도 호출되므로 export 한다.
|
||||||
|
* 라우트 측은 packKey 가 실제 존재하는 pack 인지 확인한 다음에 호출해야 한다
|
||||||
|
* (그렇지 않으면 임의 키로 빈 폴더가 생성될 수 있다).
|
||||||
|
*/
|
||||||
|
export async function ensurePackTermsDir(packKey: string): Promise<string> {
|
||||||
|
const dir = termsDirForPack(packKey)
|
||||||
|
let isNew = false
|
||||||
|
try {
|
||||||
|
await fsp.access(dir)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
isNew = true
|
||||||
|
await fsp.mkdir(dir, { recursive: true })
|
||||||
|
// 레거시(전역) .md 파일이 남아 있으면 그대로 복사 (.md 만, _meta.json 은 새 스키마로 새로 씀).
|
||||||
|
try {
|
||||||
|
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
|
||||||
|
for (const ent of legacyEntries) {
|
||||||
|
if (!ent.isFile()) continue
|
||||||
|
const name = ent.name
|
||||||
|
if (!name.toLowerCase().endsWith('.md')) continue
|
||||||
|
const kind = name.slice(0, -3)
|
||||||
|
if (!TERM_KIND_RE.test(kind)) continue
|
||||||
|
try {
|
||||||
|
await fsp.copyFile(
|
||||||
|
path.join(manifestTermsDirPath, name),
|
||||||
|
path.join(dir, name)
|
||||||
|
)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
} catch (error2) {
|
||||||
|
if ((error2 as NodeJS.ErrnoException).code !== 'ENOENT') throw error2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 폴더가 새로 만들어졌든 기존이든, _meta.json 이 없거나 구 스키마면 5종 기본 + .md 매칭으로 보완.
|
||||||
|
await ensureMetaInitialized(dir, isNew)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_meta.json` 이 없으면 5종 기본 + 디스크 .md 매칭으로 새로 작성한다.
|
||||||
|
* 구 스키마(`customLabels`) 가 있으면 새 스키마(`terms`) 로 변환한다.
|
||||||
|
* 이미 새 스키마면 그대로 둔다 (사용자가 끈 visibility 가 다시 켜지지 않도록).
|
||||||
|
*/
|
||||||
|
async function ensureMetaInitialized(dir: string, dirWasJustCreated: boolean): Promise<void> {
|
||||||
|
const metaPath = path.join(dir, TERMS_META_FILE)
|
||||||
|
let parsed: unknown = null
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(metaPath, 'utf8')
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 새 스키마면 종료. 빠진 default kind 가 디스크에 있다면 그것만 보충.
|
||||||
|
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms) {
|
||||||
|
const meta = parsed as { terms: Record<string, unknown> }
|
||||||
|
let changed = false
|
||||||
|
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||||
|
if (meta.terms[seed.kind]) continue
|
||||||
|
// .md 가 실제로 디스크에 있을 때만 보충 (없는 약관까지 자동 부활시키지 않음).
|
||||||
|
try {
|
||||||
|
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta.terms[seed.kind] = {
|
||||||
|
label: seed.label,
|
||||||
|
showInInstaller: seed.showInInstaller,
|
||||||
|
showInInstallerRp: seed.showInInstallerRp
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
await fsp.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구 스키마 customLabels 만 있던 경우 → 새 스키마로 변환.
|
||||||
|
const oldCustomLabels: Record<string, string> = {}
|
||||||
|
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).customLabels
|
||||||
|
&& typeof (parsed as Record<string, unknown>).customLabels === 'object') {
|
||||||
|
for (const [k, v] of Object.entries((parsed as { customLabels: Record<string, unknown> }).customLabels)) {
|
||||||
|
if (typeof v === 'string' && TERM_KIND_RE.test(k)) oldCustomLabels[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const terms: Record<string, TermEntry> = {}
|
||||||
|
// 5종 기본: 디스크에 .md 가 있을 때만 추가 (없는 건 사용자가 의도적으로 지운 것일 수 있음).
|
||||||
|
// 다만 폴더가 막 생성된 경우는 5종을 무조건 시드 (legacy 시드가 비어 있어도).
|
||||||
|
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||||
|
if (!dirWasJustCreated) {
|
||||||
|
try {
|
||||||
|
await fsp.access(path.join(dir, `${seed.kind}.md`))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 폴더 새로 생성 케이스: .md 가 없으면 빈 파일 만들어 줌.
|
||||||
|
const filePath = path.join(dir, `${seed.kind}.md`)
|
||||||
|
try {
|
||||||
|
await fsp.access(filePath)
|
||||||
|
} catch {
|
||||||
|
await fsp.writeFile(filePath, `# ${seed.label}\n\n`, 'utf8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
terms[seed.kind] = {
|
||||||
|
label: seed.label,
|
||||||
|
showInInstaller: seed.showInInstaller,
|
||||||
|
showInInstallerRp: seed.showInInstallerRp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 구 스키마의 사용자 정의 약관은 양쪽 인스톨러에 보이도록 기본값으로.
|
||||||
|
for (const [k, label] of Object.entries(oldCustomLabels)) {
|
||||||
|
if (terms[k]) continue
|
||||||
|
try {
|
||||||
|
await fsp.access(path.join(dir, `${k}.md`))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
terms[k] = { label, showInInstaller: true, showInInstallerRp: true }
|
||||||
|
}
|
||||||
|
await fsp.writeFile(metaPath, `${JSON.stringify({ terms }, null, 2)}\n`, 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTermsMeta(packKey: string): Promise<TermsMeta> {
|
||||||
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
|
try {
|
||||||
|
const raw = await fsp.readFile(path.join(dir, TERMS_META_FILE), 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
const result: TermsMeta = { terms: {} }
|
||||||
|
if (parsed && typeof parsed === 'object' && (parsed as Record<string, unknown>).terms
|
||||||
|
&& typeof (parsed as Record<string, unknown>).terms === 'object') {
|
||||||
|
for (const [k, v] of Object.entries((parsed as { terms: Record<string, unknown> }).terms)) {
|
||||||
|
if (!TERM_KIND_RE.test(k)) continue
|
||||||
|
if (!v || typeof v !== 'object') continue
|
||||||
|
const entry = v as Record<string, unknown>
|
||||||
|
const label = typeof entry.label === 'string' ? entry.label : k
|
||||||
|
result.terms[k] = {
|
||||||
|
label,
|
||||||
|
showInInstaller: entry.showInInstaller === true,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { terms: {} }
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
|
||||||
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
|
await fsp.writeFile(
|
||||||
|
path.join(dir, TERMS_META_FILE),
|
||||||
|
`${JSON.stringify(meta, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TermItem {
|
||||||
|
kind: string
|
||||||
|
label: string
|
||||||
|
showInInstaller: boolean
|
||||||
|
showInInstallerRp: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디스크의 .md 파일과 매칭되면서 `_meta.json` 의 `terms` 에 등록된 약관 목록을 반환.
|
||||||
|
* 정렬: 5종 기본(DEFAULT_TERM_SEEDS 순서) → 그 외 사용자 정의 (kind 사전순).
|
||||||
|
*/
|
||||||
|
export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
|
||||||
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
let onDisk: string[] = []
|
||||||
|
try {
|
||||||
|
onDisk = await fsp.readdir(dir)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
const mdKinds = new Set<string>()
|
||||||
|
for (const fname of onDisk) {
|
||||||
|
if (!fname.toLowerCase().endsWith('.md')) continue
|
||||||
|
const kind = fname.slice(0, -3)
|
||||||
|
if (!TERM_KIND_RE.test(kind)) continue
|
||||||
|
mdKinds.add(kind)
|
||||||
|
}
|
||||||
|
const items: TermItem[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
// 1) 기본 시드 순서 우선.
|
||||||
|
for (const seed of DEFAULT_TERM_SEEDS) {
|
||||||
|
const entry = meta.terms[seed.kind]
|
||||||
|
if (!entry) continue
|
||||||
|
if (!mdKinds.has(seed.kind)) continue
|
||||||
|
items.push({
|
||||||
|
kind: seed.kind,
|
||||||
|
label: entry.label,
|
||||||
|
showInInstaller: entry.showInInstaller,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp
|
||||||
|
})
|
||||||
|
seen.add(seed.kind)
|
||||||
|
}
|
||||||
|
// 2) 그 외 사용자 정의: 사전순.
|
||||||
|
const rest = Object.keys(meta.terms).filter((k) => !seen.has(k))
|
||||||
|
rest.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||||
|
for (const kind of rest) {
|
||||||
|
if (!mdKinds.has(kind)) continue
|
||||||
|
const entry = meta.terms[kind]
|
||||||
|
items.push({
|
||||||
|
kind,
|
||||||
|
label: entry.label,
|
||||||
|
showInInstaller: entry.showInInstaller,
|
||||||
|
showInInstallerRp: entry.showInInstallerRp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTermLabel(packKey: string, kind: string): Promise<string> {
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
return meta.terms[kind]?.label ?? kind
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTermEntry(packKey: string, kind: string): Promise<TermEntry | null> {
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
return meta.terms[kind] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTermVisibility(
|
||||||
|
packKey: string,
|
||||||
|
kind: string,
|
||||||
|
visibility: { showInInstaller: boolean; showInInstallerRp: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
const entry = meta.terms[kind]
|
||||||
|
if (!entry) throw new Error('term not found')
|
||||||
|
entry.showInInstaller = !!visibility.showInInstaller
|
||||||
|
entry.showInInstallerRp = !!visibility.showInInstallerRp
|
||||||
|
await saveTermsMeta(packKey, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
|
||||||
|
if (!isTermKind(kind)) return ''
|
||||||
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
|
const filePath = path.join(dir, `${kind}.md`)
|
||||||
try {
|
try {
|
||||||
return await fsp.readFile(filePath, 'utf8')
|
return await fsp.readFile(filePath, 'utf8')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -316,13 +632,114 @@ export async function loadTerm(kind: TermKind): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
|
export async function saveTerm(packKey: string, kind: TermKind, markdown: string): Promise<void> {
|
||||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
|
const filePath = path.join(dir, `${kind}.md`)
|
||||||
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
|
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
|
||||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 약관 추가. kind 충돌은 예외. 빈 `.md` 파일을 만든다.
|
||||||
|
* v0.3.4~: builtin 보호 개념이 없어 임의 kind 를 추가/삭제할 수 있다. 다만
|
||||||
|
* `meta.terms` 에 이미 있는 kind 와 충돌하면 거부. 표시 대상 기본값은 양쪽 인스톨러 모두.
|
||||||
|
*/
|
||||||
|
export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
|
||||||
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||||
|
const cleanLabel = label.trim()
|
||||||
|
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
if (meta.terms[kind]) throw new Error('term kind already exists')
|
||||||
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
|
const filePath = path.join(dir, `${kind}.md`)
|
||||||
|
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
|
||||||
|
try {
|
||||||
|
await fsp.access(filePath)
|
||||||
|
throw new Error('term file already exists')
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
|
||||||
|
// 기본 시드 kind 면 그 시드의 visibility 기본을 따르고, 그 외는 양쪽 인스톨러 모두 표시.
|
||||||
|
const seed = DEFAULT_TERM_SEEDS.find((s) => s.kind === kind)
|
||||||
|
meta.terms[kind] = {
|
||||||
|
label: cleanLabel,
|
||||||
|
showInInstaller: seed ? seed.showInInstaller : true,
|
||||||
|
showInInstallerRp: seed ? seed.showInInstallerRp : true
|
||||||
|
}
|
||||||
|
await saveTermsMeta(packKey, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 약관 삭제. v0.3.4~: builtin 보호 없음 — 모든 kind 삭제 가능. */
|
||||||
|
export async function deleteTerm(packKey: string, kind: string): Promise<void> {
|
||||||
|
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||||
|
const dir = await ensurePackTermsDir(packKey)
|
||||||
|
const filePath = path.join(dir, `${kind}.md`)
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
const meta = await loadTermsMeta(packKey)
|
||||||
|
if (meta.terms[kind]) {
|
||||||
|
delete meta.terms[kind]
|
||||||
|
await saveTermsMeta(packKey, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
|
||||||
|
* - source 의 모든 .md 를 target 에 덮어쓴다.
|
||||||
|
* - target 에만 있던 약관 엔트리는 그대로 둔다 (source 에는 없으니 안 건드림).
|
||||||
|
* - 동일한 kind 가 source 에도 있다면 source 의 라벨/표시 대상으로 덮어씀.
|
||||||
|
*/
|
||||||
|
export async function importTerms(targetPackKey: string, sourcePackKey: string): Promise<void> {
|
||||||
|
if (!isValidPackKey(targetPackKey) || !isValidPackKey(sourcePackKey)) {
|
||||||
|
throw new Error('invalid pack key')
|
||||||
|
}
|
||||||
|
if (targetPackKey === sourcePackKey) throw new Error('source and target are identical')
|
||||||
|
const sourceDir = await ensurePackTermsDir(sourcePackKey)
|
||||||
|
const targetDir = await ensurePackTermsDir(targetPackKey)
|
||||||
|
|
||||||
|
const sourceMeta = await loadTermsMeta(sourcePackKey)
|
||||||
|
const targetMeta = await loadTermsMeta(targetPackKey)
|
||||||
|
|
||||||
|
// source 의 .md 파일을 모두 target 으로 복사.
|
||||||
|
let entries: string[] = []
|
||||||
|
try {
|
||||||
|
entries = await fsp.readdir(sourceDir)
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
for (const name of entries) {
|
||||||
|
if (!name.toLowerCase().endsWith('.md')) continue
|
||||||
|
const kind = name.slice(0, -3)
|
||||||
|
if (!TERM_KIND_RE.test(kind)) continue
|
||||||
|
await fsp.copyFile(path.join(sourceDir, name), path.join(targetDir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 약관 엔트리도 source 기준으로 머지 (덮어쓰기).
|
||||||
|
const mergedTerms: Record<string, TermEntry> = { ...targetMeta.terms }
|
||||||
|
for (const [k, v] of Object.entries(sourceMeta.terms)) {
|
||||||
|
mergedTerms[k] = { ...v }
|
||||||
|
}
|
||||||
|
await saveTermsMeta(targetPackKey, { terms: mergedTerms })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 라우트(`/manifest/terms/<packKey>/<file>`)에서 호출.
|
||||||
|
* - packKey 가 영문/숫자/언더스코어/하이픈만 사용했는지 검사.
|
||||||
|
* - 파일명이 .md 로 끝나고 정상 kind 패턴인지 검사.
|
||||||
|
* - _meta.json 같은 시스템 파일은 차단.
|
||||||
|
*/
|
||||||
|
export function isPublicTermsFile(packKey: string, fileName: string): boolean {
|
||||||
|
if (!isValidPackKey(packKey)) return false
|
||||||
|
if (!fileName.toLowerCase().endsWith('.md')) return false
|
||||||
|
const kind = fileName.slice(0, -3)
|
||||||
|
return TERM_KIND_RE.test(kind)
|
||||||
|
}
|
||||||
|
|
||||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||||
try {
|
try {
|
||||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||||
|
|||||||
154
views/op/terms-pack.ejs
Normal file
154
views/op/terms-pack.ejs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title><%= t('terms.packBrowserTitle', { name: pack.name }) %></title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
<style>
|
||||||
|
/* 약관 목록 — 카드 한 줄(가로 풀폭) 씩 세로로 쌓이도록. */
|
||||||
|
.termsList { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
|
||||||
|
.termsRow {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border, #30363d);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
.termsRow .termsRowMain { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||||
|
.termsRow .termsRowLabel { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.termsRow .termsRowLabel h2 { margin: 0; font-size: 16px; }
|
||||||
|
.termsRow .termsRowSub { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.termsRow .termsRowActions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.visibilityBadges {
|
||||||
|
display: flex; gap: 6px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.visibilityBadge {
|
||||||
|
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px;
|
||||||
|
background: rgba(76, 175, 80, 0.15); color: #8ed68f;
|
||||||
|
border: 1px solid rgba(76, 175, 80, 0.35);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.visibilityBadge.off {
|
||||||
|
background: rgba(255,255,255,0.05); color: var(--text-muted);
|
||||||
|
border-color: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
.termsSideBySide {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.termsSideBySide { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.termsSection {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border, #30363d);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.termsSection h2 { margin: 0 0 12px; font-size: 15px; }
|
||||||
|
.termsAddForm { display: grid; grid-template-columns: 1fr 2fr; gap: 10px; align-items: end; }
|
||||||
|
.termsAddForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.termsAddForm label { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.termsAddForm input, .termsImportForm select {
|
||||||
|
background: var(--bg-alt); color: var(--text);
|
||||||
|
border: 1px solid var(--border, #30363d); border-radius: 6px;
|
||||||
|
padding: 8px 10px; font-size: 13px;
|
||||||
|
}
|
||||||
|
.termsAddForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||||
|
.termsAddForm .formActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
|
||||||
|
.termsImportForm { display: grid; grid-template-columns: 1fr; gap: 10px; }
|
||||||
|
.termsImportForm .field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.termsImportForm label { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.termsImportForm .formActions { display: flex; justify-content: flex-end; }
|
||||||
|
.termsImportForm .hint { color: var(--text-muted); font-size: 11px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%- include('../partials/navbar', { userId }) %>
|
||||||
|
|
||||||
|
<main class="pageWrap">
|
||||||
|
<section class="dashboardHeader">
|
||||||
|
<div>
|
||||||
|
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
|
||||||
|
<h1 style="margin-top:20px;"><%= t('terms.packTitle', { name: pack.name }) %></h1>
|
||||||
|
<p class="muted"><%= packKey %>.json</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="muted"><%= t('terms.hint') %></p>
|
||||||
|
|
||||||
|
<section class="termsList">
|
||||||
|
<% items.forEach(function (item) { %>
|
||||||
|
<article class="termsRow">
|
||||||
|
<a class="termsRowMain" href="/op/agreement/<%= packKey %>/<%= item.kind %>" style="text-decoration:none; color:inherit;">
|
||||||
|
<div class="termsRowLabel">
|
||||||
|
<h2><%= item.label %></h2>
|
||||||
|
<span class="visibilityBadges">
|
||||||
|
<span class="visibilityBadge <%= item.showInInstaller ? '' : 'off' %>"><%= t('terms.visibilityInstallerShort') %></span>
|
||||||
|
<span class="visibilityBadge <%= item.showInInstallerRp ? '' : 'off' %>"><%= t('terms.visibilityInstallerRpShort') %></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="termsRowSub"><%= item.kind %>.md</div>
|
||||||
|
</a>
|
||||||
|
<div class="termsRowActions">
|
||||||
|
<a class="secondaryButton" href="/op/agreement/<%= packKey %>/<%= item.kind %>"><%= t('terms.edit') %></a>
|
||||||
|
<form method="post" action="/op/agreement/<%= packKey %>/<%= item.kind %>/delete"
|
||||||
|
onsubmit="return confirm('<%= t('terms.deleteConfirm', { label: item.label }).replace(/'/g, "\\'") %>');"
|
||||||
|
style="margin:0;">
|
||||||
|
<button type="submit" class="dangerButton"><%= t('terms.deleteButton') %></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="termsSideBySide">
|
||||||
|
<div class="termsSection">
|
||||||
|
<h2><%= t('terms.addHeading') %></h2>
|
||||||
|
<form method="post" action="/op/agreement/<%= packKey %>/create" class="termsAddForm">
|
||||||
|
<div class="field">
|
||||||
|
<label for="newKind"><%= t('terms.kindLabel') %></label>
|
||||||
|
<input id="newKind" name="kind" type="text" required
|
||||||
|
pattern="[a-z0-9][a-z0-9-]{0,31}"
|
||||||
|
placeholder="<%= t('terms.kindPlaceholder') %>" />
|
||||||
|
<span class="hint"><%= t('terms.kindHint') %></span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="newLabel"><%= t('terms.labelLabel') %></label>
|
||||||
|
<input id="newLabel" name="label" type="text" required maxlength="50"
|
||||||
|
placeholder="<%= t('terms.labelPlaceholder') %>" />
|
||||||
|
</div>
|
||||||
|
<div class="formActions">
|
||||||
|
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="termsSection">
|
||||||
|
<h2><%= t('terms.importHeading') %></h2>
|
||||||
|
<% if (sourceCandidates.length === 0) { %>
|
||||||
|
<p class="muted"><%= t('terms.importEmpty') %></p>
|
||||||
|
<% } else { %>
|
||||||
|
<form method="post" action="/op/agreement/<%= packKey %>/import" class="termsImportForm"
|
||||||
|
onsubmit="return confirm('<%= t('terms.importConfirm').replace(/'/g, "\\'") %>');">
|
||||||
|
<div class="field">
|
||||||
|
<label for="importSource"><%= t('terms.importSourceLabel') %></label>
|
||||||
|
<select id="importSource" name="source" required>
|
||||||
|
<option value=""><%= t('terms.importSourcePlaceholder') %></option>
|
||||||
|
<% sourceCandidates.forEach(function (cand) { %>
|
||||||
|
<option value="<%= cand.key %>"><%= cand.definition ? cand.definition.name : cand.key %> (<%= cand.key %>)</option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<span class="hint"><%= t('terms.importHint') %></span>
|
||||||
|
</div>
|
||||||
|
<div class="formActions">
|
||||||
|
<button type="submit" class="primaryButton"><%= t('terms.importButton') %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -17,14 +17,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="muted"><%= t('terms.hint') %></p>
|
<p class="muted"><%= t('terms.pickPackHint') %></p>
|
||||||
|
|
||||||
<section class="cardRow horizontalScroll">
|
<section class="cardRow horizontalScroll">
|
||||||
|
<% if (items.length === 0) { %>
|
||||||
|
<p class="muted"><%= t('site.empty') %></p>
|
||||||
|
<% } %>
|
||||||
<% items.forEach(function (item) { %>
|
<% items.forEach(function (item) { %>
|
||||||
<article class="packCard">
|
<article class="packCard">
|
||||||
<a class="cardLink" href="/op/agreement/<%= item.kind %>">
|
<a class="cardLink" href="/op/agreement/<%= item.key %>">
|
||||||
<h2><%= item.label %></h2>
|
<h2><%= item.definition ? item.definition.name : item.key %></h2>
|
||||||
<p class="muted"><%= item.kind %>.md</p>
|
<p class="muted"><%= item.key %>.json</p>
|
||||||
|
<% if (item.definition) { %>
|
||||||
|
<ul class="metaList">
|
||||||
|
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
|
||||||
|
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
|
||||||
|
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
<main class="pageWrap">
|
<main class="pageWrap">
|
||||||
<section class="dashboardHeader">
|
<section class="dashboardHeader">
|
||||||
<div>
|
<div>
|
||||||
<a class="ghostLink" href="/op/agreement"><%= t('common.back') %></a>
|
<a class="ghostLink" href="/op/agreement/<%= packKey %>"><%= t('common.back') %></a>
|
||||||
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
|
<h1 style="margin-top:20px;"><%= t('terms.editorTitle', { label: label }) %></h1>
|
||||||
<p class="muted"><%= kind %>.md</p>
|
<p class="muted"><%= pack.name %> · <%= kind %>.md</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dirtyMark" id="dirty-mark" hidden>*</div>
|
<div class="dirtyMark" id="dirty-mark" hidden>*</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -29,6 +29,19 @@
|
|||||||
<span class="statusText" id="status"></span>
|
<span class="statusText" id="status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 표시 대상 토글: 어느 인스톨러에서 이 약관을 보여줄지 (중복 선택 가능). -->
|
||||||
|
<fieldset class="termsVisibility" style="margin-top:16px; padding:10px 14px; border:1px solid var(--border, #30363d); border-radius:8px;">
|
||||||
|
<legend style="padding:0 6px; font-size:12px; color:var(--text-muted);"><%= t('terms.visibilityHeading') %></legend>
|
||||||
|
<label style="display:inline-flex; align-items:center; gap:6px; margin-right:18px;">
|
||||||
|
<input type="checkbox" id="visInstaller" <%= showInInstaller ? 'checked' : '' %> />
|
||||||
|
<span><%= t('terms.visibilityInstaller') %></span>
|
||||||
|
</label>
|
||||||
|
<label style="display:inline-flex; align-items:center; gap:6px;">
|
||||||
|
<input type="checkbox" id="visInstallerRp" <%= showInInstallerRp ? 'checked' : '' %> />
|
||||||
|
<span><%= t('terms.visibilityInstallerRp') %></span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
|
<p class="muted" style="font-size:12px;"><%= t('terms.slashHint') %></p>
|
||||||
|
|
||||||
<div id="editorWrap" class="termsEditorWrap">
|
<div id="editorWrap" class="termsEditorWrap">
|
||||||
@@ -39,6 +52,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
var PACK_KEY = <%- JSON.stringify(packKey) %>;
|
||||||
var TERM_KIND = <%- JSON.stringify(kind) %>;
|
var TERM_KIND = <%- JSON.stringify(kind) %>;
|
||||||
var INITIAL = <%- JSON.stringify(content) %>;
|
var INITIAL = <%- JSON.stringify(content) %>;
|
||||||
var I18N = <%- JSON.stringify(localeDict.terms) %>;
|
var I18N = <%- JSON.stringify(localeDict.terms) %>;
|
||||||
|
|||||||
Reference in New Issue
Block a user