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:
2026-05-27 20:44:24 +09:00
parent 1ac13a03ff
commit b4160aefc1
6 changed files with 90 additions and 6 deletions

View File

@@ -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 {

View File

@@ -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 {