terms: dark editor BG, vertical row layout, add/delete custom kinds
- 약관 편집기 배경/슬래시 메뉴를 사이트 다크 팔레트로 통일 (흰 배경 + 흰 글씨 가시성 문제 해결) - 약관 목록을 가로 풀폭 1줄씩 세로로 쌓이는 레이아웃으로 변경 - 사용자 정의 약관 추가/삭제 지원 - manifest/terms/_meta.json 에 라벨 저장 - builtin 5종(map/resourcepack/mod/installer/installer-rp)은 삭제 불가, "기본" 배지 표시 - kind 식별자 규칙: 소문자/숫자/하이픈 32자 이내 - 공개 라우트 /manifest/terms/<file>.md 는 isPublicTermsFile() 로 _meta.json 차단 - 0.3.0 → 0.3.1 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -156,7 +156,20 @@
|
||||
"slashDivider": "구분선",
|
||||
"slashQuote": "인용",
|
||||
"slashCode": "코드",
|
||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
|
||||
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?",
|
||||
"builtinBadge": "기본",
|
||||
"addHeading": "약관 추가",
|
||||
"kindLabel": "식별자",
|
||||
"kindPlaceholder": "예: privacy",
|
||||
"kindHint": "소문자/숫자/하이픈만 사용, 32자 이내. 파일명과 URL 에 그대로 쓰입니다.",
|
||||
"labelLabel": "표시 이름",
|
||||
"labelPlaceholder": "예: 개인정보 처리방침",
|
||||
"addButton": "추가",
|
||||
"deleteButton": "삭제",
|
||||
"deleteConfirm": "정말 \"{{label}}\" 약관을 삭제할까요? 이 동작은 되돌릴 수 없습니다.",
|
||||
"invalidKind": "식별자는 소문자/숫자/하이픈만, 32자 이내여야 합니다.",
|
||||
"createFailed": "약관 추가 실패",
|
||||
"cannotDeleteBuiltin": "기본 약관은 삭제할 수 없습니다."
|
||||
},
|
||||
"datapack": {
|
||||
"browserTitle": "데이터팩 수정",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minecraft-music-quiz-installer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
|
||||
"main": "dist/installer/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Notion 스타일 약관 편집기 전용 스타일.
|
||||
* 텍스트영역과 미리보기 영역을 동일한 폭/타이포로 보여 주어 입력 ↔ 미리보기
|
||||
* 전환 시 시각적 점프가 최소화되도록 한다. 슬래시 메뉴는 caret 좌표 위에
|
||||
* 절대 위치로 띄운다. */
|
||||
* 절대 위치로 띄운다. 색은 사이트 다크 팔레트(var(--bg-card) 등)에 맞춘다. */
|
||||
|
||||
.termsEditorWrap {
|
||||
position: relative;
|
||||
@@ -12,9 +12,10 @@
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #d5d5d5;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
background: var(--bg-card, #1f242c);
|
||||
color: var(--text, #e6edf3);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
@@ -22,19 +23,21 @@
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
white-space: pre-wrap;
|
||||
caret-color: var(--accent, #58a6ff);
|
||||
}
|
||||
|
||||
.termsEditor:focus {
|
||||
border-color: #5b8def;
|
||||
box-shadow: 0 0 0 2px rgba(91, 141, 239, 0.2);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
.termsPreview {
|
||||
min-height: 60vh;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #d5d5d5;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
box-sizing: border-box;
|
||||
@@ -46,23 +49,30 @@
|
||||
.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 hr { border: none; border-top: 1px solid var(--border, #30363d); margin: 12px 0; }
|
||||
.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 {
|
||||
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-size: 13px;
|
||||
}
|
||||
.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 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 {
|
||||
margin: 6px 0; border: 1px solid #e0e0e0; border-radius: 6px;
|
||||
background: #fff; padding: 4px 10px;
|
||||
margin: 6px 0;
|
||||
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; }
|
||||
|
||||
@@ -73,10 +83,11 @@
|
||||
min-width: 220px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
background: var(--bg-alt, #161b22);
|
||||
color: var(--text, #e6edf3);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
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;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -89,7 +100,7 @@
|
||||
}
|
||||
.slashMenu .slashItem:hover,
|
||||
.slashMenu .slashItem.active {
|
||||
background: #eef2ff;
|
||||
background: var(--bg-card, #1f242c);
|
||||
}
|
||||
.slashMenu .slashItem strong { font-size: 13px; }
|
||||
.slashMenu .slashItem span { color: #888; font-size: 11px; }
|
||||
.slashMenu .slashItem strong { font-size: 13px; color: var(--text, #e6edf3); }
|
||||
.slashMenu .slashItem span { color: var(--text-muted, #8b949e); font-size: 11px; }
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
manifestRootPath, manifestDirPath, manifestTermsDirPath,
|
||||
fileDirPath, viewsDirPath, publicDirPath
|
||||
} from '../shared/paths.js'
|
||||
import { isPublicTermsFile } from '../shared/store.js'
|
||||
import { loadEnv } from '../shared/env.js'
|
||||
import { t, localeDict } from './i18n.js'
|
||||
import { indexRouter } from './routes/index.js'
|
||||
@@ -62,11 +63,11 @@ app.get('/manifest.json', (_req, res) => {
|
||||
res.sendFile(manifestRootPath)
|
||||
})
|
||||
|
||||
// 설치기에서 약관(markdown) 을 가져갈 수 있도록 화이트리스트 파일명만 허용.
|
||||
// 설치기 + 사이트가 약관(markdown) 을 가져갈 수 있도록 .md 만 허용한다.
|
||||
// _meta.json 같은 시스템 파일이나 경로 탈출은 isPublicTermsFile 에서 차단.
|
||||
app.get('/manifest/terms/:fileName', (req, res) => {
|
||||
const fileName = req.params.fileName
|
||||
// 화이트리스트: map.md, resourcepack.md, mod.md, installer.md, installer-rp.md
|
||||
if (!/^(map|resourcepack|mod|installer|installer-rp)\.md$/.test(fileName)) {
|
||||
if (!isPublicTermsFile(fileName)) {
|
||||
res.status(404).send('Not Found')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ import { Router } from 'express'
|
||||
import archiver from 'archiver'
|
||||
import {
|
||||
createPack,
|
||||
createTerm,
|
||||
deletePackKeys,
|
||||
deleteTerm,
|
||||
getTermLabel,
|
||||
isBuiltinTermKind,
|
||||
isTermKind,
|
||||
listPackKeys,
|
||||
listTermsWithLabels,
|
||||
loadPackDefinition,
|
||||
loadPackList,
|
||||
loadTerm,
|
||||
@@ -13,11 +19,8 @@ import {
|
||||
renamePack,
|
||||
sanitizePackKey,
|
||||
saveTerm,
|
||||
savePackList,
|
||||
isTermKind,
|
||||
TERM_KINDS
|
||||
savePackList
|
||||
} from '../../shared/store.js'
|
||||
import type { TermKind } from '../../shared/store.js'
|
||||
import { fetchReleaseVersions } from '../../shared/mojang.js'
|
||||
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
|
||||
import { requireAuth } from '../middleware/auth.js'
|
||||
@@ -301,19 +304,49 @@ opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res,
|
||||
})
|
||||
|
||||
// ─── /op/agreement ─────────────────────────────────────────────────────
|
||||
// 약관(Markdown) 5종 편집기. 사이트에서는 노션 스타일의 슬래시 명령으로
|
||||
// 마크다운을 작성하고, 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
|
||||
const TERM_LABELS: Record<TermKind, string> = {
|
||||
'map': '맵 약관',
|
||||
'resourcepack': '리소스팩 약관',
|
||||
'mod': '모드 약관',
|
||||
'installer': '설치기 약관',
|
||||
'installer-rp': '리소스팩 설치기 약관'
|
||||
}
|
||||
// 약관(Markdown) 편집기. builtin 5종은 항상 존재하고 삭제 불가, 그 외 임의 kind 는
|
||||
// 사이트에서 추가/삭제 가능. 인스톨러는 /manifest/terms/<kind>.md 로 받아 표시한다.
|
||||
|
||||
opRouter.get('/op/agreement', requireAuth, (req, res) => {
|
||||
const items = TERM_KINDS.map((kind) => ({ kind, label: TERM_LABELS[kind] }))
|
||||
res.render('op/terms', { userId: req.session.userId, items })
|
||||
opRouter.get('/op/agreement', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const items = await listTermsWithLabels()
|
||||
res.render('op/terms', { userId: req.session.userId, items })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/create', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
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}`)
|
||||
} catch (error) {
|
||||
res.status(400).send((error as Error).message || t('terms.createFailed'))
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.post('/op/agreement/:kind/delete', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const kind = pickFirstValue(req.params.kind)
|
||||
if (!isTermKind(kind)) {
|
||||
res.status(400).send(t('terms.invalidKind'))
|
||||
return
|
||||
}
|
||||
if (isBuiltinTermKind(kind)) {
|
||||
res.status(400).send(t('terms.cannotDeleteBuiltin'))
|
||||
return
|
||||
}
|
||||
await deleteTerm(kind)
|
||||
res.redirect('/op/agreement')
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
||||
@@ -324,10 +357,11 @@ opRouter.get('/op/agreement/:kind', requireAuth, async (req, res, next) => {
|
||||
return
|
||||
}
|
||||
const content = await loadTerm(kind)
|
||||
const label = await getTermLabel(kind)
|
||||
res.render('op/termsEditor', {
|
||||
userId: req.session.userId,
|
||||
kind,
|
||||
label: TERM_LABELS[kind],
|
||||
label,
|
||||
content
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -296,17 +296,116 @@ export async function savePackList(packKey: string, list: PackList): Promise<voi
|
||||
|
||||
// ─── Terms (Markdown 약관) ─────────────────────────────────────────────
|
||||
// 사이트와 인스톨러가 약관을 보여주기 위해 사용하는 markdown 파일.
|
||||
// 화이트리스트로 5종만 허용한다.
|
||||
export type TermKind = 'map' | 'resourcepack' | 'mod' | 'installer' | 'installer-rp'
|
||||
export const TERM_KINDS: readonly TermKind[] = [
|
||||
'map', 'resourcepack', 'mod', 'installer', 'installer-rp'
|
||||
]
|
||||
// - 5개 builtin 종류는 인스톨러가 직접 참조하므로 삭제할 수 없다.
|
||||
// - 그 외 임의 kind 는 사이트에서 추가/삭제 가능. 라벨은 _meta.json 에 저장.
|
||||
// - 파일명 규칙: `[a-z0-9][a-z0-9-]{0,31}\.md` (소문자/숫자/하이픈, 32자 이내).
|
||||
export type TermKind = string
|
||||
|
||||
/** 인스톨러가 하드코딩으로 참조하는 builtin kind. 삭제 금지. */
|
||||
export const BUILTIN_TERM_KINDS = ['map', 'resourcepack', 'mod', 'installer', 'installer-rp'] as const
|
||||
export type BuiltinTermKind = typeof BUILTIN_TERM_KINDS[number]
|
||||
|
||||
/** builtin 라벨. 사용자 정의 kind 는 _meta.json 에 저장된 라벨을 쓴다. */
|
||||
const BUILTIN_TERM_LABELS: Record<BuiltinTermKind, string> = {
|
||||
'map': '맵 약관',
|
||||
'resourcepack': '리소스팩 약관',
|
||||
'mod': '모드 약관',
|
||||
'installer': '설치기 약관',
|
||||
'installer-rp': '리소스팩 설치기 약관'
|
||||
}
|
||||
|
||||
const TERM_KIND_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||||
|
||||
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 function isBuiltinTermKind(value: string): value is BuiltinTermKind {
|
||||
return (BUILTIN_TERM_KINDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
interface TermsMeta {
|
||||
/** 사용자 정의 kind 라벨. builtin 은 들어가지 않는다. */
|
||||
customLabels: Record<string, string>
|
||||
}
|
||||
|
||||
const TERMS_META_FILE = '_meta.json'
|
||||
|
||||
async function loadTermsMeta(): Promise<TermsMeta> {
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(manifestTermsDirPath, TERMS_META_FILE), 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
const customLabels: Record<string, string> = {}
|
||||
if (parsed && typeof parsed === 'object' && parsed.customLabels && typeof parsed.customLabels === 'object') {
|
||||
for (const [k, v] of Object.entries(parsed.customLabels as Record<string, unknown>)) {
|
||||
if (typeof v === 'string' && TERM_KIND_RE.test(k)) customLabels[k] = v
|
||||
}
|
||||
}
|
||||
return { customLabels }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { customLabels: {} }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTermsMeta(meta: TermsMeta): Promise<void> {
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
await fsp.writeFile(
|
||||
path.join(manifestTermsDirPath, TERMS_META_FILE),
|
||||
`${JSON.stringify(meta, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
export interface TermItem {
|
||||
kind: string
|
||||
label: string
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 디스크의 .md 파일 + _meta.json 을 합쳐 약관 목록을 만든다.
|
||||
* - builtin 5종은 파일 존재 여부와 무관하게 항상 포함된다 (인스톨러가 fetch 하므로).
|
||||
* - 디스크에 있고 _meta.json 에 라벨이 있는 사용자 정의 kind 도 포함.
|
||||
* - builtin → 사용자 정의 순서, builtin 내부는 BUILTIN_TERM_KINDS 정의 순서를 유지.
|
||||
*/
|
||||
export async function listTermsWithLabels(): Promise<TermItem[]> {
|
||||
const meta = await loadTermsMeta()
|
||||
const items: TermItem[] = []
|
||||
for (const kind of BUILTIN_TERM_KINDS) {
|
||||
items.push({ kind, label: BUILTIN_TERM_LABELS[kind], builtin: true })
|
||||
}
|
||||
// 디스크에 실제로 존재하는 사용자 정의 .md 파일만 노출.
|
||||
let onDisk: string[] = []
|
||||
try {
|
||||
onDisk = await fsp.readdir(manifestTermsDirPath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const customKinds = 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
|
||||
if (isBuiltinTermKind(kind)) continue
|
||||
customKinds.add(kind)
|
||||
}
|
||||
// _meta.json 에 라벨이 등록된 것만 노출 (라벨 없는 orphan .md 는 무시).
|
||||
for (const kind of Object.keys(meta.customLabels).sort((a, b) => a.localeCompare(b, 'ko'))) {
|
||||
if (!customKinds.has(kind)) continue
|
||||
items.push({ kind, label: meta.customLabels[kind], builtin: false })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export async function getTermLabel(kind: string): Promise<string> {
|
||||
if (isBuiltinTermKind(kind)) return BUILTIN_TERM_LABELS[kind]
|
||||
const meta = await loadTermsMeta()
|
||||
return meta.customLabels[kind] ?? kind
|
||||
}
|
||||
|
||||
export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
if (!isTermKind(kind)) return ''
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
try {
|
||||
return await fsp.readFile(filePath, 'utf8')
|
||||
@@ -317,12 +416,60 @@ export async function loadTerm(kind: TermKind): Promise<string> {
|
||||
}
|
||||
|
||||
export async function saveTerm(kind: TermKind, markdown: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
const normalized = (markdown ?? '').replace(/\r\n/g, '\n')
|
||||
await fsp.writeFile(filePath, normalized.endsWith('\n') ? normalized : `${normalized}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/** 새로운 사용자 정의 약관 추가. kind 충돌/builtin 충돌은 예외. 빈 .md 파일을 만든다. */
|
||||
export async function createTerm(kind: string, label: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be created')
|
||||
const cleanLabel = label.trim()
|
||||
if (cleanLabel.length === 0 || cleanLabel.length > 50) throw new Error('invalid label length')
|
||||
const meta = await loadTermsMeta()
|
||||
if (meta.customLabels[kind]) throw new Error('term kind already exists')
|
||||
await fsp.mkdir(manifestTermsDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestTermsDirPath, `${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')
|
||||
meta.customLabels[kind] = cleanLabel
|
||||
await saveTermsMeta(meta)
|
||||
}
|
||||
|
||||
/** 사용자 정의 약관 삭제. builtin 은 거부. */
|
||||
export async function deleteTerm(kind: string): Promise<void> {
|
||||
if (!isTermKind(kind)) throw new Error('invalid term kind')
|
||||
if (isBuiltinTermKind(kind)) throw new Error('builtin term kind cannot be deleted')
|
||||
const filePath = path.join(manifestTermsDirPath, `${kind}.md`)
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
const meta = await loadTermsMeta()
|
||||
if (meta.customLabels[kind]) {
|
||||
delete meta.customLabels[kind]
|
||||
await saveTermsMeta(meta)
|
||||
}
|
||||
}
|
||||
|
||||
/** 공개 라우트(`/manifest/terms/<file>`)에서 호출. _meta.json 같은 시스템 파일을 차단하기 위함. */
|
||||
export function isPublicTermsFile(fileName: string): boolean {
|
||||
// .md 만 허용, 이름 규칙 일치, builtin 또는 정상 kind 패턴.
|
||||
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[]> {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
|
||||
@@ -5,6 +5,48 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><%= t('terms.browserTitle') %></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;
|
||||
}
|
||||
.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>
|
||||
<body class="siteBody">
|
||||
<%- include('../partials/navbar', { userId }) %>
|
||||
@@ -19,16 +61,53 @@
|
||||
|
||||
<p class="muted"><%= t('terms.hint') %></p>
|
||||
|
||||
<section class="cardRow horizontalScroll">
|
||||
<section class="termsList">
|
||||
<% items.forEach(function (item) { %>
|
||||
<article class="packCard">
|
||||
<a class="cardLink" href="/op/agreement/<%= item.kind %>">
|
||||
<h2><%= item.label %></h2>
|
||||
<p class="muted"><%= item.kind %>.md</p>
|
||||
<article class="termsRow">
|
||||
<a class="termsRowMain" href="/op/agreement/<%= 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/<%= 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>
|
||||
<% }) %>
|
||||
</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> </label>
|
||||
<button type="submit" class="primaryButton"><%= t('terms.addButton') %></button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user