feat: archive upload controls and copy-path buttons in Video Archive (REQ-178/179)
This commit is contained in:
1 parent
0c8eeac8a5
commit
db1b258b03
3 files changed
+203
-14
No files matched your search
@@ -64,6 +64,25 @@ export const api = {
|
||||
videoInfo: (id, rel) => request(`/projects/${id}/video/info?rel=${encodeURIComponent(rel)}`),
|
||||
videoUrl: (id, rel) => `/api/projects/${id}/video?rel=${encodeURIComponent(rel)}`,
|
||||
|
||||
createLibraryDate: (id, date) =>
|
||||
request(`/projects/${id}/library/dates?date=${encodeURIComponent(date)}`, { method: 'POST' }),
|
||||
uploadLibraryVideo: (id, date, file, onProgress) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `/api/projects/${id}/library/upload?date=${encodeURIComponent(date)}`)
|
||||
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total) }
|
||||
xhr.onload = () => {
|
||||
let payload = {}
|
||||
try { payload = JSON.parse(xhr.responseText || '{}') } catch {}
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(payload)
|
||||
else reject(new Error(payload.detail || `${xhr.status} ${xhr.statusText}`))
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('network error'))
|
||||
xhr.send(form)
|
||||
}),
|
||||
|
||||
createBatch: (id, body) => request(`/projects/${id}/batches`, { method: 'POST', body }),
|
||||
listBatches: (id) => request(`/projects/${id}/batches`),
|
||||
getBatch: (id) => request(`/batches/${id}`),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
|
||||
const ACCEPT = '.mp4,.mkv,.mov,.avi,.webm,.m4v'
|
||||
|
||||
function CopyPath({ label, value }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
if (!value) return null
|
||||
|
||||
const copy = async () => {
|
||||
setError('')
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
setError('Gagal menyalin path.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
title={value}
|
||||
onClick={copy}
|
||||
style={{ fontSize: '0.78rem', padding: '5px 10px', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<span aria-live="polite">{copied ? 'Tersalin' : label}</span>
|
||||
</button>
|
||||
{error && (
|
||||
<span role="status" style={{ fontSize: '0.74rem', color: 'var(--danger)' }}>
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ArchiveControls({ project, projectId, onChanged }) {
|
||||
const [date, setDate] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [folderError, setFolderError] = useState('')
|
||||
const [uploadErrors, setUploadErrors] = useState([])
|
||||
const [progress, setProgress] = useState(null)
|
||||
const fileInput = useRef(null)
|
||||
|
||||
const needDate = () => {
|
||||
if (date) return true
|
||||
setFolderError('Pilih tanggal dulu.')
|
||||
return false
|
||||
}
|
||||
|
||||
const createFolder = async () => {
|
||||
if (!needDate()) return
|
||||
setBusy(true)
|
||||
setFolderError('')
|
||||
try {
|
||||
await api.createLibraryDate(projectId, date)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
setFolderError(exc.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFiles = async (event) => {
|
||||
const files = Array.from(event.target.files || [])
|
||||
event.target.value = ''
|
||||
if (!files.length || !needDate()) return
|
||||
setBusy(true)
|
||||
setFolderError('')
|
||||
setUploadErrors([])
|
||||
for (const file of files) {
|
||||
setProgress({ name: file.name, pct: 0 })
|
||||
try {
|
||||
await api.uploadLibraryVideo(projectId, date, file, (p) =>
|
||||
setProgress({ name: file.name, pct: Math.round(p * 100) })
|
||||
)
|
||||
onChanged()
|
||||
} catch (exc) {
|
||||
setUploadErrors((prev) => [...prev, { name: file.name, message: exc.message }])
|
||||
}
|
||||
}
|
||||
setProgress(null)
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
const row = { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }
|
||||
const smallBtn = { fontSize: '0.8rem', padding: '6px 12px' }
|
||||
const status = { margin: 0, fontSize: '0.76rem' }
|
||||
|
||||
return (
|
||||
<div className="archive-controls" style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
||||
<style>{`
|
||||
.archive-controls .btn { transition: background 200ms, border-color 200ms, color 200ms; }
|
||||
.archive-controls .progress span { transition: width 200ms; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.archive-controls .btn, .archive-controls .progress span { transition: none; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{ ...row, gap: 4 }}>
|
||||
<CopyPath label="Salin path Linux" value={project.video_root_linux} />
|
||||
<CopyPath label="Salin path Windows" value={project.video_root_windows} />
|
||||
</div>
|
||||
|
||||
<div style={row}>
|
||||
<label className="hint" style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--text-muted)' }}>
|
||||
Tanggal
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
style={{ width: 148, padding: '5px 8px', fontSize: '0.8rem' }}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn" disabled={busy} onClick={createFolder} style={smallBtn}>
|
||||
Folder baru
|
||||
</button>
|
||||
<button className="btn" disabled={busy} onClick={() => fileInput.current?.click()} style={smallBtn}>
|
||||
Unggah video
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
onChange={uploadFiles}
|
||||
aria-label="Video yang akan diunggah"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end', maxWidth: 420 }}>
|
||||
{folderError && <p className="hint" style={{ ...status, color: 'var(--danger)' }}>{folderError}</p>}
|
||||
{progress && (
|
||||
<div style={{ width: 280 }}>
|
||||
<p className="hint" style={{ ...status, color: 'var(--text-muted)', marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{progress.name} · {progress.pct}%
|
||||
</p>
|
||||
<div
|
||||
className="progress"
|
||||
role="progressbar"
|
||||
aria-label={progress.name}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress.pct}
|
||||
>
|
||||
<span style={{ width: `${progress.pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{uploadErrors.map((err, i) => (
|
||||
<p key={`${err.name}-${i}`} className="hint" style={{ ...status, color: 'var(--danger)' }}>
|
||||
{err.name}: {err.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AutoAnnotateModal from '../components/AutoAnnotateModal'
|
||||
import ArchiveControls from '../components/ArchiveControls'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, formatDuration } from '../api'
|
||||
import { navigate } from '../App'
|
||||
@@ -388,6 +389,8 @@ export default function LibraryPage({ projectId, onProject }) {
|
||||
api.listJobs(projectId).then((payload) => setJobs(payload.jobs)).catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
const reloadArchive = useCallback(() => { api.archiveCycles(projectId).then(l => { setDates(l.cycles); if (!l.cycles.some(c => c.cycle === selected)) setSelected(l.cycles[0]?.cycle ?? null) }).catch(() => {}) }, [projectId, selected])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setError('')
|
||||
@@ -481,20 +484,23 @@ export default function LibraryPage({ projectId, onProject }) {
|
||||
video, bukan dari nama foldernya — file di disk tidak dipindah.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={truckBusy}
|
||||
title="Periksa tiap rekaman apakah benar ada truknya, memakai model terbaru (v4). 12 frame per rekaman."
|
||||
onClick={async () => {
|
||||
setTruckBusy(true)
|
||||
try { await api.truckScan(projectId); loadJobs() }
|
||||
catch (exc) { setError(exc.message) }
|
||||
finally { setTruckBusy(false) }
|
||||
}}
|
||||
style={{ fontSize: '0.82rem', padding: '8px 14px', cursor: truckBusy ? 'wait' : 'pointer', borderRadius: 6 }}
|
||||
>
|
||||
{truckBusy ? 'Memulai…' : 'Cek truk (v4)'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
||||
<ArchiveControls project={project} projectId={projectId} onChanged={reloadArchive} />
|
||||
<button
|
||||
className="btn"
|
||||
disabled={truckBusy}
|
||||
title="Periksa tiap rekaman apakah benar ada truknya, memakai model terbaru (v4). 12 frame per rekaman."
|
||||
onClick={async () => {
|
||||
setTruckBusy(true)
|
||||
try { await api.truckScan(projectId); loadJobs() }
|
||||
catch (exc) { setError(exc.message) }
|
||||
finally { setTruckBusy(false) }
|
||||
}}
|
||||
style={{ fontSize: '0.82rem', padding: '8px 14px', cursor: truckBusy ? 'wait' : 'pointer', borderRadius: 6 }}
|
||||
>
|
||||
{truckBusy ? 'Memulai…' : 'Cek truk (v4)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActiveJobsBanner jobs={activeJobs} onCancel={cancelJob} />
|
||||
|
||||
Reference in new issue
Block a user