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:
147
views/op/terms-pack.ejs
Normal file
147
views/op/terms-pack.ejs
Normal 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>
|
||||
Reference in New Issue
Block a user