feat(playlist): import UI with drag reorder + random/sequential ID toggle (STEP 5)
Adds /op/folder/:top/:sub/playlist/new page that lets the operator paste a YouTube playlist URL, probe entries via yt-dlp, reorder them by drag, edit title/ID per row, and choose between random IDs or zero-padded sequential IDs before registering all jobs in one batch.
This commit is contained in:
431
public/playlist.js
Normal file
431
public/playlist.js
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
/* 플레이리스트 import UI.
|
||||||
|
*
|
||||||
|
* 흐름:
|
||||||
|
* 1) URL 입력 → 목록 불러오기 (POST /playlist/probe)
|
||||||
|
* 2) 항목 목록 렌더 + 인라인 편집 (제목, ID) + 드래그 재정렬 + 항목 삭제
|
||||||
|
* 3) ID 모드 토글 (랜덤 / 시퀀셜) — 시퀀셜이면 zero-pad 토글 노출
|
||||||
|
* 4) 등록 → POST /playlist/start → 폴더 페이지로 돌아가서 다운로드 폴링
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
var ctx = window.__PLAYLIST__ || {}
|
||||||
|
var folderPath = ctx.folderPath || []
|
||||||
|
var folderPathEnc = folderPath
|
||||||
|
.map(function (s) { return encodeURIComponent(s) })
|
||||||
|
.join('/')
|
||||||
|
var folderBaseUrl = '/op/folder/' + folderPathEnc
|
||||||
|
var probeUrl = folderBaseUrl + '/playlist/probe'
|
||||||
|
var startUrl = folderBaseUrl + '/playlist/start'
|
||||||
|
|
||||||
|
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
||||||
|
|
||||||
|
// ─── state ────────────────────────────────────────────────────────────
|
||||||
|
// entries: [{ url, title, videoId, durationSec, idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }]
|
||||||
|
var state = {
|
||||||
|
entries: [],
|
||||||
|
idMode: 'random', // 'random' | 'sequential'
|
||||||
|
zeroPad: false,
|
||||||
|
busy: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DOM refs ─────────────────────────────────────────────────────────
|
||||||
|
var urlInput = document.getElementById('playlistUrl')
|
||||||
|
var probeBtn = document.getElementById('probeBtn')
|
||||||
|
var probeStatus = document.getElementById('probeStatus')
|
||||||
|
var entriesSection = document.getElementById('entriesSection')
|
||||||
|
var entryList = document.getElementById('entryList')
|
||||||
|
var entriesCount = document.getElementById('entriesCount')
|
||||||
|
var idModeRadios = document.getElementsByName('idMode')
|
||||||
|
var zeroPadWrap = document.getElementById('zeroPadWrap')
|
||||||
|
var zeroPadToggle = document.getElementById('zeroPadToggle')
|
||||||
|
var registerBtn = document.getElementById('registerBtn')
|
||||||
|
var registerStatus = document.getElementById('registerStatus')
|
||||||
|
|
||||||
|
// ─── 유틸 ─────────────────────────────────────────────────────────────
|
||||||
|
function randomId() {
|
||||||
|
// 24자 hex (UUID 압축). crypto.randomUUID 가 없는 옛 브라우저 폴백.
|
||||||
|
if (window.crypto && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID().replace(/-/g, '').slice(0, 24)
|
||||||
|
}
|
||||||
|
var s = ''
|
||||||
|
for (var i = 0; i < 24; i++) {
|
||||||
|
s += Math.floor(Math.random() * 16).toString(16)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(sec) {
|
||||||
|
if (sec == null || !isFinite(sec)) return ''
|
||||||
|
sec = Math.round(sec)
|
||||||
|
var h = Math.floor(sec / 3600)
|
||||||
|
var m = Math.floor((sec % 3600) / 60)
|
||||||
|
var s = sec % 60
|
||||||
|
if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
|
||||||
|
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProbeStatus(text, cls) {
|
||||||
|
probeStatus.textContent = text || ''
|
||||||
|
probeStatus.className = cls || 'muted'
|
||||||
|
}
|
||||||
|
function setRegisterStatus(text, cls) {
|
||||||
|
registerStatus.textContent = text || ''
|
||||||
|
registerStatus.className = cls || 'muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ID 생성 / 재계산 ─────────────────────────────────────────────────
|
||||||
|
function regenerateIds() {
|
||||||
|
var n = state.entries.length
|
||||||
|
if (state.idMode === 'random') {
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
state.entries[i].videoId = randomId()
|
||||||
|
state.entries[i].idStatus = 'ok'
|
||||||
|
state.entries[i].idReason = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// sequential: 1..n, optional zero-pad based on n
|
||||||
|
var width = state.zeroPad ? String(n).length : 0
|
||||||
|
for (var j = 0; j < n; j++) {
|
||||||
|
var num = String(j + 1)
|
||||||
|
if (width > 0) while (num.length < width) num = '0' + num
|
||||||
|
state.entries[j].videoId = num
|
||||||
|
state.entries[j].idStatus = 'pending'
|
||||||
|
state.entries[j].idReason = ''
|
||||||
|
}
|
||||||
|
// 시퀀셜은 짧은 숫자 ID 라 충돌 가능성이 상대적으로 크므로 일괄 가용성 확인.
|
||||||
|
for (var k = 0; k < n; k++) {
|
||||||
|
scheduleIdCheck(k, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ID 가용성 확인 (단건) ────────────────────────────────────────────
|
||||||
|
function scheduleIdCheck(idx, delay) {
|
||||||
|
var entry = state.entries[idx]
|
||||||
|
if (!entry) return
|
||||||
|
if (entry.idDebounce) { clearTimeout(entry.idDebounce); entry.idDebounce = 0 }
|
||||||
|
var v = entry.videoId
|
||||||
|
if (!v) {
|
||||||
|
entry.idStatus = 'bad'
|
||||||
|
entry.idReason = '비어 있음'
|
||||||
|
paintEntryStatus(idx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!ID_RE.test(v)) {
|
||||||
|
entry.idStatus = 'bad'
|
||||||
|
entry.idReason = '형식: 영문/숫자/_/- (1~64자)'
|
||||||
|
paintEntryStatus(idx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 요청 내 중복 검사
|
||||||
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
|
if (i !== idx && state.entries[i].videoId === v) {
|
||||||
|
entry.idStatus = 'bad'
|
||||||
|
entry.idReason = '같은 ID 가 다른 항목에 있음'
|
||||||
|
paintEntryStatus(idx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.idStatus = 'pending'
|
||||||
|
entry.idReason = '확인 중…'
|
||||||
|
paintEntryStatus(idx)
|
||||||
|
entry.idDebounce = setTimeout(function () {
|
||||||
|
var current = state.entries[idx]
|
||||||
|
if (!current || current.videoId !== v) return
|
||||||
|
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json() })
|
||||||
|
.then(function (j) {
|
||||||
|
var c = state.entries[idx]
|
||||||
|
if (!c || c.videoId !== v) return
|
||||||
|
if (j && j.ok && j.available) {
|
||||||
|
c.idStatus = 'ok'
|
||||||
|
c.idReason = ''
|
||||||
|
} else {
|
||||||
|
c.idStatus = 'bad'
|
||||||
|
c.idReason = (j && j.reason) || '이미 사용 중'
|
||||||
|
}
|
||||||
|
paintEntryStatus(idx)
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
var c = state.entries[idx]
|
||||||
|
if (!c || c.videoId !== v) return
|
||||||
|
c.idStatus = 'pending'
|
||||||
|
c.idReason = '확인 실패 (재시도 자동 대기)'
|
||||||
|
paintEntryStatus(idx)
|
||||||
|
})
|
||||||
|
}, delay != null ? delay : 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 렌더 ─────────────────────────────────────────────────────────────
|
||||||
|
function render() {
|
||||||
|
entriesCount.textContent = state.entries.length + ' 항목'
|
||||||
|
entryList.innerHTML = ''
|
||||||
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
|
entryList.appendChild(buildRow(i))
|
||||||
|
}
|
||||||
|
// sequential 모드에서 아무 항목이라도 충돌 있으면 등록 버튼 비활성.
|
||||||
|
updateRegisterEnable()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(idx) {
|
||||||
|
var entry = state.entries[idx]
|
||||||
|
var li = document.createElement('li')
|
||||||
|
li.className = 'playlistEntryRow'
|
||||||
|
li.draggable = true
|
||||||
|
li.dataset.idx = String(idx)
|
||||||
|
li.innerHTML = ''
|
||||||
|
+ '<span class="dragHandle" title="드래그로 순서 변경">≡</span>'
|
||||||
|
+ '<span class="entryNum">' + (idx + 1) + '</span>'
|
||||||
|
+ '<div class="entryFields">'
|
||||||
|
+ '<input type="text" class="entryTitleInput" />'
|
||||||
|
+ '<div class="entryIdRow">'
|
||||||
|
+ '<input type="text" class="entryIdInput" autocomplete="off" spellcheck="false" />'
|
||||||
|
+ '<span class="entryIdStatus muted"></span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<span class="entryDuration muted"></span>'
|
||||||
|
+ '<button type="button" class="entryRemove" title="제외">✕</button>'
|
||||||
|
|
||||||
|
var titleInput = li.querySelector('.entryTitleInput')
|
||||||
|
var idInput = li.querySelector('.entryIdInput')
|
||||||
|
var durSpan = li.querySelector('.entryDuration')
|
||||||
|
var removeBtn = li.querySelector('.entryRemove')
|
||||||
|
|
||||||
|
titleInput.value = entry.title || ''
|
||||||
|
idInput.value = entry.videoId || ''
|
||||||
|
durSpan.textContent = formatDuration(entry.durationSec)
|
||||||
|
|
||||||
|
titleInput.addEventListener('input', function () {
|
||||||
|
state.entries[idx].title = titleInput.value
|
||||||
|
})
|
||||||
|
idInput.addEventListener('input', function () {
|
||||||
|
state.entries[idx].videoId = idInput.value.trim()
|
||||||
|
scheduleIdCheck(idx, 250)
|
||||||
|
// 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신
|
||||||
|
revalidateOtherIds(idx)
|
||||||
|
})
|
||||||
|
removeBtn.addEventListener('click', function () {
|
||||||
|
state.entries.splice(idx, 1)
|
||||||
|
// sequential 이면 번호 재계산
|
||||||
|
if (state.idMode === 'sequential') regenerateIds()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
attachRowDnd(li, idx)
|
||||||
|
paintEntryStatusInto(idx, li)
|
||||||
|
return li
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintEntryStatus(idx) {
|
||||||
|
var rows = entryList.children
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
var r = rows[i]
|
||||||
|
if (Number(r.dataset.idx) === idx) {
|
||||||
|
paintEntryStatusInto(idx, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateRegisterEnable()
|
||||||
|
}
|
||||||
|
function paintEntryStatusInto(idx, rowEl) {
|
||||||
|
var entry = state.entries[idx]
|
||||||
|
if (!entry) return
|
||||||
|
var statusEl = rowEl.querySelector('.entryIdStatus')
|
||||||
|
if (!statusEl) return
|
||||||
|
if (entry.idStatus === 'ok') {
|
||||||
|
statusEl.textContent = '사용 가능'
|
||||||
|
statusEl.className = 'entryIdStatus idStatusOk'
|
||||||
|
} else if (entry.idStatus === 'bad') {
|
||||||
|
statusEl.textContent = entry.idReason || '오류'
|
||||||
|
statusEl.className = 'entryIdStatus idStatusBad'
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = entry.idReason || '확인 중…'
|
||||||
|
statusEl.className = 'entryIdStatus muted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 한 항목의 ID 가 바뀌면 같은 ID 를 가진 다른 항목의 상태도 새로 평가해야 한다.
|
||||||
|
function revalidateOtherIds(changedIdx) {
|
||||||
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
|
if (i === changedIdx) continue
|
||||||
|
scheduleIdCheck(i, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRegisterEnable() {
|
||||||
|
var ok = state.entries.length > 0 && !state.busy
|
||||||
|
if (ok) {
|
||||||
|
for (var i = 0; i < state.entries.length; i++) {
|
||||||
|
if (state.entries[i].idStatus !== 'ok') { ok = false; break }
|
||||||
|
if (!state.entries[i].title || !state.entries[i].title.trim()) { ok = false; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerBtn.disabled = !ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 드래그 재정렬 ────────────────────────────────────────────────────
|
||||||
|
// 패턴: 원본 요소 자체를 dragover 동안 이동시켜 placeholder 없이 라이브 미리보기.
|
||||||
|
var drag = null // { srcEl }
|
||||||
|
function attachRowDnd(li) {
|
||||||
|
li.addEventListener('dragstart', function (e) {
|
||||||
|
// input 안에서 드래그 시작은 막는다 (텍스트 드래그와 충돌).
|
||||||
|
var t = e.target
|
||||||
|
if (t && t.tagName === 'INPUT') {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
drag = { srcEl: li }
|
||||||
|
try {
|
||||||
|
e.dataTransfer.setData('text/plain', li.dataset.idx)
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
} catch (_) {}
|
||||||
|
setTimeout(function () {
|
||||||
|
if (drag && drag.srcEl) drag.srcEl.classList.add('dragGhost')
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
li.addEventListener('dragend', function () {
|
||||||
|
if (drag && drag.srcEl) drag.srcEl.classList.remove('dragGhost')
|
||||||
|
drag = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entryList.addEventListener('dragover', function (e) {
|
||||||
|
if (!drag) return
|
||||||
|
e.preventDefault()
|
||||||
|
try { e.dataTransfer.dropEffect = 'move' } catch (_) {}
|
||||||
|
var target = null
|
||||||
|
for (var i = 0; i < entryList.children.length; i++) {
|
||||||
|
var c = entryList.children[i]
|
||||||
|
if (c === drag.srcEl) continue
|
||||||
|
var rect = c.getBoundingClientRect()
|
||||||
|
if (e.clientY < rect.top + rect.height / 2) { target = c; break }
|
||||||
|
}
|
||||||
|
if (target) {
|
||||||
|
if (drag.srcEl.nextSibling !== target) entryList.insertBefore(drag.srcEl, target)
|
||||||
|
} else {
|
||||||
|
if (drag.srcEl !== entryList.lastChild) entryList.appendChild(drag.srcEl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
entryList.addEventListener('drop', function (e) {
|
||||||
|
if (!drag) return
|
||||||
|
e.preventDefault()
|
||||||
|
// 새 순서 = DOM 순서대로 state 배열을 다시 만든다.
|
||||||
|
var newOrder = []
|
||||||
|
for (var i = 0; i < entryList.children.length; i++) {
|
||||||
|
var idx = Number(entryList.children[i].dataset.idx)
|
||||||
|
newOrder.push(state.entries[idx])
|
||||||
|
}
|
||||||
|
state.entries = newOrder
|
||||||
|
drag = null
|
||||||
|
if (state.idMode === 'sequential') regenerateIds()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── ID 모드 토글 ─────────────────────────────────────────────────────
|
||||||
|
for (var i = 0; i < idModeRadios.length; i++) {
|
||||||
|
idModeRadios[i].addEventListener('change', function (e) {
|
||||||
|
if (!e.target.checked) return
|
||||||
|
state.idMode = e.target.value
|
||||||
|
zeroPadWrap.hidden = state.idMode !== 'sequential'
|
||||||
|
regenerateIds()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
zeroPadToggle.addEventListener('change', function () {
|
||||||
|
state.zeroPad = zeroPadToggle.checked
|
||||||
|
if (state.idMode === 'sequential') {
|
||||||
|
regenerateIds()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── probe ────────────────────────────────────────────────────────────
|
||||||
|
probeBtn.addEventListener('click', function () {
|
||||||
|
var url = (urlInput.value || '').trim()
|
||||||
|
if (!url) {
|
||||||
|
setProbeStatus('플레이리스트 URL 을 입력해 주세요.', 'idStatusBad')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
probeBtn.disabled = true
|
||||||
|
setProbeStatus('목록을 불러오는 중…', 'muted')
|
||||||
|
fetch(probeUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: url })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j } }) })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.body || !res.body.ok) {
|
||||||
|
var msg = (res.body && res.body.message) || ('HTTP ' + res.status)
|
||||||
|
setProbeStatus(msg, 'idStatusBad')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw = res.body.entries || []
|
||||||
|
state.entries = raw.map(function (e) {
|
||||||
|
return {
|
||||||
|
url: e.url,
|
||||||
|
title: e.title || '제목 없음',
|
||||||
|
durationSec: e.durationSec,
|
||||||
|
videoId: '',
|
||||||
|
idStatus: 'pending',
|
||||||
|
idReason: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
regenerateIds()
|
||||||
|
render()
|
||||||
|
entriesSection.hidden = state.entries.length === 0
|
||||||
|
setProbeStatus(state.entries.length + ' 개 항목을 불러왔습니다.', 'idStatusOk')
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
setProbeStatus('요청 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
probeBtn.disabled = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── register ────────────────────────────────────────────────────────
|
||||||
|
registerBtn.addEventListener('click', function () {
|
||||||
|
if (registerBtn.disabled) return
|
||||||
|
var payload = {
|
||||||
|
entries: state.entries.map(function (e) {
|
||||||
|
return {
|
||||||
|
url: e.url,
|
||||||
|
title: (e.title || '').trim(),
|
||||||
|
videoId: (e.videoId || '').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
state.busy = true
|
||||||
|
registerBtn.disabled = true
|
||||||
|
setRegisterStatus('등록 중…', 'muted')
|
||||||
|
fetch(startUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { status: r.status, body: j } }) })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.body || !res.body.ok) {
|
||||||
|
var msg = (res.body && res.body.message) || ('HTTP ' + res.status)
|
||||||
|
state.busy = false
|
||||||
|
setRegisterStatus(msg, 'idStatusBad')
|
||||||
|
updateRegisterEnable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRegisterStatus(
|
||||||
|
(res.body.jobs ? res.body.jobs.length : 0) + ' 개 다운로드가 큐에 등록되었습니다. 폴더 페이지로 이동합니다…',
|
||||||
|
'idStatusOk'
|
||||||
|
)
|
||||||
|
setTimeout(function () {
|
||||||
|
location.href = folderBaseUrl
|
||||||
|
}, 800)
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
state.busy = false
|
||||||
|
setRegisterStatus('요청 실패: ' + (err && err.message ? err.message : err), 'idStatusBad')
|
||||||
|
updateRegisterEnable()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 초기 상태
|
||||||
|
updateRegisterEnable()
|
||||||
|
})()
|
||||||
@@ -312,3 +312,90 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
}
|
}
|
||||||
.adminFolder { position: relative; }
|
.adminFolder { position: relative; }
|
||||||
.adminVideo { position: relative; }
|
.adminVideo { position: relative; }
|
||||||
|
|
||||||
|
/* ─── 플레이리스트 import ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.playlistProbeSection {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.playlistProbeRow {
|
||||||
|
display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.playlistProbeRow input[type="text"] {
|
||||||
|
flex: 1; min-width: 320px;
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
color: var(--text); padding: 8px 12px; font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlistEntriesSection {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlistControlsRow {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
flex-wrap: wrap; gap: 12px;
|
||||||
|
}
|
||||||
|
.idModeBox {
|
||||||
|
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.idModeLabel { color: var(--text-muted); font-size: 13px; }
|
||||||
|
.idModeBox label { color: var(--text); font-size: 14px; cursor: pointer; }
|
||||||
|
.zeroPadWrap { margin-left: 8px; color: var(--text-muted) !important; font-size: 13px; }
|
||||||
|
.playlistSummary { color: var(--text-muted); font-size: 13px; }
|
||||||
|
|
||||||
|
.playlistEntryList {
|
||||||
|
list-style: none; margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
}
|
||||||
|
.playlistEntryRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 26px 36px 1fr 70px 28px;
|
||||||
|
align-items: center; gap: 10px;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.playlistEntryRow.dragGhost { opacity: 0.5; }
|
||||||
|
.dragHandle {
|
||||||
|
cursor: grab; color: var(--text-muted); font-size: 18px;
|
||||||
|
user-select: none; text-align: center;
|
||||||
|
}
|
||||||
|
.entryNum {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
color: var(--text-muted); font-size: 13px; text-align: right;
|
||||||
|
}
|
||||||
|
.entryFields {
|
||||||
|
display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||||
|
}
|
||||||
|
.entryTitleInput {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text); padding: 5px 8px; font-size: 14px; width: 100%;
|
||||||
|
}
|
||||||
|
.entryIdRow {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.entryIdInput {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
color: var(--text); padding: 4px 8px; font-size: 13px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
width: 240px; max-width: 100%;
|
||||||
|
}
|
||||||
|
.entryIdStatus { font-size: 12px; }
|
||||||
|
.entryDuration {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px; text-align: right;
|
||||||
|
}
|
||||||
|
.entryRemove {
|
||||||
|
background: transparent; border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted); border-radius: 6px; cursor: pointer;
|
||||||
|
width: 28px; height: 28px; padding: 0; font-size: 14px;
|
||||||
|
}
|
||||||
|
.entryRemove:hover { color: #e57373; border-color: #e57373; }
|
||||||
|
|
||||||
|
.playlistFooter {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px; flex-wrap: wrap; margin-top: 8px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -218,6 +218,34 @@ opRouter.get(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ─── 플레이리스트 import 페이지 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
opRouter.get(
|
||||||
|
'/op/folder/:topName/:subName/playlist/new',
|
||||||
|
requireAuth,
|
||||||
|
(req, res, next) => {
|
||||||
|
try {
|
||||||
|
const segments = collectFolderSegments(req.params)
|
||||||
|
const folder = getFolderByPath(segments)
|
||||||
|
if (!folder) {
|
||||||
|
res.status(404).send('폴더를 찾을 수 없습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (folder.parentId === null) {
|
||||||
|
res.status(400).send('플레이리스트는 하위 폴더에서만 등록할 수 있습니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.render('op/playlist', {
|
||||||
|
userId: req.session.userId,
|
||||||
|
folder,
|
||||||
|
breadcrumb: folderPathNames(folder.id)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
|
// ─── 영상 업로드 / 유튜브 ───────────────────────────────────────────────
|
||||||
|
|
||||||
function uploadSingle(fieldName: string) {
|
function uploadSingle(fieldName: string) {
|
||||||
|
|||||||
64
views/op/playlist.ejs
Normal file
64
views/op/playlist.ejs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>플레이리스트 추가 · <%= breadcrumb.join(' / ') %></title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="siteBody">
|
||||||
|
<%
|
||||||
|
var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/')
|
||||||
|
var folderHref = '/op/folder/' + folderPathEnc
|
||||||
|
var folderLabel = breadcrumb.join(' / ')
|
||||||
|
%>
|
||||||
|
<header class="editorNav">
|
||||||
|
<div class="editorNavLeft">
|
||||||
|
<a class="muted" href="<%= folderHref %>">← <%= folderLabel %></a>
|
||||||
|
<span class="titleInput" style="cursor:default;">플레이리스트 추가</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="editorMain">
|
||||||
|
<section class="playlistProbeSection">
|
||||||
|
<p class="muted">유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.</p>
|
||||||
|
<div class="playlistProbeRow">
|
||||||
|
<input type="text" id="playlistUrl" placeholder="https://www.youtube.com/playlist?list=..." />
|
||||||
|
<button type="button" class="primaryButton" id="probeBtn">목록 불러오기</button>
|
||||||
|
</div>
|
||||||
|
<p id="probeStatus" class="muted"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="playlistEntriesSection" id="entriesSection" hidden>
|
||||||
|
<div class="playlistControlsRow">
|
||||||
|
<div class="idModeBox">
|
||||||
|
<span class="idModeLabel">영상 ID 생성 방식</span>
|
||||||
|
<label><input type="radio" name="idMode" value="random" checked /> 랜덤</label>
|
||||||
|
<label><input type="radio" name="idMode" value="sequential" /> 정렬된 번호</label>
|
||||||
|
<label class="zeroPadWrap" id="zeroPadWrap" hidden>
|
||||||
|
<input type="checkbox" id="zeroPadToggle" /> 자릿수 채우기 (예: 001)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="playlistSummary">
|
||||||
|
<span id="entriesCount" class="muted">0 항목</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="playlistEntryList" id="entryList"></ol>
|
||||||
|
|
||||||
|
<div class="playlistFooter">
|
||||||
|
<p id="registerStatus" class="muted"></p>
|
||||||
|
<button type="button" class="primaryButton" id="registerBtn">등록 & 다운로드 시작</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__PLAYLIST__ = {
|
||||||
|
folderPath: <%- jsonForScript(breadcrumb) %>,
|
||||||
|
folderId: <%- jsonForScript(folder.id) %>
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/static/playlist.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user