Inline edit, URL-driven meta refresh, drag gap animation, copy-from-music

Music list tab:
- Title + artist are now contenteditable in-place. Typing updates state on
  every input event; Enter blurs (no embedded newlines), and on Save the
  current DOM text is re-synced into state.music[i].title/.artist before
  the JSON POST so a quick-save right after typing doesn't drop the last
  keystroke. While focused, the field is highlighted with the accent
  outline and unclamped so long titles wrap. Empty cells show a muted
  placeholder via :empty::before { content: attr(data-placeholder) }.
- Edit modal "save" now POSTs the new URL to /op/list/<pack>/video-meta
  (new endpoint backed by yt-dlp --dump-json --no-playlist) and patches
  state.music[i] with the returned title/channel/durationSec. If yt-dlp
  is unavailable or the lookup fails, the modal asks the user whether to
  apply just the URL change without metadata.
- Drag-and-drop UX: replaced the simple "highlight target" feedback with a
  "gap opens above the target" animation. The hovered row gets a
  .dropAbove class that animates margin-top to 64px via CSS transition,
  visually carving out the slot the dragged item will land in. Insertion
  math is now strictly "drop before the hover target" (with the
  srcIdx<dstIdx → dstIdx-1 adjustment after the splice removal), matching
  what the gap implies. Contenteditable spans no longer hijack drag start
  — on focus the parent <li>.draggable is flipped off so the user can
  freely select text, and back on at blur.

Image list tab:
- New "음악목록에서 가져오기" button. Copies state.music[*].url into
  state.images, which (via the existing thumbUrl() helper) renders each
  song's YouTube thumbnail as the image. Behind a confirm prompt because
  it replaces the entire image list.
- Same drag gap animation (.dropAbove → margin-left 80px) applied to the
  grid cards for consistency.

Server:
- youtube.ts: add fetchVideoMeta(url) using the same ensureYtDlp() path
  (auto-installed binary under %appdata%/.mc_custom). Returns one
  YtPlaylistEntry or null.
- routes/op.ts: POST /op/list/:packName/video-meta. 400 on missing URL,
  503 NO_YTDLP if the auto-install failed, 500 on other yt-dlp errors,
  200 { ok: true, entry } otherwise.

Smoke test (Rick Astley URL) returns
title=Rick Astley - Never Gonna..., channel=Rick Astley, durationSec=213.
This commit is contained in:
2026-05-12 13:38:29 +09:00
parent 7349d4e71e
commit 7316477e23
5 changed files with 232 additions and 20 deletions

View File

@@ -44,6 +44,11 @@
el.textContent = text || '' el.textContent = text || ''
el.classList.toggle('error', !!isError) el.classList.toggle('error', !!isError)
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]
})
}
// ── 렌더 ────────────────────────────────────────── // ── 렌더 ──────────────────────────────────────────
function renderMusic() { function renderMusic() {
@@ -58,11 +63,16 @@
'<span class="rowNum">' + (idx + 1) + '</span>' + '<span class="rowNum">' + (idx + 1) + '</span>' +
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' + '<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
'<div class="rowMeta">' + '<div class="rowMeta">' +
'<div class="rowTitle">' + escapeHtml(entry.title || '(제목 없음)') + '</div>' + '<div class="rowTitle" contenteditable="true" spellcheck="false" data-field="title" data-placeholder="(제목 없음)">' +
'<div class="rowSub">' + escapeHtml(entry.artist || '') + '</div>' + escapeHtml(entry.title || '') +
'</div>' +
'<div class="rowSub" contenteditable="true" spellcheck="false" data-field="artist" data-placeholder="(가수 미상)">' +
escapeHtml(entry.artist || '') +
'</div>' +
'</div>' + '</div>' +
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>' '<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
attachRowEvents(li, 'music', idx) attachRowEvents(li, 'music', idx)
attachInlineEdit(li, idx)
ol.appendChild(li) ol.appendChild(li)
}) })
} }
@@ -83,42 +93,85 @@
}) })
} }
function escapeHtml(s) { // ── 인라인 편집 (제목/가수) ─────────────────────────
return String(s).replace(/[&<>"']/g, function (c) { function attachInlineEdit(li, idx) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c] 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 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) { function attachRowEvents(el, type, idx) {
el.addEventListener('dragstart', function (e) { 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') el.classList.add('dragging')
try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {} try { e.dataTransfer.setData('text/plain', String(idx)) } catch (_) {}
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
}) })
el.addEventListener('dragend', function () { el.addEventListener('dragend', function () {
el.classList.remove('dragging') el.classList.remove('dragging')
var parent = (type === 'music')
? document.getElementById('music-list')
: document.getElementById('image-list')
clearDropTargets(parent)
dragSrc = null dragSrc = null
}) })
el.addEventListener('dragover', function (e) { el.addEventListener('dragover', function (e) {
if (!dragSrc || dragSrc.type !== type) return if (!dragSrc || dragSrc.type !== type) return
e.preventDefault() e.preventDefault()
e.dataTransfer.dropEffect = 'move' 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) { el.addEventListener('drop', function (e) {
e.preventDefault() 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 if (!dragSrc || dragSrc.type !== type) return
var srcIdx = dragSrc.index var srcIdx = dragSrc.index
var dstIdx = idx var dstIdx = idx
if (srcIdx === dstIdx) return if (srcIdx === dstIdx) return
var arr = (type === 'music') ? state.music : state.images var arr = (type === 'music') ? state.music : state.images
var moved = arr.splice(srcIdx, 1)[0] 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() if (type === 'music') renderMusic(); else renderImage()
}) })
el.addEventListener('contextmenu', function (e) { el.addEventListener('contextmenu', function (e) {
@@ -170,6 +223,7 @@
editingIdx = idx editingIdx = idx
if (type === 'music') { if (type === 'music') {
document.getElementById('edit-music-url').value = state.music[idx].url || '' document.getElementById('edit-music-url').value = state.music[idx].url || ''
setStatus('edit-music-status', '')
editMusic.hidden = false editMusic.hidden = false
} else { } else {
var url = state.images[idx].url || '' var url = state.images[idx].url || ''
@@ -191,15 +245,45 @@
}) })
}) })
// 음악 수정 저장: URL 변경 시 yt-dlp 로 제목/가수/시간 자동 갱신.
document.getElementById('edit-music-save').addEventListener('click', function () { document.getElementById('edit-music-save').addEventListener('click', function () {
var url = document.getElementById('edit-music-url').value.trim() var url = document.getElementById('edit-music-url').value.trim()
if (!url) { return } 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 state.music[editingIdx].url = url
closeAllModals() closeAllModals()
renderMusic() 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() { function updateSegButtons() {
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) { document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode) b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode)
@@ -219,6 +303,22 @@
renderImage() 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) ─────────────────────── // ── 액션 (save/clear/fetch) ───────────────────────
var confirmModal = document.getElementById('confirmModal') var confirmModal = document.getElementById('confirmModal')
function ask(title, message, onOk) { function ask(title, message, onOk) {
@@ -254,6 +354,16 @@
function doSave(target) { function doSave(target) {
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim() state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
state.imagePlaylistUrl = document.getElementById('image-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 var statusId = 'status-' + target
setStatus(statusId, '저장 중…') setStatus(statusId, '저장 중…')
fetch('/op/list/' + encodeURIComponent(PACK_KEY), { fetch('/op/list/' + encodeURIComponent(PACK_KEY), {

View File

@@ -412,14 +412,37 @@ body.siteBody.centerLayout {
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;
transition: margin-top 0.18s ease, border-color 0.12s ease;
} }
.trackRow.dragging { opacity: 0.5; } .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; } .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; }
.rowTitle { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .rowTitle {
.rowSub { font-size: 12px; color: var(--text-muted); margin-top: 2px; } 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; } .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); position: relative; aspect-ratio: 1 / 1; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 10px; border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; cursor: grab; user-select: none; overflow: hidden; cursor: grab; user-select: none;
transition: margin-left 0.18s ease, border-color 0.12s ease;
} }
.imageCard.dragging { opacity: 0.5; } .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; } .imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cardNum { .cardNum {
position: absolute; top: 6px; left: 6px; position: absolute; top: 6px; left: 6px;

View File

@@ -13,7 +13,7 @@ import {
savePackList savePackList
} from '../../shared/store' } from '../../shared/store'
import { fetchReleaseVersions } from '../../shared/mojang' import { fetchReleaseVersions } from '../../shared/mojang'
import { fetchPlaylistEntries, YtDlpUnavailableError } from '../youtube' import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube'
import { requireAuth } from '../middleware/auth' import { requireAuth } from '../middleware/auth'
import type { PackDefinition, PackList } from '../../shared/types' 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 로 풀어 목록 후보를 반환. // 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환.
// body: { url: string } // body: { url: string }
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => { opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {

View File

@@ -140,6 +140,54 @@ function downloadToFile(url: string, dest: string, redirects = 0): Promise<void>
}) })
} }
/**
* 단일 영상 URL 의 메타데이터를 가져온다.
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
*/
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
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<string, unknown>
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 을 펼쳐 각 영상의 메타데이터를 가져온다. * 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다.
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON. * `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.

View File

@@ -46,6 +46,7 @@
<div class="listActionsRow"> <div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button> <button type="button" class="primaryButton" data-action="save" data-target="image">목록 저장</button>
<button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button> <button type="button" class="dangerButton" data-action="clear" data-target="image">목록 초기화</button>
<button type="button" class="secondaryButton" id="image-from-music">음악목록에서 가져오기</button>
<span class="statusText" id="status-image"></span> <span class="statusText" id="status-image"></span>
</div> </div>
@@ -92,6 +93,10 @@
<label>유튜브 영상 주소 <label>유튜브 영상 주소
<input type="url" id="edit-music-url" class="textInput" /> <input type="url" id="edit-music-url" class="textInput" />
</label> </label>
<p class="muted" style="margin-top:6px;font-size:12px;">
저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.
</p>
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
</div> </div>
<footer style="display:flex;gap:8px;justify-content:flex-end;"> <footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close>취소</button> <button type="button" class="secondaryButton" data-modal-close>취소</button>