feat: archive upload controls and copy-path buttons in Video Archive (REQ-178/179)

This commit is contained in:
Andrew-AAAA committed 2026-09-29 09:07:26 +07:00
1 parent 0c8eeac8a5
commit db1b258b03
3 files changed
+203 -14

No files matched your search

+19
View File
@@ -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}`),
+164
View File
@@ -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>
)
}
+20 -14
View File
@@ -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} />