site: 음악목록 항목별 별칭 편집 기능 추가
- MusicListEntry 에 aliases: string[] 필드 추가, 저장 시 trim·중복 제거. - 목록 행에 "별칭" 버튼 표시(개수 있으면 강조), 클릭 시 모달 오픈. - 모달에서 "별칭 추가" → 입력행 생성, "−" 버튼 → 해당 행 삭제, 좌상단 "← 돌아가기" 또는 오버레이 클릭으로 저장 후 닫기.
This commit is contained in:
@@ -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": "메타데이터 조회 실패",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface MusicListEntry {
|
|||||||
artist: string
|
artist: string
|
||||||
/** 노래 길이 (초). */
|
/** 노래 길이 (초). */
|
||||||
durationSec: number
|
durationSec: number
|
||||||
|
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
|
||||||
|
aliases: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageListEntry {
|
export interface ImageListEntry {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user