From 7316477e233d72b3665ad92ca32ea7e5809ffacc Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 12 May 2026 13:38:29 +0900 Subject: [PATCH] Inline edit, URL-driven meta refresh, drag gap animation, copy-from-music MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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.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. --- public/listEditor.js | 140 +++++++++++++++++++++++++++++++++++----- public/styles.css | 33 ++++++++-- src/server/routes/op.ts | 26 +++++++- src/server/youtube.ts | 48 ++++++++++++++ views/op/listEditor.ejs | 5 ++ 5 files changed, 232 insertions(+), 20 deletions(-) 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) + '' + '' + '
' + - '
' + escapeHtml(entry.title || '(제목 없음)') + '
' + - '
' + escapeHtml(entry.artist || '') + '
' + + '
' + + escapeHtml(entry.title || '') + + '
' + + '
' + + escapeHtml(entry.artist || '') + + '
' + '
' + '' + 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 로 제목·가수·재생시간을 자동으로 갱신합니다. +

+