feat(folders): nested folder tree (max depth 2) + id-based mutations

- new storeDb.ts: DB+FS layer (folders w/ parent_id, videos w/ global id)
- depth cap: sub-folder cannot have children (enforced in createFolder)
- video id: editable anytime (changeVideoId), uniqueness via PK
- routes:
  - public /folder/:topName[/:subName], /video/:topName[/:subName]/:videoId
  - admin /op/folder/:topName[/:subName], id-based /op/folders/:id/*
    and /op/videos/:id/* (rename/changeId/delete/save + idAvailable check)
- views: breadcrumb + subFolder grid; 플레이리스트 추가 button shows at 2단계
- client JS: id-based mutation endpoints, share URL data attribute,
  in-site player pushes share URL to address bar
- store.ts slimmed to Account + readAccounts (FS layer moved to storeDb)
- pickIntId accepts numeric JSON bodies

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-31 23:36:02 +09:00
parent 3560dcb802
commit c5faa8808c
17 changed files with 1207 additions and 577 deletions

View File

@@ -1,6 +1,7 @@
(function () {
var ctx = window.__EDITOR__ || { folder: '', video: null }
var folder = ctx.folder
var ctx = window.__EDITOR__ || { folderPath: [], folderId: null, video: null }
var folderPathEnc = (ctx.folderPath || []).map(encodeURIComponent).join('/')
var folderBaseUrl = '/op/folder/' + folderPathEnc
var video = ctx.video
var dropZone = document.getElementById('dropZone')
@@ -46,7 +47,7 @@
form.append('title', titleInput.value || file.name)
uploadStatus.textContent = '업로드 중...'
var xhr = new XMLHttpRequest()
xhr.open('POST', '/op/folder/' + encodeURIComponent(folder) + '/video/upload')
xhr.open('POST', folderBaseUrl + '/video/upload')
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100)
@@ -57,7 +58,7 @@
try {
var res = JSON.parse(xhr.responseText)
if (res.ok) {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(res.videoId)
location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(res.videoId)
} else {
uploadStatus.textContent = '업로드 실패: ' + (res.message || '')
}
@@ -89,7 +90,7 @@
probeInfo.textContent = '확인 중...'
ytProbeBtn.disabled = true
ytStartBtn.disabled = true
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
fetch(folderBaseUrl + '/video/youtube/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
@@ -144,7 +145,7 @@
}
// 중복 클릭 방지
ytStartBtn.disabled = true
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
fetch(folderBaseUrl + '/video/youtube/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url, title: titleInput.value })
@@ -180,7 +181,7 @@
dlProgress.value = job.progress
probeInfo.textContent = job.message
if (job.status === 'done') {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
location.href = folderBaseUrl + '/video/editor?id=' + encodeURIComponent(videoId)
} else if (job.status === 'error') {
probeInfo.textContent = '실패: ' + (job.error || '')
} else {
@@ -364,14 +365,13 @@
saveBtn.addEventListener('click', function () {
if (!video) { alert('먼저 영상을 추가하세요.'); return }
var payload = {
id: video.id,
title: titleInput.value,
startSec: trimStart,
endSec: trimEnd
}
saveBtn.disabled = true
saveBtn.textContent = '저장 중...'
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/save', {
fetch('/op/videos/' + encodeURIComponent(video.id) + '/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
@@ -380,7 +380,7 @@
saveBtn.textContent = '저장'
if (j.ok) {
alert(j.note || '저장 완료')
location.href = '/op/folder/' + encodeURIComponent(folder)
location.href = folderBaseUrl
} else {
alert(j.message || '저장 실패')
}