From b4160aefc15edb26e10d6477703447fde02e512a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 27 May 2026 20:44:24 +0900 Subject: [PATCH] list-editor: add per-track description button + modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- locales/server/ko-kr.json | 5 ++++ public/listEditor.js | 58 ++++++++++++++++++++++++++++++++++++++- public/styles.css | 13 ++++++--- src/shared/store.ts | 3 +- src/shared/types.ts | 2 ++ views/op/listEditor.ejs | 15 ++++++++++ 6 files changed, 90 insertions(+), 6 deletions(-) diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index f3ee3b8..4c0ad09 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -78,6 +78,11 @@ "aliasPlaceholder": "별칭 입력", "aliasRemove": "삭제", "aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.", + "descBtn": "설명", + "descModalTitle": "설명 - {{title}}", + "descBack": "← 돌아가기", + "descPlaceholder": "이 곡에 대한 설명을 입력하세요", + "descHint": "곡 소개·트리비아 등 자유 메모. 정답 채점이나 데이터팩에는 사용되지 않습니다.", "metaLoading": "메타데이터 가져오는 중…", "metaFailedShort": "메타 조회 실패", "metaFailedTitle": "메타데이터 조회 실패", diff --git a/public/listEditor.js b/public/listEditor.js index 09fe40a..6b7ea60 100644 --- a/public/listEditor.js +++ b/public/listEditor.js @@ -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 = '' + (idx + 1) + '' + '' + @@ -114,12 +115,16 @@ escapeHtml(entry.artist || '') + '' + '' + + '' + '' + '' + fmtTime(entry.durationSec) + '' 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 { diff --git a/public/styles.css b/public/styles.css index 48ff982..352068c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -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 { diff --git a/src/shared/store.ts b/src/shared/store.ts index 39516b6..afeb5f2 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index d8e62c3..854de10 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -57,6 +57,8 @@ export interface MusicListEntry { durationSec: number /** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */ aliases: string[] + /** 곡 설명 / 트리비아 메모. 정답 채점이나 데이터팩 생성에는 사용되지 않는다. */ + description: string } export interface ImageListEntry { diff --git a/views/op/listEditor.ejs b/views/op/listEditor.ejs index e62acae..f7f2d94 100644 --- a/views/op/listEditor.ejs +++ b/views/op/listEditor.ejs @@ -124,6 +124,21 @@ + + +