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>
This commit is contained in:
@@ -64,15 +64,16 @@ app.get('/manifest.json', (_req, res) => {
|
||||
})
|
||||
|
||||
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||
// 음악퀴즈(pack) 별로 manifest/terms/<packKey>/<file>.md 에서 노출한다.
|
||||
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||
app.get('/manifest/terms/:fileName', (req, res) => {
|
||||
const fileName = req.params.fileName
|
||||
if (!isPublicTermsFile(fileName)) {
|
||||
app.get('/manifest/terms/:packKey/:fileName', (req, res) => {
|
||||
const { packKey, fileName } = req.params
|
||||
if (!isPublicTermsFile(packKey, fileName)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
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
|
||||
res.status(404).send('Not Found')
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
deletePackKeys,
|
||||
deleteTerm,
|
||||
getTermLabel,
|
||||
importTerms,
|
||||
isBuiltinTermKind,
|
||||
isTermKind,
|
||||
listPackKeys,
|
||||
@@ -304,35 +305,107 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
|
||||
})
|
||||
|
||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||
// 약관(Markdown) 편집기. builtin 5종은 항상 존재하고 삭제 불가, 그 외 임의 kind 는
|
||||
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
|
||||
// 약관(Markdown) 편집기. 음악퀴즈(pack) 단위로 따로 저장한다.
|
||||
// builtin 5종은 어느 pack 에서나 항상 존재하고 삭제 불가, 그 외 임의 kind 는
|
||||
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<packKey>/<kind>.md 로 받아 표시한다.
|
||||
|
||||
// /op/agreement → 음악퀴즈 선택(/op/list 와 동일한 카드 형식).
|
||||
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
||||
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 })
|
||||
} catch (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 {
|
||||
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(kindInput, label)
|
||||
res.redirect(`/op/agreement/${kindInput}`)
|
||||
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/:kind/delete', requireAuth, async (req, res, next) => {
|
||||
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'))
|
||||
@@ -342,24 +415,32 @@ opRouter.post('/op/agreement/:kind/delete', requireAuth, async (req, res, next)
|
||||
res.status(400).send(t('terms.cannotDeleteBuiltin'))
|
||||
return
|
||||
}
|
||||
await deleteTerm(kind)
|
||||
res.redirect('/op/agreement')
|
||||
await deleteTerm(packKey, kind)
|
||||
res.redirect(`/op/agreement/${packKey}`)
|
||||
} catch (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 {
|
||||
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(404).send(t('errors.unknown'))
|
||||
return
|
||||
}
|
||||
const content = await loadTerm(kind)
|
||||
const label = await getTermLabel(kind)
|
||||
const content = await loadTerm(packKey, kind)
|
||||
const label = await getTermLabel(packKey, kind)
|
||||
res.render('op/termsEditor', {
|
||||
userId: req.session.userId,
|
||||
packKey,
|
||||
pack: definition,
|
||||
kind,
|
||||
label,
|
||||
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 {
|
||||
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)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(404).json({ ok: false, message: t('errors.unknown') })
|
||||
return
|
||||
}
|
||||
const content = typeof req.body?.content === 'string' ? req.body.content : ''
|
||||
await saveTerm(kind, content)
|
||||
await saveTerm(packKey, kind, content)
|
||||
res.json({ ok: true })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
|
||||
Reference in New Issue
Block a user