2 Commits

Author SHA1 Message Date
05dc9d7166 terms: seed-on-fetch + rename/delete sync (v0.3.3)
- public route `/manifest/terms/:packKey/:fileName` 가 sendFile 전에
  `ensurePackTermsDir(packKey)` 를 호출하도록 수정. 관리자가 사이트 약관
  페이지를 한 번도 열지 않은 fresh 배포에서도 설치기가 정상적으로 약관을
  받을 수 있다. `loadPackDefinition` 으로 실제 pack 만 허용해 임의 키로
  빈 폴더가 생성되는 것을 차단.
- `renamePack`: pack JSON 이름이 바뀌면 `manifest/terms/<oldKey>/` 도
  `<newKey>/` 로 함께 rename.
- `deletePackKeys`: pack 삭제 시 약관 폴더도 `fs.rm` 으로 정리 — 동일 key
  재생성 시 옛 약관 부활 방지.
- `ensurePackTermsDir` export.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:39:28 +09:00
25977d894b terms: per-pack storage + import from another pack (v0.3.2)
- store.ts: 약관을 manifest/terms/<packKey>/ 폴더별로 저장. 첫 접근 시
  legacy 전역 .md 파일을 시드로 자동 복사한다.
- importTerms() 추가: 다른 음악퀴즈의 .md + _meta.json 을 현재 pack 으로
  복사한다. 동일 kind 는 source 값으로 덮어쓴다.
- /op/agreement 라우트를 세 단계로 분리:
  · /op/agreement → 음악퀴즈 카드 선택 페이지
  · /op/agreement/:packName → 해당 pack 의 약관 목록 + 추가 + 불러오기
  · /op/agreement/:packName/:kind → 에디터
- 공개 라우트도 /manifest/terms/:packKey/:fileName 으로 변경.
- 설치기 main.ts: state.selectedKey 를 약관 URL 에 포함하도록 수정 (메인 +
  rp 양쪽). pack 미선택 상태에서는 에러 반환.
- termsEditor.js: PACK_KEY 를 받아 저장 URL 에 포함.
- 다른 음악퀴즈 후보 select + 확인 모달 + locale 추가.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:29:04 +09:00
11 changed files with 476 additions and 148 deletions

View File

@@ -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}}",
@@ -169,7 +172,16 @@
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.", "deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.", "invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
"createFailed": "약관 추가 실패", "createFailed": "약관 추가 실패",
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다." "cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다.",
"importHeading": "다른 음악퀴즈에서 불러오기",
"importSourceLabel": "가져올 음악퀴즈",
"importSourcePlaceholder": "음악퀴즈를 선택하세요",
"importHint": "선택한 음악퀴즈의 모든 약관(.md + 라벨)을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관이 있으면 덮어씁니다.",
"importButton": "불러오기",
"importEmpty": "불러올 수 있는 다른 음악퀴즈가 없습니다.",
"importConfirm": "선택한 음악퀴즈의 약관을 현재 음악퀴즈로 복사합니다. 같은 식별자의 약관은 덮어쓰여집니다. 진행할까요?",
"importFailed": "약관 불러오기 실패",
"invalidImportSource": "올바르지 않은 음악퀴즈입니다."
}, },
"datapack": { "datapack": {
"browserTitle": "데이터팩 수정", "browserTitle": "데이터팩 수정",

View File

@@ -1,6 +1,6 @@
{ {
"name": "minecraft-music-quiz-installer", "name": "minecraft-music-quiz-installer",
"version": "0.3.1", "version": "0.3.3",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js", "main": "dist/installer/main.js",
"scripts": { "scripts": {

View File

@@ -162,7 +162,7 @@
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), { 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({ content: editor.value })

View File

@@ -252,14 +252,15 @@ 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) 으로 반환. // 사이트가 /manifest/terms/<packKey>/<kind>.md 로 노출하는 md 파일을 그대로 받아 본문(string) 으로 반환.
// rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인 // rp 인스톨러에서는 'resourcepack' 과 'installer-rp' 두 종류만 실제로 사용하지만, 메인
// 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다. // 인스톨러와 동일한 화이트리스트를 둬서 사이트 컴포넌트 분류와 1:1 로 매칭되게 한다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp']) 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 (!TERM_KIND_WHITELIST.has(kind)) return { ok: false, message: 'unknown 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) {

View File

@@ -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종 제한. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다. // 화이트리스트로 5종 제한. pack 미선택 상태에서는 에러를 돌려준다. 네트워크 실패 시 에러 메시지가 그대로 화면에 노출된다.
const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp']) const TERM_KIND_WHITELIST = new Set(['map', 'resourcepack', 'mod', 'installer', 'installer-rp'])
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 (!TERM_KIND_WHITELIST.has(kind)) {
return { ok: false, message: 'unknown term kind' } return { ok: false, message: 'unknown 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) {

View File

@@ -6,7 +6,7 @@ import {
manifestRootPath, manifestDirPath, manifestTermsDirPath, manifestRootPath, manifestDirPath, manifestTermsDirPath,
fileDirPath, viewsDirPath, publicDirPath fileDirPath, viewsDirPath, publicDirPath
} from '../shared/paths.js' } from '../shared/paths.js'
import { isPublicTermsFile } from '../shared/store.js' import { ensurePackTermsDir, isPublicTermsFile, 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'
@@ -64,18 +64,34 @@ app.get('/manifest.json', (_req, res) => {
}) })
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다. // 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단. // _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
app.get('/manifest/terms/:fileName', (req, res) => { //
const fileName = req.params.fileName // fresh 배포에서 관리자가 약관 페이지를 한 번도 열지 않은 상태로 설치기가 약관을
if (!isPublicTermsFile(fileName)) { // 요청하는 경우에도 작동하도록, 실제 pack 이면 ensurePackTermsDir 로 v0.3.1
// 전역 .md 들을 시드 복사한 뒤 sendFile 한다. 임의 packKey 로 빈 폴더가
// 생성되는 것은 loadPackDefinition 으로 차단.
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') res.status(404).send('Not Found')
return 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.type('text/markdown; charset=utf-8')
res.sendFile(path.join(manifestTermsDirPath, fileName), (err) => { res.sendFile(path.join(manifestTermsDirPath, packKey, fileName), (err) => {
if (!err || res.headersSent) return if (!err || res.headersSent) return
res.status(404).send('Not Found') res.status(404).send('Not Found')
}) })
} catch (error) {
next(error)
}
}) })
// 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용. // 설치기에서 개별 음악퀴즈 JSON을 가져갈 수 있도록 파일 단위로만 허용.

View File

@@ -6,6 +6,7 @@ import {
deletePackKeys, deletePackKeys,
deleteTerm, deleteTerm,
getTermLabel, getTermLabel,
importTerms,
isBuiltinTermKind, isBuiltinTermKind,
isTermKind, isTermKind,
listPackKeys, listPackKeys,
@@ -304,35 +305,107 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
}) })
// ─── /op/agreement ───────────────────────────────────────────────────── // ─── /op/agreement ─────────────────────────────────────────────────────
// 약관(Markdown) 편집기. builtin 5종은 항상 존재하고 삭제 불가, 그 외 임의 kind 는 // 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다. // builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<packKey>/<kind>.md 로 받아 표시한다.
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => { opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
try { try {
const items = await listTermsWithLabels() 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 }) res.render('op/terms', { userId: req.session.userId, items })
} catch (error) { } catch (error) {
next(error) next(error)
} }
}) })
opRouter.post('/op/agreement/create', 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 kindInput = pickFirstValue(req.body.kind).trim().toLowerCase()
const label = pickFirstValue(req.body.label) const label = pickFirstValue(req.body.label)
if (!isTermKind(kindInput)) { if (!isTermKind(kindInput)) {
res.status(400).send(t('terms.invalidKind')) res.status(400).send(t('terms.invalidKind'))
return return
} }
await createTerm(kindInput, label) await createTerm(packKey, kindInput, label)
res.redirect(`/op/agreement/${kindInput}`) res.redirect(`/op/agreement/${packKey}/${kindInput}`)
} catch (error) { } catch (error) {
res.status(400).send((error as Error).message || t('terms.createFailed')) res.status(400).send((error as Error).message || t('terms.createFailed'))
} }
}) })
opRouter.post('/op/agreement/:kind/delete', requireAuth, async (req, res, next) => { opRouter.post('/op/agreement/:packName/import', 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 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) const kind = pickFirstValue(req.params.kind)
if (!isTermKind(kind)) { if (!isTermKind(kind)) {
res.status(400).send(t('terms.invalidKind')) res.status(400).send(t('terms.invalidKind'))
@@ -342,24 +415,32 @@ opRouter.post('/op/agreement/:kind/delete', requireAuth, async (req, res, next)
res.status(400).send(t('terms.cannotDeleteBuiltin')) res.status(400).send(t('terms.cannotDeleteBuiltin'))
return return
} }
await deleteTerm(kind) await deleteTerm(packKey, kind)
res.redirect('/op/agreement') res.redirect(`/op/agreement/${packKey}`)
} catch (error) { } catch (error) {
next(error) next(error)
} }
}) })
opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => { opRouter.get('/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).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 content = await loadTerm(packKey, kind)
const label = await getTermLabel(kind) const label = await getTermLabel(packKey, kind)
res.render('op/termsEditor', { res.render('op/termsEditor', {
userId: req.session.userId, userId: req.session.userId,
packKey,
pack: definition,
kind, kind,
label, label,
content content
@@ -369,15 +450,21 @@ 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)
res.json({ ok: true }) res.json({ ok: true })
} catch (error) { } catch (error) {
next(error) next(error)

View File

@@ -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,9 +317,11 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
// ─── Terms (Markdown 약관) ───────────────────────────────────────────── // ─── Terms (Markdown 약관) ─────────────────────────────────────────────
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일. // 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
// - 음악퀴즈(pack)별로 독립 폴더(`manifest/terms/<packKey>/`) 에 저장한다.
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다. // - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 _meta.json 에 저장. // - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 `<packKey>/_meta.json` 에 저장.
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내). // - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
// - 레거시(전역) `manifest/terms/*.md` 파일이 남아 있으면 packKey 폴더 첫 접근 시 자동 시드.
export type TermKind = string export type TermKind = string
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */ /** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
@@ -331,9 +354,69 @@ interface TermsMeta {
const TERMS_META_FILE = '_meta.json' const TERMS_META_FILE = '_meta.json'
async function loadTermsMeta(): Promise<TermsMeta> { 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)
try { try {
const raw = await fsp.readFile(path.join(manifestTermsDirPath, TERMS_META_FILE), 'utf8') await fsp.access(dir)
return dir
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
await fsp.mkdir(dir, { recursive: true })
// 레거시 전역 파일을 시드로 복사.
try {
const legacyEntries = await fsp.readdir(manifestTermsDirPath, { withFileTypes: true })
for (const ent of legacyEntries) {
if (!ent.isFile()) continue
const name = ent.name
if (name === TERMS_META_FILE) {
try {
await fsp.copyFile(
path.join(manifestTermsDirPath, name),
path.join(dir, name)
)
} catch { /* ignore */ }
continue
}
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 (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
return dir
}
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) const parsed = JSON.parse(raw)
const customLabels: Record<string, string> = {} const customLabels: Record<string, string> = {}
if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') { if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') {
@@ -348,10 +431,10 @@ async function loadTermsMeta(): Promise<TermsMeta> {
} }
} }
async function saveTermsMeta(meta: TermsMeta): Promise<void> { async function saveTermsMeta(packKey: string, meta: TermsMeta): Promise<void> {
await fsp.mkdir(manifestTermsDirPath, { recursive: true }) const dir = await ensurePackTermsDir(packKey)
await fsp.writeFile( await fsp.writeFile(
path.join(manifestTermsDirPath, TERMS_META_FILE), path.join(dir, TERMS_META_FILE),
`${JSON.stringify(meta, null, 2)}\n`, `${JSON.stringify(meta, null, 2)}\n`,
'utf8' 'utf8'
) )
@@ -369,8 +452,9 @@ export interface TermItem {
* - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함. * - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함.
* - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지. * - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지.
*/ */
export async function listTermsWithLabels(): Promise<TermItem[]> { export async function listTermsWithLabels(packKey: string): Promise<TermItem[]> {
const meta = await loadTermsMeta() const dir = await ensurePackTermsDir(packKey)
const meta = await loadTermsMeta(packKey)
const items: TermItem[] = [] const items: TermItem[] = []
for (const kind of BUILTIN_TERM_KINDS) { for (const kind of BUILTIN_TERM_KINDS) {
items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true }) items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true })
@@ -378,7 +462,7 @@ export async function listTermsWithLabels(): Promise<TermItem[]> {
// 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출. // 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출.
let onDisk: string[] = [] let onDisk: string[] = []
try { try {
onDisk = await fsp.readdir(manifestTermsDirPath) onDisk = await fsp.readdir(dir)
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
} }
@@ -398,15 +482,16 @@ export async function listTermsWithLabels(): Promise<TermItem[]> {
return items return items
} }
export async function getTermLabel(kind: string): Promise<string> { export async function getTermLabel(packKey: string, kind: string): Promise<string> {
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind] if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
const meta = await loadTermsMeta() const meta = await loadTermsMeta(packKey)
return meta.customLabels[kind] ?? kind return meta.customLabels[kind] ?? kind
} }
export async function loadTerm(kind: TermKind): Promise<string> { export async function loadTerm(packKey: string, kind: TermKind): Promise<string> {
if (!isTermKind(kind)) return '' if (!isTermKind(kind)) return ''
const filePath = path.join(manifestTermsDirPath, `${kind}.md`) 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) {
@@ -415,24 +500,24 @@ 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> {
if (!isTermKind(kind)) throw new Error('invalid term kind') if (!isTermKind(kind)) throw new Error('invalid term kind')
await fsp.mkdir(manifestTermsDirPath, { recursive: true }) const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(manifestTermsDirPath, `${kind}.md`) 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 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */ /** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
export async function createTerm(kind: string, label: string): Promise<void> { export async function createTerm(packKey: string, kind: string, label: string): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind') if (!isTermKind(kind)) throw new Error('invalid term kind')
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created') if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created')
const cleanLabel = label.trim() const cleanLabel = label.trim()
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length') if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
const meta = await loadTermsMeta() const meta = await loadTermsMeta(packKey)
if (meta.customLabels[kind]) throw new Error('term kind already exists') if (meta.customLabels[kind]) throw new Error('term kind already exists')
await fsp.mkdir(manifestTermsDirPath, { recursive: true }) const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(manifestTermsDirPath, `${kind}.md`) const filePath = path.join(dir, `${kind}.md`)
// 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음). // 파일 충돌도 막는다 (수동 생성된 .md 가 있을 수 있음).
try { try {
await fsp.access(filePath) await fsp.access(filePath)
@@ -442,29 +527,74 @@ export async function createTerm(kind: string, label: string): Promise<void> {
} }
await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8') await fsp.writeFile(filePath, `# ${cleanLabel}\n\n`, 'utf8')
meta.customLabels[kind] = cleanLabel meta.customLabels[kind] = cleanLabel
await saveTermsMeta(meta) await saveTermsMeta(packKey, meta)
} }
/** 사용자 정의 약관 삭제. builtin 은 거부. */ /** 사용자 정의 약관 삭제. builtin 은 거부. */
export async function deleteTerm(kind: string): Promise<void> { export async function deleteTerm(packKey: string, kind: string): Promise<void> {
if (!isTermKind(kind)) throw new Error('invalid term kind') if (!isTermKind(kind)) throw new Error('invalid term kind')
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted') if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted')
const filePath = path.join(manifestTermsDirPath, `${kind}.md`) const dir = await ensurePackTermsDir(packKey)
const filePath = path.join(dir, `${kind}.md`)
try { try {
await fsp.unlink(filePath) await fsp.unlink(filePath)
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
} }
const meta = await loadTermsMeta() const meta = await loadTermsMeta(packKey)
if (meta.customLabels[kind]) { if (meta.customLabels[kind]) {
delete meta.customLabels[kind] delete meta.customLabels[kind]
await saveTermsMeta(meta) await saveTermsMeta(packKey, meta)
} }
} }
/** 공개 라우트(`/manifest/terms/<file>`)에서 호출. _meta.json 같은 시스템 파일을 차단하기 위함. */ /**
export function isPublicTermsFile(fileName: string): boolean { * 다른 음악퀴즈의 약관 전체를 현재 pack 으로 복사한다 (불러오기).
// .md 만 허용, 이름 규칙 일치, builtin 또는 정상 kind 패턴. * - source 의 모든 .md 와 _meta.json 을 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 mergedLabels: Record<string, string> = { ...targetMeta.customLabels }
for (const [k, v] of Object.entries(sourceMeta.customLabels)) {
mergedLabels[k] = v
}
await saveTermsMeta(targetPackKey, { customLabels: mergedLabels })
}
/**
* 공개 라우트(`/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 if (!fileName.toLowerCase().endsWith('.md')) return false
const kind = fileName.slice(0, -3) const kind = fileName.slice(0, -3)
return TERM_KIND_RE.test(kind) return TERM_KIND_RE.test(kind)

147
views/op/terms-pack.ejs Normal file
View File

@@ -0,0 +1,147 @@
<!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; }
.builtinBadge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
background: rgba(255,255,255,0.08); color: var(--text-muted);
font-size: 11px;
}
.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>
<% if (item.builtin) { %>
<span class="builtinBadge"><%= t('terms.builtinBadge') %></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>
<% if (!item.builtin) { %>
<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>

View File

@@ -5,48 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('terms.browserTitle') %></title> <title><%= t('terms.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" /> <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; }
.builtinBadge {
display: inline-block; padding: 2px 8px; border-radius: 999px;
background: rgba(255,255,255,0.08); color: var(--text-muted);
font-size: 11px;
}
.termsAddSection {
margin-top: 24px;
background: var(--bg-card);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 16px 18px;
}
.termsAddSection h2 { margin: 0 0 12px; font-size: 15px; }
.termsAddForm { display: grid; grid-template-columns: 1fr 2fr auto; 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 {
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; }
@media (max-width: 700px) {
.termsAddForm { grid-template-columns: 1fr; }
}
</style>
</head> </head>
<body class="siteBody"> <body class="siteBody">
<%- include('../partials/navbar', { userId }) %> <%- include('../partials/navbar', { userId }) %>
@@ -59,55 +17,28 @@
</div> </div>
</section> </section>
<p class="muted"><%= t('terms.hint') %></p> <p class="muted"><%= t('terms.pickPackHint') %></p>
<section class="termsList"> <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="termsRow"> <article class="packCard">
<a class="termsRowMain" href="/op/agreement/<%= item.kind %>" style="text-decoration:none; color:inherit;"> <a class="cardLink" href="/op/agreement/<%= item.key %>">
<div class="termsRowLabel"> <h2><%= item.definition ? item.definition.name : item.key %></h2>
<h2><%= item.label %></h2> <p class="muted"><%= item.key %>.json</p>
<% if (item.builtin) { %> <% if (item.definition) { %>
<span class="builtinBadge"><%= t('terms.builtinBadge') %></span> <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>
<% } %> <% } %>
</div>
<div class="termsRowSub"><%= item.kind %>.md</div>
</a> </a>
<div class="termsRowActions">
<a class="secondaryButton" href="/op/agreement/<%= item.kind %>"><%= t('terms.edit') %></a>
<% if (!item.builtin) { %>
<form method="post" action="/op/agreement/<%= 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> </article>
<% }) %> <% }) %>
</section> </section>
<section class="termsAddSection">
<h2><%= t('terms.addHeading') %></h2>
<form method="post" action="/op/agreement/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="field">
<label>&nbsp;</label>
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
</div>
</form>
</section>
</main> </main>
</body> </body>
</html> </html>

View File

@@ -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>
@@ -39,6 +39,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) %>;