diff --git a/public/playlist.js b/public/playlist.js new file mode 100644 index 0000000..e4aa854 --- /dev/null +++ b/public/playlist.js @@ -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 = '' + + '' + + '' + (idx + 1) + '' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + 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() +})() diff --git a/public/styles.css b/public/styles.css index c92cea1..872db75 100644 --- a/public/styles.css +++ b/public/styles.css @@ -312,3 +312,90 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content } .adminFolder { 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; +} diff --git a/src/routes/op.ts b/src/routes/op.ts index e60013c..9535253 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -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) { diff --git a/views/op/playlist.ejs b/views/op/playlist.ejs new file mode 100644 index 0000000..c398871 --- /dev/null +++ b/views/op/playlist.ejs @@ -0,0 +1,64 @@ + + + + + + 플레이리스트 추가 · <%= breadcrumb.join(' / ') %> + + + + <% + var folderPathEnc = breadcrumb.map(function (s) { return encodeURIComponent(s) }).join('/') + var folderHref = '/op/folder/' + folderPathEnc + var folderLabel = breadcrumb.join(' / ') + %> +
+
+ ← <%= folderLabel %> + 플레이리스트 추가 +
+
+ +
+
+

유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.

+
+ + +
+

+
+ + +
+ + + + +