list-editor: add per-track description button + modal
Each music list row now shows a 설명 button immediately to the left of the 별칭 button. Click opens a modal with a multi-line textarea; on close the value is persisted into MusicListEntry.description and saved to the same pack list JSON. The button gets a hasDesc visual indicator when filled. Description is stored but intentionally not consumed by datapack export or alias matching — purely informational metadata. - types.ts: add description: string to MusicListEntry - store.ts: normalize entry.description via sanitizeStr (defaults to '') - listEditor.ejs: new #descModal alongside aliasModal - listEditor.js: render descBtn left of aliasBtn, attach handlers, also set description: '' on playlist-fetched entries - styles.css: extend trackRow grid to 6 cols, reuse aliasBtn styling for descBtn, add descTextarea sizing - locale (ko-kr): descBtn / descModalTitle / descBack / descPlaceholder / descHint Backwards-compatible: existing list JSON files without description field normalize to ''. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,11 @@
|
||||
"aliasPlaceholder": "별칭 입력",
|
||||
"aliasRemove": "삭제",
|
||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||
"descBtn": "설명",
|
||||
"descModalTitle": "설명 - {{title}}",
|
||||
"descBack": "← 돌아가기",
|
||||
"descPlaceholder": "이 곡에 대한 설명을 입력하세요",
|
||||
"descHint": "곡 소개·트리비아 등 자유 메모. 정답 채점이나 데이터팩에는 사용되지 않습니다.",
|
||||
"metaLoading": "메타데이터 가져오는 중…",
|
||||
"metaFailedShort": "메타 조회 실패",
|
||||
"metaFailedTitle": "메타데이터 조회 실패",
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
var aliasLabel = aliasCount > 0
|
||||
? tt('aliasBtnWithCount', { count: aliasCount })
|
||||
: tt('aliasBtn')
|
||||
var hasDesc = typeof entry.description === 'string' && entry.description.trim().length > 0
|
||||
li.innerHTML =
|
||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||
@@ -114,12 +115,16 @@
|
||||
escapeHtml(entry.artist || '') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="descBtn' + (hasDesc ? ' hasDesc' : '') + '" data-desc-open="' + idx + '" draggable="false">' +
|
||||
escapeHtml(tt('descBtn')) +
|
||||
'</button>' +
|
||||
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
|
||||
escapeHtml(aliasLabel) +
|
||||
'</button>' +
|
||||
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||
attachDraggable(li, 'music', idx)
|
||||
attachInlineEdit(li, idx)
|
||||
attachDescBtn(li, idx)
|
||||
attachAliasBtn(li, idx)
|
||||
ol.appendChild(li)
|
||||
})
|
||||
@@ -527,6 +532,57 @@
|
||||
if (e.target === aliasModal) closeAliasModalSaving()
|
||||
})
|
||||
|
||||
// ── 설명 모달 (음악) ─────────────────────────────────
|
||||
// 별칭 모달과 같은 패턴: 모달 닫힐 때 textarea 값을 state.music[idx].description 에 저장.
|
||||
var descModal = document.getElementById('descModal')
|
||||
var descTextarea = document.getElementById('desc-textarea')
|
||||
var descModalTitleEl = document.getElementById('desc-modal-title')
|
||||
var descBackBtn = document.getElementById('desc-back')
|
||||
var descEditingIdx = -1
|
||||
|
||||
function attachDescBtn(li, idx) {
|
||||
var btn = li.querySelector('[data-desc-open]')
|
||||
if (!btn) return
|
||||
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation()
|
||||
openDescModal(idx)
|
||||
})
|
||||
}
|
||||
|
||||
function openDescModal(idx) {
|
||||
if (!state.music[idx]) return
|
||||
descEditingIdx = idx
|
||||
var entry = state.music[idx]
|
||||
descModalTitleEl.textContent = tt('descModalTitle', { title: entry.title || tt('titleFallback') })
|
||||
descTextarea.value = typeof entry.description === 'string' ? entry.description : ''
|
||||
descModal.hidden = false
|
||||
setTimeout(function () { descTextarea.focus() }, 0)
|
||||
}
|
||||
|
||||
function closeDescModalSaving() {
|
||||
if (descEditingIdx < 0 || !state.music[descEditingIdx]) {
|
||||
descModal.hidden = true
|
||||
descEditingIdx = -1
|
||||
return
|
||||
}
|
||||
// textarea 값을 그대로 저장하되, 줄바꿈은 보존하고 양끝 공백만 다듬는다.
|
||||
var nextDesc = (descTextarea.value || '').replace(/\r\n/g, '\n').trim()
|
||||
var prev = state.music[descEditingIdx].description || ''
|
||||
if (nextDesc !== prev) {
|
||||
state.music[descEditingIdx].description = nextDesc
|
||||
markDirty()
|
||||
renderMusic()
|
||||
}
|
||||
descModal.hidden = true
|
||||
descEditingIdx = -1
|
||||
}
|
||||
|
||||
descBackBtn.addEventListener('click', closeDescModalSaving)
|
||||
descModal.addEventListener('click', function (e) {
|
||||
if (e.target === descModal) closeDescModalSaving()
|
||||
})
|
||||
|
||||
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
|
||||
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||
if (state.music.length === 0) {
|
||||
@@ -637,7 +693,7 @@
|
||||
var entries = result.body.entries || []
|
||||
if (target === 'music') {
|
||||
state.music = entries.map(function (e) {
|
||||
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0 }
|
||||
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0, aliases: [], description: '' }
|
||||
})
|
||||
renderMusic()
|
||||
} else {
|
||||
|
||||
@@ -407,19 +407,24 @@ body.siteBody.centerLayout {
|
||||
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.trackRow {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 80px 1fr auto auto;
|
||||
grid-template-columns: 36px 80px 1fr auto auto auto;
|
||||
gap: 12px; align-items: center;
|
||||
padding: 8px 12px; background: var(--bg-card);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
cursor: grab; user-select: none;
|
||||
}
|
||||
.aliasBtn {
|
||||
.aliasBtn, .descBtn {
|
||||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aliasBtn:hover { border-color: var(--accent); }
|
||||
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); }
|
||||
.aliasBtn:hover, .descBtn:hover { border-color: var(--accent); }
|
||||
.aliasBtn.hasAliases, .descBtn.hasDesc { border-color: var(--accent); color: var(--accent); }
|
||||
.descTextarea {
|
||||
width: 100%; min-height: 140px; resize: vertical;
|
||||
font-family: inherit; font-size: 13px; line-height: 1.5;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* 별칭 모달 */
|
||||
.aliasModalHeader {
|
||||
|
||||
@@ -287,7 +287,8 @@ export function normalizePackList(input: unknown): PackList {
|
||||
title: sanitizeStr(entry.title),
|
||||
artist: sanitizeStr(entry.artist),
|
||||
durationSec: sanitizeNumber(entry.durationSec),
|
||||
aliases: sanitizeAliases(entry.aliases)
|
||||
aliases: sanitizeAliases(entry.aliases),
|
||||
description: sanitizeStr(entry.description)
|
||||
}))
|
||||
.filter((entry) => entry.url.length > 0),
|
||||
images: images
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface MusicListEntry {
|
||||
durationSec: number
|
||||
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||
aliases: string[]
|
||||
/** 곡 설명 / 트리비아 메모. 정답 채점이나 데이터팩 생성에는 사용되지 않는다. */
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ImageListEntry {
|
||||
|
||||
@@ -124,6 +124,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description modal (music) -->
|
||||
<div class="modalOverlay" id="descModal" hidden>
|
||||
<div class="modalCard">
|
||||
<header class="aliasModalHeader">
|
||||
<button type="button" class="ghostLink" id="desc-back"><%= t('listEditor.descBack') %></button>
|
||||
<h3 id="desc-modal-title"></h3>
|
||||
<span></span>
|
||||
</header>
|
||||
<div class="modalBody">
|
||||
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.descHint') %></p>
|
||||
<textarea id="desc-textarea" class="textInput descTextarea" placeholder="<%= t('listEditor.descPlaceholder') %>" rows="6"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal (image) -->
|
||||
<div class="modalOverlay" id="editImageModal" hidden>
|
||||
<div class="modalCard">
|
||||
|
||||
Reference in New Issue
Block a user