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 로 제목·가수·재생시간을 자동으로 갱신합니다. +

+