site: 음악목록 항목별 별칭 편집 기능 추가

- MusicListEntry 에 aliases: string[] 필드 추가, 저장 시 trim·중복 제거.
- 목록 행에 "별칭" 버튼 표시(개수 있으면 강조), 클릭 시 모달 오픈.
- 모달에서 "별칭 추가" → 입력행 생성, "−" 버튼 → 해당 행 삭제,
  좌상단 "← 돌아가기" 또는 오버레이 클릭으로 저장 후 닫기.
This commit is contained in:
2026-05-13 15:57:35 +09:00
parent f9cf373550
commit 2344c4b8d2
6 changed files with 188 additions and 2 deletions

View File

@@ -69,6 +69,14 @@
"titleFallback": "(제목 없음)", "titleFallback": "(제목 없음)",
"artistFallback": "(가수 미상)", "artistFallback": "(가수 미상)",
"rowEditTooltip": "더블클릭해서 수정", "rowEditTooltip": "더블클릭해서 수정",
"aliasBtn": "별칭",
"aliasBtnWithCount": "별칭 ({{count}})",
"aliasModalTitle": "별칭 - {{title}}",
"aliasBack": "← 돌아가기",
"aliasAdd": "별칭 추가",
"aliasPlaceholder": "별칭 입력",
"aliasRemove": "삭제",
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
"metaLoading": "메타데이터 가져오는 중…", "metaLoading": "메타데이터 가져오는 중…",
"metaFailedShort": "메타 조회 실패", "metaFailedShort": "메타 조회 실패",
"metaFailedTitle": "메타데이터 조회 실패", "metaFailedTitle": "메타데이터 조회 실패",

View File

@@ -99,6 +99,10 @@
li.dataset.index = String(idx) li.dataset.index = String(idx)
// 기본 상태에서는 contenteditable 을 켜지 않는다. 더블클릭 시에만 편집 모드 ON. // 기본 상태에서는 contenteditable 을 켜지 않는다. 더블클릭 시에만 편집 모드 ON.
// 이렇게 해야 어디를 눌러도 드래그가 시작될 수 있다. // 이렇게 해야 어디를 눌러도 드래그가 시작될 수 있다.
var aliasCount = Array.isArray(entry.aliases) ? entry.aliases.length : 0
var aliasLabel = aliasCount > 0
? tt('aliasBtnWithCount', { count: aliasCount })
: tt('aliasBtn')
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"/>' +
@@ -110,9 +114,13 @@
escapeHtml(entry.artist || '') + escapeHtml(entry.artist || '') +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
escapeHtml(aliasLabel) +
'</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)
attachAliasBtn(li, idx)
ol.appendChild(li) ol.appendChild(li)
}) })
} }
@@ -402,6 +410,110 @@
renderImage() renderImage()
}) })
// ── 별칭 모달 ─────────────────────────────────────
// 음악 행의 "별칭" 버튼을 누르면 열린다. 헤더의 "← 돌아가기" 버튼 (또는 닫기 동작)이
// 호출되면 현재 인풋박스들에 입력된 값을 정규화해 state.music[idx].aliases 에 저장.
var aliasModal = document.getElementById('aliasModal')
var aliasRowsHost = document.getElementById('alias-rows')
var aliasModalTitleEl = document.getElementById('alias-modal-title')
var aliasBackBtn = document.getElementById('alias-back')
var aliasAddBtn = document.getElementById('alias-add')
var aliasEditingIdx = -1
function attachAliasBtn(li, idx) {
var btn = li.querySelector('[data-alias-open]')
if (!btn) return
// 버튼에서 시작하는 mousedown 은 행 드래그로 전파되지 않도록 차단.
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
btn.addEventListener('click', function (e) {
e.stopPropagation()
openAliasModal(idx)
})
}
function openAliasModal(idx) {
if (!state.music[idx]) return
aliasEditingIdx = idx
var entry = state.music[idx]
aliasModalTitleEl.textContent = tt('aliasModalTitle', { title: entry.title || tt('titleFallback') })
aliasRowsHost.innerHTML = ''
var existing = Array.isArray(entry.aliases) ? entry.aliases : []
if (existing.length === 0) {
// 빈 상태에서도 입력 시작을 쉽게 하려고 첫 줄 하나는 미리 만들어 둔다.
appendAliasRow('')
} else {
existing.forEach(function (a) { appendAliasRow(a) })
}
aliasModal.hidden = false
}
function appendAliasRow(value) {
var row = document.createElement('div')
row.className = 'aliasRow'
var input = document.createElement('input')
input.type = 'text'
input.className = 'textInput aliasInput'
input.placeholder = tt('aliasPlaceholder')
input.value = value || ''
var removeBtn = document.createElement('button')
removeBtn.type = 'button'
removeBtn.className = 'aliasRowRemove'
removeBtn.title = tt('aliasRemove')
removeBtn.textContent = ''
removeBtn.addEventListener('click', function () { row.remove() })
row.appendChild(input)
row.appendChild(removeBtn)
aliasRowsHost.appendChild(row)
return input
}
function readAliasInputs() {
var seen = Object.create(null)
var out = []
var inputs = aliasRowsHost.querySelectorAll('.aliasInput')
for (var i = 0; i < inputs.length; i++) {
var v = (inputs[i].value || '').trim()
if (!v) continue
if (seen[v]) continue
seen[v] = true
out.push(v)
}
return out
}
function closeAliasModalSaving() {
if (aliasEditingIdx < 0 || !state.music[aliasEditingIdx]) {
aliasModal.hidden = true
aliasEditingIdx = -1
return
}
var nextAliases = readAliasInputs()
var prev = state.music[aliasEditingIdx].aliases || []
var changed = prev.length !== nextAliases.length
if (!changed) {
for (var i = 0; i < prev.length; i++) {
if (prev[i] !== nextAliases[i]) { changed = true; break }
}
}
if (changed) {
state.music[aliasEditingIdx].aliases = nextAliases
markDirty()
renderMusic()
}
aliasModal.hidden = true
aliasEditingIdx = -1
}
aliasAddBtn.addEventListener('click', function () {
var input = appendAliasRow('')
input.focus()
})
aliasBackBtn.addEventListener('click', closeAliasModalSaving)
// 모달 바깥 클릭으로 닫혀도 입력값은 보존(저장)되도록 처리.
aliasModal.addEventListener('click', function (e) {
if (e.target === aliasModal) closeAliasModalSaving()
})
// ── 사진목록: 음악목록 그대로 복사 ───────────────── // ── 사진목록: 음악목록 그대로 복사 ─────────────────
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) {

View File

@@ -407,12 +407,42 @@ 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; grid-template-columns: 36px 80px 1fr 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 {
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); }
/* 별칭 모달 */
.aliasModalHeader {
display: grid !important;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
}
.aliasModalHeader h3 { text-align: center; }
.aliasModalHeader .ghostLink {
background: transparent; border: none; color: var(--accent); cursor: pointer;
font-size: 13px; padding: 4px 8px;
}
.aliasModalHeader .ghostLink:hover { text-decoration: underline; }
.aliasRowList { display: flex; flex-direction: column; gap: 8px; }
.aliasRow { display: flex; gap: 8px; align-items: center; }
.aliasRow .aliasInput { flex: 1; }
.aliasRowRemove {
background: var(--bg-card); border: 1px solid var(--border); color: var(--danger);
width: 32px; height: 32px; border-radius: 6px; cursor: pointer;
font-size: 16px; line-height: 1; flex-shrink: 0;
}
.aliasRowRemove:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; } .rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; } .rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
.rowMeta { min-width: 0; } .rowMeta { min-width: 0; }

View File

@@ -229,6 +229,21 @@ function sanitizeNumber(value: unknown): number {
return Math.floor(n) return Math.floor(n)
} }
/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */
function sanitizeAliases(value: unknown): string[] {
if (!Array.isArray(value)) return []
const out: string[] = []
const seen = new Set<string>()
for (const item of value) {
const s = sanitizeStr(item)
if (!s) continue
if (seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
}
export function normalizePackList(input: unknown): PackList { export function normalizePackList(input: unknown): PackList {
const fallback = defaultPackList() const fallback = defaultPackList()
if (!input || typeof input !== 'object') return fallback if (!input || typeof input !== 'object') return fallback
@@ -244,7 +259,8 @@ export function normalizePackList(input: unknown): PackList {
url: sanitizeStr(entry.url), url: sanitizeStr(entry.url),
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)
})) }))
.filter((entry) => entry.url.length > 0), .filter((entry) => entry.url.length > 0),
images: images images: images

View File

@@ -47,6 +47,8 @@ export interface MusicListEntry {
artist: string artist: string
/** 노래 길이 (초). */ /** 노래 길이 (초). */
durationSec: number durationSec: number
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
aliases: string[]
} }
export interface ImageListEntry { export interface ImageListEntry {

View File

@@ -106,6 +106,24 @@
</div> </div>
</div> </div>
<!-- Alias modal (music) -->
<div class="modalOverlay" id="aliasModal" hidden>
<div class="modalCard">
<header class="aliasModalHeader">
<button type="button" class="ghostLink" id="alias-back"><%= t('listEditor.aliasBack') %></button>
<h3 id="alias-modal-title"></h3>
<span></span>
</header>
<div class="modalBody">
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.aliasHint') %></p>
<div id="alias-rows" class="aliasRowList"></div>
<div>
<button type="button" class="secondaryButton" id="alias-add"><%= t('listEditor.aliasAdd') %></button>
</div>
</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">