diff --git a/locales/server/ko-kr.json b/locales/server/ko-kr.json index fa8c50b..0ad041e 100644 --- a/locales/server/ko-kr.json +++ b/locales/server/ko-kr.json @@ -69,6 +69,14 @@ "titleFallback": "(제목 없음)", "artistFallback": "(가수 미상)", "rowEditTooltip": "더블클릭해서 수정", + "aliasBtn": "별칭", + "aliasBtnWithCount": "별칭 ({{count}})", + "aliasModalTitle": "별칭 - {{title}}", + "aliasBack": "← 돌아가기", + "aliasAdd": "별칭 추가", + "aliasPlaceholder": "별칭 입력", + "aliasRemove": "삭제", + "aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.", "metaLoading": "메타데이터 가져오는 중…", "metaFailedShort": "메타 조회 실패", "metaFailedTitle": "메타데이터 조회 실패", diff --git a/public/listEditor.js b/public/listEditor.js index 1f779ab..44aee82 100644 --- a/public/listEditor.js +++ b/public/listEditor.js @@ -99,6 +99,10 @@ li.dataset.index = String(idx) // 기본 상태에서는 contenteditable 을 켜지 않는다. 더블클릭 시에만 편집 모드 ON. // 이렇게 해야 어디를 눌러도 드래그가 시작될 수 있다. + var aliasCount = Array.isArray(entry.aliases) ? entry.aliases.length : 0 + var aliasLabel = aliasCount > 0 + ? tt('aliasBtnWithCount', { count: aliasCount }) + : tt('aliasBtn') li.innerHTML = '' + (idx + 1) + '' + '' + @@ -110,9 +114,13 @@ escapeHtml(entry.artist || '') + '' + '' + + '' + '' + fmtTime(entry.durationSec) + '' attachDraggable(li, 'music', idx) attachInlineEdit(li, idx) + attachAliasBtn(li, idx) ol.appendChild(li) }) } @@ -402,6 +410,110 @@ 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 () { if (state.music.length === 0) { diff --git a/public/styles.css b/public/styles.css index 50d8470..48ff982 100644 --- a/public/styles.css +++ b/public/styles.css @@ -407,12 +407,42 @@ 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; + grid-template-columns: 36px 80px 1fr 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 { + 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; } .rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; } .rowMeta { min-width: 0; } diff --git a/src/shared/store.ts b/src/shared/store.ts index 97f2956..c8e5f60 100644 --- a/src/shared/store.ts +++ b/src/shared/store.ts @@ -229,6 +229,21 @@ function sanitizeNumber(value: unknown): number { return Math.floor(n) } +/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */ +function sanitizeAliases(value: unknown): string[] { + if (!Array.isArray(value)) return [] + const out: string[] = [] + const seen = new Set() + 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 { const fallback = defaultPackList() if (!input || typeof input !== 'object') return fallback @@ -244,7 +259,8 @@ export function normalizePackList(input: unknown): PackList { url: sanitizeStr(entry.url), title: sanitizeStr(entry.title), artist: sanitizeStr(entry.artist), - durationSec: sanitizeNumber(entry.durationSec) + durationSec: sanitizeNumber(entry.durationSec), + aliases: sanitizeAliases(entry.aliases) })) .filter((entry) => entry.url.length > 0), images: images diff --git a/src/shared/types.ts b/src/shared/types.ts index e0b0eb6..1f707b3 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -47,6 +47,8 @@ export interface MusicListEntry { artist: string /** 노래 길이 (초). */ durationSec: number + /** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */ + aliases: string[] } export interface ImageListEntry { diff --git a/views/op/listEditor.ejs b/views/op/listEditor.ejs index 299b5d7..e62acae 100644 --- a/views/op/listEditor.ejs +++ b/views/op/listEditor.ejs @@ -106,6 +106,24 @@ + + +