feat(videos): inline ID edit with live availability check
영상 편집 페이지에 영상 ID 입력란을 추가했다. 입력 시 250ms 디바운스로 /op/videos/idAvailable 를 호출해 중복/형식을 실시간 확인하고, 변경 버튼으로 /op/videos/:id/changeId 를 호출한 뒤 새 ID 의 편집 페이지로 location.replace 한다. 공유 주소 복사 버튼도 함께 노출한다. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,103 @@
|
||||
var endSec = document.getElementById('endSec')
|
||||
var saveBtn = document.getElementById('saveBtn')
|
||||
|
||||
// ── 영상 ID 인라인 편집 (실시간 가용성 표시) ───────────────────────
|
||||
var videoIdInput = document.getElementById('videoIdInput')
|
||||
var idStatus = document.getElementById('idStatus')
|
||||
var changeIdBtn = document.getElementById('changeIdBtn')
|
||||
var copyShareBtn = document.getElementById('copyShareBtn')
|
||||
var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/
|
||||
var idDebounce = null
|
||||
|
||||
function setIdStatus(text, cls) {
|
||||
if (!idStatus) return
|
||||
idStatus.textContent = text
|
||||
idStatus.className = 'idStatus ' + (cls || 'muted')
|
||||
}
|
||||
|
||||
function refreshIdStatus() {
|
||||
if (!video || !videoIdInput) return
|
||||
var v = (videoIdInput.value || '').trim()
|
||||
if (!v) {
|
||||
setIdStatus('비어 있음', 'idStatusBad')
|
||||
changeIdBtn.disabled = true
|
||||
return
|
||||
}
|
||||
if (v === video.id) {
|
||||
setIdStatus('현재 ID', 'muted')
|
||||
changeIdBtn.disabled = true
|
||||
return
|
||||
}
|
||||
if (!ID_RE.test(v)) {
|
||||
setIdStatus('형식: 영문/숫자/_/- (1~64자)', 'idStatusBad')
|
||||
changeIdBtn.disabled = true
|
||||
return
|
||||
}
|
||||
setIdStatus('확인 중…', 'muted')
|
||||
changeIdBtn.disabled = true
|
||||
clearTimeout(idDebounce)
|
||||
idDebounce = setTimeout(function () {
|
||||
fetch('/op/videos/idAvailable?id=' + encodeURIComponent(v), { cache: 'no-store' })
|
||||
.then(function (r) { return r.json() })
|
||||
.then(function (j) {
|
||||
// stale 응답 (사용자가 그 사이에 입력을 더 바꾼 경우) 무시
|
||||
if ((videoIdInput.value || '').trim() !== v) return
|
||||
if (j && j.ok && j.available) {
|
||||
setIdStatus('사용 가능', 'idStatusOk')
|
||||
changeIdBtn.disabled = false
|
||||
} else {
|
||||
setIdStatus(j && j.reason ? j.reason : '이미 사용 중', 'idStatusBad')
|
||||
changeIdBtn.disabled = true
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if ((videoIdInput.value || '').trim() !== v) return
|
||||
setIdStatus('확인 실패', 'idStatusBad')
|
||||
changeIdBtn.disabled = true
|
||||
})
|
||||
}, 250)
|
||||
}
|
||||
|
||||
if (videoIdInput && video) {
|
||||
videoIdInput.addEventListener('input', refreshIdStatus)
|
||||
changeIdBtn.addEventListener('click', function () {
|
||||
var newId = (videoIdInput.value || '').trim()
|
||||
if (!newId || newId === video.id) return
|
||||
changeIdBtn.disabled = true
|
||||
setIdStatus('변경 중…', 'muted')
|
||||
fetch('/op/videos/' + encodeURIComponent(video.id) + '/changeId', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ newId: newId })
|
||||
}).then(function (r) { return r.json() }).then(function (j) {
|
||||
if (!j.ok) {
|
||||
setIdStatus(j.message || 'ID 변경 실패', 'idStatusBad')
|
||||
changeIdBtn.disabled = false
|
||||
return
|
||||
}
|
||||
// 같은 페이지를 새 ID 로 다시 진입 (URL ?id= 도 갱신).
|
||||
location.replace(folderBaseUrl + '/video/editor?id=' + encodeURIComponent(j.video.id))
|
||||
}).catch(function (e) {
|
||||
setIdStatus('변경 실패: ' + e.message, 'idStatusBad')
|
||||
changeIdBtn.disabled = false
|
||||
})
|
||||
})
|
||||
if (copyShareBtn) {
|
||||
copyShareBtn.addEventListener('click', function () {
|
||||
// 입력란의 후보 ID 가 아니라 실제로 DB 에 저장된 video.id 로 URL 을 만든다.
|
||||
// (변경 전에는 새 URL 이 아직 살아있지 않으므로.)
|
||||
var shareUrl = location.origin + '/video/' + folderPathEnc + '/' + encodeURIComponent(video.id)
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(shareUrl).then(function () {
|
||||
setIdStatus('공유 주소 복사됨', 'idStatusOk')
|
||||
}, function () { window.prompt('주소를 복사하세요:', shareUrl) })
|
||||
} else {
|
||||
window.prompt('주소를 복사하세요:', shareUrl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 드래그&드롭
|
||||
;['dragenter', 'dragover'].forEach(function (evt) {
|
||||
dropZone.addEventListener(evt, function (e) {
|
||||
|
||||
@@ -230,6 +230,20 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
||||
.videoPanel video {
|
||||
width: 100%; max-height: 60vh; background: #000; border-radius: 10px;
|
||||
}
|
||||
.videoIdRow {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 10px 14px;
|
||||
}
|
||||
.videoIdRow label { color: var(--text-muted); font-size: 13px; }
|
||||
.videoIdRow input[type="text"] {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); padding: 6px 10px; font-family: ui-monospace, monospace;
|
||||
font-size: 14px; min-width: 260px; flex: 1; max-width: 420px;
|
||||
}
|
||||
.idStatus { font-size: 13px; min-width: 90px; }
|
||||
.idStatusOk { color: #5dd28a; }
|
||||
.idStatusBad { color: #e57373; }
|
||||
.trimTimeline {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 16px;
|
||||
|
||||
@@ -39,6 +39,15 @@
|
||||
</div>
|
||||
|
||||
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
|
||||
<div class="videoIdRow">
|
||||
<label for="videoIdInput">영상 ID</label>
|
||||
<input type="text" id="videoIdInput" autocomplete="off" spellcheck="false"
|
||||
value="<%= video ? video.id : '' %>" />
|
||||
<span id="idStatus" class="idStatus muted"></span>
|
||||
<button type="button" class="secondaryButton" id="changeIdBtn" disabled>변경</button>
|
||||
<button type="button" class="secondaryButton" id="copyShareBtn">공유 주소 복사</button>
|
||||
</div>
|
||||
|
||||
<video id="editVideo" controls preload="metadata"
|
||||
<% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>></video>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user