terms: agreement pages + site Notion-style editor + rp cancel fix
- 5종 약관(map/resourcepack/mod/installer/installer-rp) markdown 시드 + manifest/terms/ 노출 - 사이트 /op/agreement 목록 + Notion 스타일 markdown 에디터 (슬래시 명령어, 미리보기) - 메인 installer: 음악퀴즈 선택 직후 약관 동의 페이지(맵·모드·설치기) 추가 - rp installer: 음악퀴즈 선택 직후 약관 동의 페이지(리소스팩·설치기) 추가 - rp installer 취소 버그 수정: buildResourcepackZip 단계간 + archive.abort() 폴링 - rp installer 취소 UX: 즉시 "취소 중…" 표시, 취소 시 installFailed 알림 생략 - 0.2.6 → 0.3.0 (큰 기능) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
95
public/termsEditor.css
Normal file
95
public/termsEditor.css
Normal file
@@ -0,0 +1,95 @@
|
||||
/* Notion 스타일 약관 편집기 전용 스타일.
|
||||
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
|
||||
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
|
||||
* 절대 위치로 띄운다. */
|
||||
|
||||
.termsEditorWrap {
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.termsEditor {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #d5d5d5;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.termsEditor:focus {
|
||||
border-color: #5b8def;
|
||||
box-shadow: 0 0 0 2px rgba(91, 141, 239, 0.2);
|
||||
}
|
||||
|
||||
.termsPreview {
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #d5d5d5;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.termsPreview h1 { font-size: 22px; margin: 12px 0 8px; }
|
||||
.termsPreview h2 { font-size: 18px; margin: 10px 0 6px; }
|
||||
.termsPreview h3 { font-size: 15px; margin: 8px 0 4px; }
|
||||
.termsPreview p { margin: 6px 0; }
|
||||
.termsPreview ul, .termsPreview ol { margin: 6px 0; padding-left: 22px; }
|
||||
.termsPreview li { margin: 2px 0; }
|
||||
.termsPreview hr { border: none; border-top: 1px solid #e0e0e0; margin: 12px 0; }
|
||||
.termsPreview blockquote {
|
||||
margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ddd; color: #555;
|
||||
}
|
||||
.termsPreview code {
|
||||
background: #eee; padding: 1px 5px; border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.termsPreview pre {
|
||||
background: #f0f0f0; padding: 10px 12px; border-radius: 6px; overflow: auto;
|
||||
}
|
||||
.termsPreview pre code { background: transparent; padding: 0; }
|
||||
.termsPreview a { color: #2664d8; text-decoration: underline; word-break: break-all; }
|
||||
.termsPreview details {
|
||||
margin: 6px 0; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; padding: 4px 10px;
|
||||
}
|
||||
.termsPreview details > summary { cursor: pointer; font-weight: 600; padding: 4px 0; }
|
||||
|
||||
/* 슬래시 자동완성 메뉴 — 노션 느낌으로 caret 좌표 위에 띄움. */
|
||||
.slashMenu {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
min-width: 220px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
padding: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.slashMenu .slashItem {
|
||||
display: flex; flex-direction: column;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.slashMenu .slashItem:hover,
|
||||
.slashMenu .slashItem.active {
|
||||
background: #eef2ff;
|
||||
}
|
||||
.slashMenu .slashItem strong { font-size: 13px; }
|
||||
.slashMenu .slashItem span { color: #888; font-size: 11px; }
|
||||
385
public/termsEditor.js
Normal file
385
public/termsEditor.js
Normal file
@@ -0,0 +1,385 @@
|
||||
/* 약관(Markdown) 편집기.
|
||||
* - 기본은 textarea: 사용자가 직접 #, - 등을 입력할 수 있다.
|
||||
* - "/" 를 줄 맨 앞 또는 빈 공간 다음에 입력하면 슬래시 메뉴를 띄워
|
||||
* 제목/내용/글머리/번호/토글/구분선/인용/코드 블록을 선택해 자동 삽입한다.
|
||||
* (사용자가 #, - 같은 기호를 외울 필요 없이 명령어로 입력 가능)
|
||||
* - 미리보기 탭에서 작은 markdown → HTML 렌더러로 결과를 보여 준다.
|
||||
*/
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
var editor = document.getElementById('editor')
|
||||
var preview = document.getElementById('preview')
|
||||
var slashMenu = document.getElementById('slashMenu')
|
||||
var status = document.getElementById('status')
|
||||
var dirtyMark = document.getElementById('dirty-mark')
|
||||
var saveBtn = document.getElementById('saveBtn')
|
||||
var tabBtns = document.querySelectorAll('.tabBar .tabBtn')
|
||||
|
||||
editor.value = INITIAL || ''
|
||||
var dirty = false
|
||||
function setDirty(v) {
|
||||
dirty = v
|
||||
dirtyMark.hidden = !v
|
||||
}
|
||||
|
||||
// ─── markdown 미리 보기용 미니 렌더러 ────────────────────────────────
|
||||
// 정식 markdown 파서는 아니지만, 본 편집기가 만들어 내는 형태(#, ##, ###,
|
||||
// - , 1. , > , ---, ``` , 토글 details) 정도는 충실히 처리한다.
|
||||
function escHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
function inline(s) {
|
||||
s = escHtml(s)
|
||||
// code `x`
|
||||
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// bold **x**
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
// italic *x*
|
||||
s = s.replace(/(^|\W)\*([^*\n]+)\*(?=\W|$)/g, '$1<em>$2</em>')
|
||||
// links [text](url) — also auto-link bare http(s)
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s)]+)/g, function (m, p, u) {
|
||||
return p + '<a href="' + u + '" target="_blank" rel="noopener">' + u + '</a>'
|
||||
})
|
||||
return s
|
||||
}
|
||||
function renderMd(src) {
|
||||
var lines = src.replace(/\r\n/g, '\n').split('\n')
|
||||
var out = []
|
||||
var i = 0
|
||||
var stackList = null // 'ul' | 'ol' | null
|
||||
function closeList() { if (stackList) { out.push('</' + stackList + '>'); stackList = null } }
|
||||
while (i < lines.length) {
|
||||
var line = lines[i]
|
||||
// 코드 블록 ```lang
|
||||
var fence = /^```(\w*)\s*$/.exec(line)
|
||||
if (fence) {
|
||||
closeList()
|
||||
var code = []
|
||||
i += 1
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) {
|
||||
code.push(lines[i]); i += 1
|
||||
}
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<pre><code>' + escHtml(code.join('\n')) + '</code></pre>')
|
||||
continue
|
||||
}
|
||||
// 토글 (자체 구문) :::toggle 제목 ... :::
|
||||
var togStart = /^:::toggle\s+(.+)$/.exec(line)
|
||||
if (togStart) {
|
||||
closeList()
|
||||
var summary = togStart[1]
|
||||
var body = []
|
||||
i += 1
|
||||
while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
|
||||
body.push(lines[i]); i += 1
|
||||
}
|
||||
if (i < lines.length) i += 1
|
||||
out.push('<details><summary>' + inline(summary) + '</summary>' + renderMd(body.join('\n')) + '</details>')
|
||||
continue
|
||||
}
|
||||
// 헤딩
|
||||
var h = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (h) {
|
||||
closeList()
|
||||
var level = h[1].length
|
||||
out.push('<h' + level + '>' + inline(h[2]) + '</h' + level + '>')
|
||||
i += 1; continue
|
||||
}
|
||||
// hr
|
||||
if (/^---+\s*$/.test(line)) {
|
||||
closeList()
|
||||
out.push('<hr />'); i += 1; continue
|
||||
}
|
||||
// 인용 >
|
||||
if (/^>\s?/.test(line)) {
|
||||
closeList()
|
||||
var q = []
|
||||
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
||||
q.push(lines[i].replace(/^>\s?/, '')); i += 1
|
||||
}
|
||||
out.push('<blockquote>' + renderMd(q.join('\n')) + '</blockquote>')
|
||||
continue
|
||||
}
|
||||
// 번호 목록
|
||||
var ol = /^\s*\d+\.\s+(.*)$/.exec(line)
|
||||
if (ol) {
|
||||
if (stackList !== 'ol') { closeList(); out.push('<ol>'); stackList = 'ol' }
|
||||
out.push('<li>' + inline(ol[1]) + '</li>')
|
||||
i += 1; continue
|
||||
}
|
||||
// 불릿
|
||||
var ul = /^\s*[-*]\s+(.*)$/.exec(line)
|
||||
if (ul) {
|
||||
if (stackList !== 'ul') { closeList(); out.push('<ul>'); stackList = 'ul' }
|
||||
out.push('<li>' + inline(ul[1]) + '</li>')
|
||||
i += 1; continue
|
||||
}
|
||||
// 빈 줄
|
||||
if (/^\s*$/.test(line)) { closeList(); i += 1; continue }
|
||||
// 일반 문단
|
||||
closeList()
|
||||
var para = [line]; i += 1
|
||||
while (i < lines.length && !/^\s*$/.test(lines[i])
|
||||
&& !/^(#{1,6})\s+/.test(lines[i])
|
||||
&& !/^\s*[-*]\s+/.test(lines[i])
|
||||
&& !/^\s*\d+\.\s+/.test(lines[i])
|
||||
&& !/^>/.test(lines[i])
|
||||
&& !/^---+\s*$/.test(lines[i])
|
||||
&& !/^```/.test(lines[i])
|
||||
&& !/^:::/.test(lines[i])) {
|
||||
para.push(lines[i]); i += 1
|
||||
}
|
||||
out.push('<p>' + inline(para.join('\n').replace(/\n/g, '<br/>')) + '</p>')
|
||||
}
|
||||
closeList()
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
function refreshPreview() {
|
||||
preview.innerHTML = renderMd(editor.value)
|
||||
}
|
||||
|
||||
// ─── 탭 전환 (edit / preview) ────────────────────────────────────────
|
||||
tabBtns.forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
tabBtns.forEach(function (x) { x.classList.remove('active') })
|
||||
b.classList.add('active')
|
||||
var mode = b.getAttribute('data-mode')
|
||||
if (mode === 'preview') {
|
||||
refreshPreview()
|
||||
editor.hidden = true
|
||||
preview.hidden = false
|
||||
} else {
|
||||
editor.hidden = false
|
||||
preview.hidden = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 저장 ───────────────────────────────────────────────────────────
|
||||
function save() {
|
||||
status.classList.remove('error')
|
||||
status.textContent = I18N.saving
|
||||
fetch('/op/agreement/' + encodeURIComponent(TERM_KIND), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editor.value })
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (j) { return { ok: r.ok && j && j.ok !== false, body: j } })
|
||||
}).then(function (res) {
|
||||
if (!res.ok) throw new Error((res.body && res.body.message) || 'failed')
|
||||
setDirty(false)
|
||||
status.textContent = I18N.saved
|
||||
}).catch(function (err) {
|
||||
status.classList.add('error')
|
||||
status.textContent = I18N.saveFailed.replace('{{message}}', err.message)
|
||||
})
|
||||
}
|
||||
saveBtn.addEventListener('click', save)
|
||||
|
||||
// Ctrl+S 저장
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault(); save()
|
||||
}
|
||||
})
|
||||
|
||||
// 페이지 떠나기 가드
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (!dirty) return
|
||||
e.preventDefault()
|
||||
e.returnValue = I18N.leaveConfirm
|
||||
return I18N.leaveConfirm
|
||||
})
|
||||
|
||||
editor.addEventListener('input', function () {
|
||||
setDirty(true)
|
||||
})
|
||||
|
||||
// ─── 슬래시 자동완성 ─────────────────────────────────────────────────
|
||||
// 정의: { label, hint, insert: 줄 시작에 들어갈 텍스트 (커서 위치는 |로 표시) }
|
||||
var SLASH_ITEMS = [
|
||||
{ label: I18N.slashHeading1, hint: '# ', insert: '# |' },
|
||||
{ label: I18N.slashHeading2, hint: '## ', insert: '## |' },
|
||||
{ label: I18N.slashHeading3, hint: '### ', insert: '### |' },
|
||||
{ label: I18N.slashText, hint: '', insert: '|' },
|
||||
{ label: I18N.slashBullet, hint: '- ', insert: '- |' },
|
||||
{ label: I18N.slashNumbered, hint: '1. ', insert: '1. |' },
|
||||
{ label: I18N.slashToggle, hint: ':::toggle 제목 ... :::', insert: ':::toggle 제목\n|\n:::' },
|
||||
{ label: I18N.slashDivider, hint: '---', insert: '---\n|' },
|
||||
{ label: I18N.slashQuote, hint: '> ', insert: '> |' },
|
||||
{ label: I18N.slashCode, hint: '```', insert: '```\n|\n```' }
|
||||
]
|
||||
|
||||
var slashState = null // { startPos: number, query: string, activeIndex: number, filtered: [] }
|
||||
|
||||
function renderSlashItems(filtered) {
|
||||
slashMenu.innerHTML = ''
|
||||
filtered.forEach(function (item, idx) {
|
||||
var el = document.createElement('div')
|
||||
el.className = 'slashItem' + (idx === slashState.activeIndex ? ' active' : '')
|
||||
var strong = document.createElement('strong')
|
||||
strong.textContent = item.label
|
||||
var span = document.createElement('span')
|
||||
span.textContent = item.hint || ''
|
||||
el.appendChild(strong); el.appendChild(span)
|
||||
el.addEventListener('mousedown', function (e) {
|
||||
e.preventDefault()
|
||||
applySlash(item)
|
||||
})
|
||||
slashMenu.appendChild(el)
|
||||
})
|
||||
}
|
||||
|
||||
function positionSlash() {
|
||||
// textarea caret 좌표 근사: 보이지 않는 mirror div 를 만들어 caret 위치를 추정한다.
|
||||
var rect = editor.getBoundingClientRect()
|
||||
var wrapRect = editor.parentElement.getBoundingClientRect()
|
||||
var caret = getCaretCoords(editor)
|
||||
var top = caret.top + 22 + (rect.top - wrapRect.top) - editor.scrollTop
|
||||
var left = caret.left + (rect.left - wrapRect.left)
|
||||
slashMenu.style.top = top + 'px'
|
||||
slashMenu.style.left = left + 'px'
|
||||
}
|
||||
|
||||
function openSlash() {
|
||||
slashState = {
|
||||
startPos: editor.selectionStart - 1, // '/' 위치
|
||||
query: '',
|
||||
activeIndex: 0,
|
||||
filtered: SLASH_ITEMS.slice()
|
||||
}
|
||||
renderSlashItems(slashState.filtered)
|
||||
slashMenu.hidden = false
|
||||
positionSlash()
|
||||
}
|
||||
function closeSlash() {
|
||||
slashState = null
|
||||
slashMenu.hidden = true
|
||||
}
|
||||
|
||||
function applySlash(item) {
|
||||
if (!slashState) return
|
||||
var value = editor.value
|
||||
var start = slashState.startPos
|
||||
var end = editor.selectionStart
|
||||
// 줄의 시작 위치 계산 (이미 '#', '- ' 같은 prefix 가 있어도 무시하고 새 prefix 로 교체)
|
||||
var lineStart = value.lastIndexOf('\n', start - 1) + 1
|
||||
var lineEnd = value.indexOf('\n', end)
|
||||
if (lineEnd === -1) lineEnd = value.length
|
||||
var beforeLine = value.slice(0, lineStart)
|
||||
var afterLine = value.slice(lineEnd)
|
||||
var currentLine = value.slice(lineStart, lineEnd)
|
||||
// 줄 안에서 '/검색어' 부분을 제거하고, 나머지 텍스트를 prefix 뒤에 이어 붙인다.
|
||||
var rest = currentLine.slice(0, start - lineStart) + currentLine.slice(end - lineStart)
|
||||
var insert = item.insert
|
||||
var caretMarker = insert.indexOf('|')
|
||||
var inserted = insert.replace('|', rest)
|
||||
editor.value = beforeLine + inserted + afterLine
|
||||
var caretPos = (beforeLine + insert.slice(0, caretMarker)).length
|
||||
editor.selectionStart = editor.selectionEnd = caretPos
|
||||
closeSlash()
|
||||
setDirty(true)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
editor.addEventListener('keydown', function (e) {
|
||||
if (slashState) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
slashState.activeIndex = (slashState.activeIndex + 1) % slashState.filtered.length
|
||||
renderSlashItems(slashState.filtered)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
slashState.activeIndex = (slashState.activeIndex - 1 + slashState.filtered.length) % slashState.filtered.length
|
||||
renderSlashItems(slashState.filtered)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
if (slashState.filtered.length > 0) {
|
||||
e.preventDefault()
|
||||
applySlash(slashState.filtered[slashState.activeIndex])
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
closeSlash()
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
editor.addEventListener('input', function (e) {
|
||||
var pos = editor.selectionStart
|
||||
var ch = editor.value.slice(pos - 1, pos)
|
||||
if (!slashState && ch === '/') {
|
||||
// 줄 시작 또는 공백 다음에서만 슬래시 메뉴 활성화
|
||||
var prev = pos >= 2 ? editor.value.slice(pos - 2, pos - 1) : '\n'
|
||||
if (prev === '\n' || prev === ' ' || pos === 1) {
|
||||
openSlash()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (slashState) {
|
||||
var startPos = slashState.startPos
|
||||
if (pos < startPos || editor.value[startPos] !== '/') {
|
||||
closeSlash()
|
||||
return
|
||||
}
|
||||
var q = editor.value.slice(startPos + 1, pos).toLowerCase()
|
||||
slashState.query = q
|
||||
slashState.filtered = SLASH_ITEMS.filter(function (it) {
|
||||
if (!q) return true
|
||||
return it.label.toLowerCase().indexOf(q) !== -1
|
||||
|| (it.hint && it.hint.toLowerCase().indexOf(q) !== -1)
|
||||
})
|
||||
slashState.activeIndex = 0
|
||||
renderSlashItems(slashState.filtered)
|
||||
positionSlash()
|
||||
}
|
||||
})
|
||||
|
||||
editor.addEventListener('blur', function () {
|
||||
// mousedown on menu uses e.preventDefault → blur 시에도 안전하게 닫는다.
|
||||
setTimeout(closeSlash, 100)
|
||||
})
|
||||
|
||||
// ─── caret 좌표 계산 (mirror div 기법) ───────────────────────────────
|
||||
function getCaretCoords(el) {
|
||||
var div = document.createElement('div')
|
||||
var s = getComputedStyle(el)
|
||||
var props = [
|
||||
'boxSizing','width','height','overflowX','overflowY',
|
||||
'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
|
||||
'paddingTop','paddingRight','paddingBottom','paddingLeft',
|
||||
'fontStyle','fontVariant','fontWeight','fontStretch','fontSize','fontSizeAdjust',
|
||||
'lineHeight','fontFamily','textAlign','textTransform','textIndent','textDecoration',
|
||||
'letterSpacing','wordSpacing','tabSize','MozTabSize','whiteSpace'
|
||||
]
|
||||
div.style.position = 'absolute'
|
||||
div.style.visibility = 'hidden'
|
||||
div.style.whiteSpace = 'pre-wrap'
|
||||
div.style.wordWrap = 'break-word'
|
||||
props.forEach(function (p) { div.style[p] = s[p] })
|
||||
div.style.position = 'absolute'
|
||||
div.style.top = '0'
|
||||
div.style.left = '0'
|
||||
var rect = el.getBoundingClientRect()
|
||||
document.body.appendChild(div)
|
||||
var pos = el.selectionStart
|
||||
var before = el.value.substring(0, pos)
|
||||
div.textContent = before
|
||||
var span = document.createElement('span')
|
||||
span.textContent = el.value.substring(pos) || '.'
|
||||
div.appendChild(span)
|
||||
var top = span.offsetTop + parseInt(s.borderTopWidth, 10)
|
||||
var left = span.offsetLeft + parseInt(s.borderLeftWidth, 10)
|
||||
document.body.removeChild(div)
|
||||
return { top: top, left: left }
|
||||
}
|
||||
})()
|
||||
Reference in New Issue
Block a user