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:
Claude (owner)
2026-05-31 23:51:18 +09:00
parent 3089af1ad8
commit 55ce30733b
3 changed files with 120 additions and 0 deletions

View File

@@ -20,6 +20,103 @@
var endSec = document.getElementById('endSec') var endSec = document.getElementById('endSec')
var saveBtn = document.getElementById('saveBtn') 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) { ;['dragenter', 'dragover'].forEach(function (evt) {
dropZone.addEventListener(evt, function (e) { dropZone.addEventListener(evt, function (e) {

View File

@@ -230,6 +230,20 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
.videoPanel video { .videoPanel video {
width: 100%; max-height: 60vh; background: #000; border-radius: 10px; 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 { .trimTimeline {
background: var(--bg-card); border: 1px solid var(--border); background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 16px; border-radius: 10px; padding: 16px;

View File

@@ -39,6 +39,15 @@
</div> </div>
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>> <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" <video id="editVideo" controls preload="metadata"
<% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>></video> <% if (video) { %>src="/api/video/<%= encodeURIComponent(video.id) %>/file?edited=0"<% } %>></video>