diff --git a/public/listEditor.js b/public/listEditor.js
index 619e61c..25868db 100644
--- a/public/listEditor.js
+++ b/public/listEditor.js
@@ -44,6 +44,11 @@
el.textContent = text || ''
el.classList.toggle('error', !!isError)
}
+ function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, function (c) {
+ return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]
+ })
+ }
// ── 렌더 ──────────────────────────────────────────
function renderMusic() {
@@ -58,11 +63,16 @@
'' + (idx + 1) + '' +
'
' +
'
' +
'' + fmtTime(entry.durationSec) + ''
attachRowEvents(li, 'music', idx)
+ attachInlineEdit(li, idx)
ol.appendChild(li)
})
}
@@ -83,42 +93,85 @@
})
}
- function escapeHtml(s) {
- return String(s).replace(/[&<>"']/g, function (c) {
- return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c]
+ // ── 인라인 편집 (제목/가수) ─────────────────────────
+ function attachInlineEdit(li, idx) {
+ li.querySelectorAll('[contenteditable="true"]').forEach(function (el) {
+ // contenteditable 요소 위에서 드래그가 시작되면 텍스트 선택이 의도였을 가능성이 크다.
+ // 편집 중에는 부모 li 가 드래그되지 않도록 한다.
+ el.addEventListener('focus', function () { li.draggable = false })
+ el.addEventListener('blur', function () { li.draggable = true })
+ el.addEventListener('input', function () {
+ var field = el.getAttribute('data-field')
+ var value = (el.textContent || '').replace(/\r?\n/g, ' ').trim()
+ if (field === 'title') state.music[idx].title = value
+ else if (field === 'artist') state.music[idx].artist = value
+ })
+ el.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') { e.preventDefault(); el.blur() }
+ })
})
}
// ── 행/카드 이벤트 (드래그·우클릭) ─────────────────
var dragSrc = null
+ var lastDropTarget = null
+
+ function clearDropTargets(container) {
+ container.querySelectorAll('.dropAbove').forEach(function (el) {
+ el.classList.remove('dropAbove')
+ })
+ lastDropTarget = null
+ }
+
function attachRowEvents(el, type, idx) {
el.addEventListener('dragstart', function (e) {
- dragSrc = { type: type, index: idx }
+ // contenteditable 안에서 시작된 드래그(텍스트 선택)는 무시.
+ if (!el.draggable) { e.preventDefault(); return }
+ dragSrc = { type: type, index: idx, el: el }
el.classList.add('dragging')
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
e.dataTransfer.effectAllowed = 'move'
})
el.addEventListener('dragend', function () {
el.classList.remove('dragging')
+ var parent = (type === 'music')
+ ? document.getElementById('music-list')
+ : document.getElementById('image-list')
+ clearDropTargets(parent)
dragSrc = null
})
el.addEventListener('dragover', function (e) {
if (!dragSrc || dragSrc.type !== type) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
- el.classList.add('dragOver')
+ if (lastDropTarget !== el) {
+ if (lastDropTarget) lastDropTarget.classList.remove('dropAbove')
+ // 자기 자신 위에서는 표식 없음(이동 효과 없음).
+ if (el !== dragSrc.el) el.classList.add('dropAbove')
+ lastDropTarget = el
+ }
+ })
+ el.addEventListener('dragleave', function (e) {
+ // 자식으로 이동한 경우는 leave 가 아님 — pointer 가 진짜로 벗어났을 때만 해제.
+ if (e.relatedTarget && el.contains(e.relatedTarget)) return
+ el.classList.remove('dropAbove')
+ if (lastDropTarget === el) lastDropTarget = null
})
- el.addEventListener('dragleave', function () { el.classList.remove('dragOver') })
el.addEventListener('drop', function (e) {
e.preventDefault()
- el.classList.remove('dragOver')
+ var parent = (type === 'music')
+ ? document.getElementById('music-list')
+ : document.getElementById('image-list')
+ clearDropTargets(parent)
if (!dragSrc || dragSrc.type !== type) return
var srcIdx = dragSrc.index
var dstIdx = idx
if (srcIdx === dstIdx) return
var arr = (type === 'music') ? state.music : state.images
var moved = arr.splice(srcIdx, 1)[0]
- arr.splice(dstIdx, 0, moved)
+ // "dropAbove" 의미를 그대로 → 항상 target 행의 자리에 끼워 넣는다.
+ var insertAt = (srcIdx < dstIdx) ? dstIdx - 1 : dstIdx
+ arr.splice(insertAt, 0, moved)
if (type === 'music') renderMusic(); else renderImage()
})
el.addEventListener('contextmenu', function (e) {
@@ -170,6 +223,7 @@
editingIdx = idx
if (type === 'music') {
document.getElementById('edit-music-url').value = state.music[idx].url || ''
+ setStatus('edit-music-status', '')
editMusic.hidden = false
} else {
var url = state.images[idx].url || ''
@@ -191,15 +245,45 @@
})
})
+ // 음악 수정 저장: URL 변경 시 yt-dlp 로 제목/가수/시간 자동 갱신.
document.getElementById('edit-music-save').addEventListener('click', function () {
var url = document.getElementById('edit-music-url').value.trim()
- if (!url) { return }
- state.music[editingIdx].url = url
- closeAllModals()
- renderMusic()
+ if (!url) return
+ var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
+ if (url === prev.url) { closeAllModals(); return }
+ setStatus('edit-music-status', '메타데이터 가져오는 중…')
+ fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ url: url })
+ }).then(function (r) {
+ return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
+ }).then(function (result) {
+ if (!result.ok || !result.body || !result.body.ok) {
+ // yt-dlp 가 없거나 메타 실패면, 사용자가 명시적으로 적용할지 결정.
+ var msg = (result.body && result.body.message) ? result.body.message : '메타 조회 실패'
+ ask('메타데이터 조회 실패', msg + '\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?', function () {
+ state.music[editingIdx].url = url
+ closeAllModals()
+ renderMusic()
+ })
+ setStatus('edit-music-status', msg, true)
+ return
+ }
+ var meta = result.body.entry
+ state.music[editingIdx] = {
+ url: meta.url || url,
+ title: meta.title || prev.title || '',
+ artist: meta.channel || prev.artist || '',
+ durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
+ }
+ closeAllModals()
+ renderMusic()
+ }).catch(function (err) {
+ setStatus('edit-music-status', '실패: ' + err.message, true)
+ })
})
- // 이미지 수정 팝업의 토글 (유튜브 주소 / 이미지 주소)
function updateSegButtons() {
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode)
@@ -219,6 +303,22 @@
renderImage()
})
+ // ── 사진목록: 음악목록 그대로 복사 (각 곡의 유튜브 썸네일이 자동으로 사용됨) ──
+ document.getElementById('image-from-music').addEventListener('click', function () {
+ if (state.music.length === 0) {
+ setStatus('status-image', '음악목록이 비어 있어 가져올 수 없습니다.', true)
+ return
+ }
+ ask('사진목록 가져오기',
+ '저장된 음악목록의 영상 ' + state.music.length + '개를 그대로 사진목록으로 가져옵니다.\n'
+ + '현재 사진목록은 모두 사라집니다. 진행할까요?',
+ function () {
+ state.images = state.music.map(function (m) { return { url: m.url } })
+ renderImage()
+ setStatus('status-image', state.images.length + '개 항목을 불러왔습니다.')
+ })
+ })
+
// ── 액션 (save/clear/fetch) ───────────────────────
var confirmModal = document.getElementById('confirmModal')
function ask(title, message, onOk) {
@@ -254,6 +354,16 @@
function doSave(target) {
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
state.imagePlaylistUrl = document.getElementById('image-playlist-url').value.trim()
+ // 인라인 contenteditable 의 최신 값을 한 번 더 동기화 (focus 가 input 이벤트 없이 끝나는 케이스 보호).
+ document.querySelectorAll('#music-list .trackRow').forEach(function (li) {
+ var idx = Number(li.dataset.index)
+ var t = li.querySelector('[data-field="title"]')
+ var a = li.querySelector('[data-field="artist"]')
+ if (state.music[idx]) {
+ if (t) state.music[idx].title = (t.textContent || '').replace(/\r?\n/g, ' ').trim()
+ if (a) state.music[idx].artist = (a.textContent || '').replace(/\r?\n/g, ' ').trim()
+ }
+ })
var statusId = 'status-' + target
setStatus(statusId, '저장 중…')
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
diff --git a/public/styles.css b/public/styles.css
index 9760c2b..273dc8b 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -412,14 +412,37 @@ body.siteBody.centerLayout {
padding: 8px 12px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px;
cursor: grab; user-select: none;
+ transition: margin-top 0.18s ease, border-color 0.12s ease;
}
.trackRow.dragging { opacity: 0.5; }
-.trackRow.dragOver { border-color: var(--accent); }
+/* 드래그 중 hover 표시: 위쪽으로 64px 공간이 벌어지며 "여기 끼울 수 있다" 를 표현 */
+.trackRow.dropAbove { margin-top: 64px; border-color: var(--accent); }
.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; }
-.rowTitle { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.rowSub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
+.rowTitle {
+ font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ outline: none; border-radius: 4px; padding: 2px 4px; margin: -2px -4px;
+}
+.rowSub {
+ font-size: 12px; color: var(--text-muted); margin-top: 2px;
+ outline: none; border-radius: 4px; padding: 2px 4px;
+}
+.rowTitle[contenteditable="true"]:hover,
+.rowSub[contenteditable="true"]:hover { background: rgba(255,255,255,0.04); }
+.rowTitle[contenteditable="true"]:focus,
+.rowSub[contenteditable="true"]:focus {
+ background: var(--bg);
+ box-shadow: 0 0 0 1px var(--accent);
+ white-space: normal; cursor: text;
+}
+/* placeholder 효과: 비어 있을 때 회색 안내 텍스트 */
+.rowTitle[contenteditable="true"]:empty::before,
+.rowSub[contenteditable="true"]:empty::before {
+ content: attr(data-placeholder);
+ color: var(--text-muted);
+ opacity: 0.6;
+}
.rowDur { color: var(--text-muted); font-size: 13px; }
/* 사진 그리드 */
@@ -432,9 +455,11 @@ body.siteBody.centerLayout {
position: relative; aspect-ratio: 1 / 1; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; cursor: grab; user-select: none;
+ transition: margin-left 0.18s ease, border-color 0.12s ease;
}
.imageCard.dragging { opacity: 0.5; }
-.imageCard.dragOver { border-color: var(--accent); }
+/* 사진 그리드도 동일하게 "옆이 벌어진다" 표현 */
+.imageCard.dropAbove { margin-left: 80px; border-color: var(--accent); }
.imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cardNum {
position: absolute; top: 6px; left: 6px;
diff --git a/src/server/routes/op.ts b/src/server/routes/op.ts
index 52adcd1..0ae76a4 100644
--- a/src/server/routes/op.ts
+++ b/src/server/routes/op.ts
@@ -13,7 +13,7 @@ import {
savePackList
} from '../../shared/store'
import { fetchReleaseVersions } from '../../shared/mojang'
-import { fetchPlaylistEntries, YtDlpUnavailableError } from '../youtube'
+import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube'
import { requireAuth } from '../middleware/auth'
import type { PackDefinition, PackList } from '../../shared/types'
@@ -174,6 +174,30 @@ opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
}
})
+// 단일 영상 메타데이터 조회 (음악 항목 수정에서 URL 변경 시 자동 갱신용).
+// body: { url: string }
+opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
+ const url = pickFirstValue(req.body?.url).trim()
+ if (!url) {
+ res.status(400).json({ ok: false, message: '영상 주소를 입력해 주세요.' })
+ return
+ }
+ try {
+ const entry = await fetchVideoMeta(url)
+ if (!entry) {
+ res.status(404).json({ ok: false, message: '메타데이터를 찾을 수 없습니다.' })
+ return
+ }
+ res.json({ ok: true, entry })
+ } catch (error) {
+ if (error instanceof YtDlpUnavailableError) {
+ res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' })
+ return
+ }
+ res.status(500).json({ ok: false, message: (error as Error).message })
+ }
+})
+
// 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환.
// body: { url: string }
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
diff --git a/src/server/youtube.ts b/src/server/youtube.ts
index f39803b..5fb70cc 100644
--- a/src/server/youtube.ts
+++ b/src/server/youtube.ts
@@ -140,6 +140,54 @@ function downloadToFile(url: string, dest: string, redirects = 0): Promise
})
}
+/**
+ * 단일 영상 URL 의 메타데이터를 가져온다.
+ * `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
+ */
+export async function fetchVideoMeta(url: string): Promise {
+ const bin = await ensureYtDlp()
+ return new Promise((resolve, reject) => {
+ const child = spawn(bin, [
+ '--dump-json',
+ '--no-warnings',
+ '--no-playlist',
+ '--skip-download',
+ url
+ ], { stdio: ['ignore', 'pipe', 'pipe'] })
+ let stdout = ''
+ let stderr = ''
+ child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
+ child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
+ child.on('error', (err) => reject(err))
+ child.on('close', (code) => {
+ if (code !== 0) {
+ reject(new Error(`yt-dlp 영상 조회 실패 (code=${code}): ${stderr.trim() || stdout.trim()}`))
+ return
+ }
+ const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
+ if (!line) { resolve(null); return }
+ try {
+ const obj = JSON.parse(line) as Record
+ const id = typeof obj.id === 'string' ? obj.id : ''
+ if (!id) { resolve(null); return }
+ resolve({
+ id,
+ title: typeof obj.title === 'string' ? obj.title : '',
+ channel: typeof obj.channel === 'string'
+ ? obj.channel
+ : (typeof obj.uploader === 'string' ? obj.uploader : ''),
+ durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
+ url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
+ ? obj.webpage_url
+ : `https://www.youtube.com/watch?v=${id}`
+ })
+ } catch (err) {
+ reject(err)
+ }
+ })
+ })
+}
+
/**
* 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다.
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
diff --git a/views/op/listEditor.ejs b/views/op/listEditor.ejs
index 1e23dff..dae8e36 100644
--- a/views/op/listEditor.ejs
+++ b/views/op/listEditor.ejs
@@ -46,6 +46,7 @@
+
@@ -92,6 +93,10 @@
+
+ 저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.
+
+