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": "별칭 입력",
|
"aliasPlaceholder": "별칭 입력",
|
||||||
"aliasRemove": "삭제",
|
"aliasRemove": "삭제",
|
||||||
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
|
||||||
|
"descBtn": "설명",
|
||||||
|
"descModalTitle": "설명 - {{title}}",
|
||||||
|
"descBack": "← 돌아가기",
|
||||||
|
"descPlaceholder": "이 곡에 대한 설명을 입력하세요",
|
||||||
|
"descHint": "곡 소개·트리비아 등 자유 메모. 정답 채점이나 데이터팩에는 사용되지 않습니다.",
|
||||||
"metaLoading": "메타데이터 가져오는 중…",
|
"metaLoading": "메타데이터 가져오는 중…",
|
||||||
"metaFailedShort": "메타 조회 실패",
|
"metaFailedShort": "메타 조회 실패",
|
||||||
"metaFailedTitle": "메타데이터 조회 실패",
|
"metaFailedTitle": "메타데이터 조회 실패",
|
||||||
|
|||||||
@@ -103,6 +103,7 @@
|
|||||||
var aliasLabel = aliasCount > 0
|
var aliasLabel = aliasCount > 0
|
||||||
? tt('aliasBtnWithCount', { count: aliasCount })
|
? tt('aliasBtnWithCount', { count: aliasCount })
|
||||||
: tt('aliasBtn')
|
: tt('aliasBtn')
|
||||||
|
var hasDesc = typeof entry.description === 'string' && entry.description.trim().length > 0
|
||||||
li.innerHTML =
|
li.innerHTML =
|
||||||
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
'<span class="rowNum">' + (idx + 1) + '</span>' +
|
||||||
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
|
||||||
@@ -114,12 +115,16 @@
|
|||||||
escapeHtml(entry.artist || '') +
|
escapeHtml(entry.artist || '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</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">' +
|
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
|
||||||
escapeHtml(aliasLabel) +
|
escapeHtml(aliasLabel) +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
|
||||||
attachDraggable(li, 'music', idx)
|
attachDraggable(li, 'music', idx)
|
||||||
attachInlineEdit(li, idx)
|
attachInlineEdit(li, idx)
|
||||||
|
attachDescBtn(li, idx)
|
||||||
attachAliasBtn(li, idx)
|
attachAliasBtn(li, idx)
|
||||||
ol.appendChild(li)
|
ol.appendChild(li)
|
||||||
})
|
})
|
||||||
@@ -527,6 +532,57 @@
|
|||||||
if (e.target === aliasModal) closeAliasModalSaving()
|
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 () {
|
document.getElementById('image-from-music').addEventListener('click', function () {
|
||||||
if (state.music.length === 0) {
|
if (state.music.length === 0) {
|
||||||
@@ -637,7 +693,7 @@
|
|||||||
var entries = result.body.entries || []
|
var entries = result.body.entries || []
|
||||||
if (target === 'music') {
|
if (target === 'music') {
|
||||||
state.music = entries.map(function (e) {
|
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()
|
renderMusic()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -407,19 +407,24 @@ body.siteBody.centerLayout {
|
|||||||
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
.trackRow {
|
.trackRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px 80px 1fr auto auto;
|
grid-template-columns: 36px 80px 1fr auto auto auto;
|
||||||
gap: 12px; align-items: center;
|
gap: 12px; align-items: center;
|
||||||
padding: 8px 12px; background: var(--bg-card);
|
padding: 8px 12px; background: var(--bg-card);
|
||||||
border: 1px solid var(--border); border-radius: 8px;
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
cursor: grab; user-select: none;
|
cursor: grab; user-select: none;
|
||||||
}
|
}
|
||||||
.aliasBtn {
|
.aliasBtn, .descBtn {
|
||||||
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
background: var(--bg); border: 1px solid var(--border); color: var(--text);
|
||||||
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.aliasBtn:hover { border-color: var(--accent); }
|
.aliasBtn:hover, .descBtn:hover { border-color: var(--accent); }
|
||||||
.aliasBtn.hasAliases { border-color: var(--accent); 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 {
|
.aliasModalHeader {
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ export function normalizePackList(input: unknown): PackList {
|
|||||||
title: sanitizeStr(entry.title),
|
title: sanitizeStr(entry.title),
|
||||||
artist: sanitizeStr(entry.artist),
|
artist: sanitizeStr(entry.artist),
|
||||||
durationSec: sanitizeNumber(entry.durationSec),
|
durationSec: sanitizeNumber(entry.durationSec),
|
||||||
aliases: sanitizeAliases(entry.aliases)
|
aliases: sanitizeAliases(entry.aliases),
|
||||||
|
description: sanitizeStr(entry.description)
|
||||||
}))
|
}))
|
||||||
.filter((entry) => entry.url.length > 0),
|
.filter((entry) => entry.url.length > 0),
|
||||||
images: images
|
images: images
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface MusicListEntry {
|
|||||||
durationSec: number
|
durationSec: number
|
||||||
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||||
aliases: string[]
|
aliases: string[]
|
||||||
|
/** 곡 설명 / 트리비아 메모. 정답 채점이나 데이터팩 생성에는 사용되지 않는다. */
|
||||||
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageListEntry {
|
export interface ImageListEntry {
|
||||||
|
|||||||
@@ -124,6 +124,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- Edit modal (image) -->
|
||||||
<div class="modalOverlay" id="editImageModal" hidden>
|
<div class="modalOverlay" id="editImageModal" hidden>
|
||||||
<div class="modalCard">
|
<div class="modalCard">
|
||||||
|
|||||||
Reference in New Issue
Block a user