mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 21:03:17 +02:00
feat: share videos to TV (Chromecast + AirPlay) (#33)
* server: add scoped media-token endpoint for Cast/AirPlay auth bypass
Chromecast and Apple TV fetch media URLs as independent HTTP clients
with no session cookie. The existing serve_artifact handler requires
auth_user.require_auth(), so those devices always received 401.
Changes:
- MediaToken struct stored in AppState (Arc<Mutex<HashMap>>), scoped to
a single (archive_id, entry_uid, artifact_index) tuple with a 2-hour TTL
- POST /api/archives/:id/entries/:uid/artifacts/:idx/media-token
requires an authenticated session, verifies the artifact exists,
prunes expired tokens, mints a 43-char URL-safe token, and returns
{ url, expires_in_secs }
- serve_artifact now accepts an optional ?token= query param; a valid
scoped token bypasses require_auth() while a missing/invalid/expired
token falls through to the normal 401 path
- CSP script-src extended to include https://www.gstatic.com so the
Cast sender SDK script (injected lazily by VideoPreview) is not blocked
- 4 new tests: bare-URL still 401, tokenized fetch succeeds without
session cookie, bogus token 401, wrong-artifact-index 401
* frontend: Cast/AirPlay overlay in VideoPreview
When the user opens a video archive entry, VideoPreview now:
1. Issues a signed media token (POST .../artifacts/:idx/media-token) and
uses the returned signed URL as <video src>. This ensures the video
element's src is one that Cast devices and Apple TV can fetch without a
session cookie.
2. Lazily injects the Google Cast SDK script (cast_sender.js from
gstatic.com, now allowed by the updated CSP). Once the SDK reports
available, a <google-cast-launcher> web component appears as an overlay
button in the top-right corner of the video. Selecting a Cast device
triggers loadMedia() with the signed URL and the artifact's MIME type.
3. Detects AirPlay support (webkitShowPlaybackTargetPicker on
HTMLVideoElement) and shows an AirPlay icon button alongside Cast.
The <video> element carries x-webkit-airplay='allow', so Safari's native
controls also surface the AirPlay option. The explicit overlay button
calls webkitShowPlaybackTargetPicker() for consistent placement.
Both buttons are hidden when the respective APIs are unavailable (HTTP
pages, non-Safari for AirPlay, no Cast extension/devices), so there is no
UI regression for users who don't cast.
PreviewPanel now passes contentType (derived from artifact extension) to
VideoPreview so Cast receives a correct MIME type.
New CSS: .video-tv-controls (absolute overlay), .video-tv-btn (frosted
glass icon button), .video-tv-loading (placeholder during token fetch).
* server: fix serve_artifact auth OR logic — bogus token falls back to session
Previously a request carrying ?token=<expired> was immediately rejected
with 401, even if the user held a valid session cookie. This broke
logged-in browser playback after the 2-hour signed-URL window expired,
because VideoPreview uses the signed URL as <video src>.
Fix: compute token_valid first; if the token is absent or invalid, fall
through to auth_user.require_auth() instead of returning early.
Effect: valid token skips session check, invalid/missing token checks
session, both invalid → 401 as before.
Updated the bogus-token-no-session test docstring to clarify it tests
the no-auth path specifically. Added new test:
media_token_bogus_token_with_session_returns_200 — verifies a logged-in
user can still fetch the artifact via a URL carrying a stale token.
* frontend: guard token-fetch effect against stale async resolution
A slow issueMediaToken() response for video A could resolve after the
user selected video B and call setSignedSrc(urlA), making the
preview/Cast play the wrong file.
Add a cancelled flag set in the effect cleanup; both .then and .catch
check it before touching state, so only the most recent src wins.
* frontend: load Cast media immediately if session already exists
Previously the effect only sent video to the TV on SESSION_STARTED /
SESSION_RESUMED events. Two gaps:
1. If a Cast session was already active when signedSrc became ready
(e.g. the SDK resumed a session before the token fetch finished, or
the user switches videos while already casting), nothing was sent.
2. Same gap if castReady fired after an already-established session.
Fix: extract loadMedia(session) and call it against
ctx.getCurrentSession() immediately when castReady + signedSrc are both
truthy, in addition to keeping the event listener for future connects.
* server: staged file-upload endpoint
POST /api/archives/:id/uploads streams a multipart body to a temp file
under the archive's store/temp/ directory and returns a staged_path the
capture pipeline can move into place.
- Routes: /api/archives/:id/uploads (POST, requires auth)
- Body cap: 10 GiB; chunk-streamed to disk, never buffered in memory
- Path-traversal sanitised on the filename field
- Temp files are cleaned up on error paths (disk-leak fix)
- main.rs wires the new route into the server startup
- Cargo: adds the multipart dependency
* frontend: file upload in Capture dialog
Drag-and-drop or 'Upload file' button stages files for archiving:
- File items sit alongside URL rows in the same list; each shows the
original filename, a live progress bar during upload, and a check badge
when ready. The locator input is replaced entirely — no editable field.
- Archive button is disabled until all uploads finish; each file item
contributes to the Archive N count once its upload is done.
- File items are excluded from sessionStorage persistence (they are
transient — the staged server path would be invalid after a reload).
Staged-file cleanup is handled at every exit path so temp/uploads/ does
not accumulate:
• removeRow on an in-progress item aborts the XHR; removeRow on a done
item calls DELETE /archives/:id/uploads.
• Dialog cancel (Escape / Cancel button) aborts all in-flight XHRs and
DELETEs all completed staged files via the close-event handler.
• handleArchive sets isSubmittingRef=true before dialog.close() so the
close handler skips cleanup — the background capture job handles
staged-file removal on success instead.
• uploadFile() returns { promise, abort } so the component can cancel
the XHR without any visible fetch.
api.js additions: uploadFile (XHR with progress + abort), deleteUpload.
(Static assets rebuilt from combined source to include screensharing
changes from this branch.)
* fix: collection enrollment with default_visibility_bits
Two related fixes from feat-file-uploading:
core: fix collection enrollment using default_visibility_bits instead of
entry.visibility — entries were being enrolled with the entry-level
visibility rather than the collection's configured default.
server: allow changing default_visibility_bits on the default collection
— the PATCH handler was incorrectly blocking updates to the default
collection's visibility configuration.
* server: fix unbounded staged-upload disk growth
Two review findings:
P2 — delete staged file on capture failure (routes.rs)
When perform_capture returns Err, the job was marked failed but
staged_upload_path was never removed. With a 10 GiB body cap a few
failed imports could exhaust archive storage before the next restart.
Mirror the success-path cleanup into the Err arm so the file is removed
immediately regardless of outcome.
P1 — periodic staged-upload pruning (main.rs)
The startup prune of temp/uploads/ only ran once, so uploads abandoned
mid-session (browser crash, navigation away) accumulated forever on a
long-running server. Folded the pruning logic into the existing 24 h
maintenance task alongside session cleanup, so stale dirs are swept
continuously without requiring a restart.
* server+frontend: fix staged-upload disk-growth and prune safety
Server (main.rs + routes.rs):
- Extract prune_stale_upload_dirs() helper called by both startup and
the periodic 24h task, eliminating the duplicated loop.
- Sentinel (.uploading) created in the UUID dir before streaming begins;
removed on successful completion; error path uses remove_dir_all so
the partial file and sentinel are cleaned up together.
The periodic prune skips any dir containing .uploading (active XHR).
- Startup prune passes cleanup_stale_sentinels=true: the server has not
started accepting connections yet so any sentinel is a crash remnant —
it is removed and the dir proceeds to the age check, preventing leaked
dirs from a previous crash accumulating forever.
- Staleness measured from the newest non-sentinel child file mtime so a
just-finished slow upload (dir mtime stale, file mtime fresh) is not
pruned before the user can submit it for capture. Empty dirs fall back
to dir mtime.
- Failed captures (Err branch in spawn_blocking) now also delete the
staged file and UUID dir immediately, matching the success path.
Frontend (api.js + CaptureDialog.jsx):
- submitCapture attaches err.status = res.status on non-2xx responses
so callers can distinguish a definite HTTP rejection from a network
error where the response may have been lost.
- submitBgJob catch deletes the staged file only when e.status is set
(server definitively rejected the POST /captures request). A network
error leaves the file in place because the server may have accepted
the job and the response was lost — deleting would race the capture.
This commit is contained in:
parent
6377daadae
commit
1af920eb63
17 changed files with 1498 additions and 142 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings } from '../api'
|
||||
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api'
|
||||
|
||||
let nextItemId = 1
|
||||
|
||||
|
|
@ -131,6 +131,32 @@ function makeItem(locator = '') {
|
|||
}
|
||||
}
|
||||
|
||||
function makeFileItem(filename) {
|
||||
return {
|
||||
id: nextItemId++,
|
||||
kind: 'file',
|
||||
filename,
|
||||
uploadProgress: 0,
|
||||
uploadStatus: 'uploading',
|
||||
uploadLocator: null,
|
||||
uploadError: null,
|
||||
// Fields present for submission-logic compatibility
|
||||
locator: '',
|
||||
quality: 'best',
|
||||
probeState: 'idle',
|
||||
probeQualities: null,
|
||||
probeHasAudio: false,
|
||||
playlistProbeState: 'idle',
|
||||
playlistInfo: null,
|
||||
playlistItems: null,
|
||||
playlistQuality: null,
|
||||
playlistExpanded: false,
|
||||
syncEnabled: false,
|
||||
error: null,
|
||||
status: 'idle',
|
||||
}
|
||||
}
|
||||
|
||||
function applyPlaylistQuality(newQ, currentItems) {
|
||||
if (newQ === 'best') {
|
||||
return currentItems.map(item => ({ ...item, quality: 'best' }))
|
||||
|
|
@ -174,6 +200,16 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
// stable ref so probe callbacks always see the current archiveId
|
||||
const archiveIdRef = useRef(archiveId)
|
||||
useEffect(() => { archiveIdRef.current = archiveId }, [archiveId])
|
||||
const fileInputRef = useRef(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
// Mirror of items state kept in a ref so the native 'close' event handler
|
||||
// can read the latest items without going stale. Updated every render.
|
||||
const itemsRef = useRef([])
|
||||
// Set to true in handleArchive before dialog.close() so the 'close' handler
|
||||
// skips staged-file cleanup (the capture job will clean those up on success).
|
||||
const isSubmittingRef = useRef(false)
|
||||
// item.id → abort() function for in-flight XHR uploads
|
||||
const uploadXhrs = useRef(new Map())
|
||||
|
||||
// Stable refs so polling callbacks always use the latest prop values
|
||||
const onCapturedRef = useRef(onCaptured)
|
||||
|
|
@ -204,8 +240,10 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
|
||||
// Persist items to sessionStorage on every change
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureItems', JSON.stringify(items))
|
||||
sessionStorage.setItem('captureItems', JSON.stringify(items.filter(it => it.kind !== 'file')))
|
||||
}, [items])
|
||||
// Keep itemsRef in sync so the 'close' handler always sees current items.
|
||||
useEffect(() => { itemsRef.current = items }, [items])
|
||||
|
||||
// Advanced options panel state
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
|
|
@ -257,6 +295,25 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const handler = () => {
|
||||
probeTimers.current.forEach(id => clearTimeout(id))
|
||||
probeTimers.current.clear()
|
||||
// On cancel (Escape / Cancel button) clean up any staged upload files.
|
||||
// Guard against the Archive path: handleArchive sets isSubmittingRef true
|
||||
// before dialog.close() so this block is skipped when archiving — the
|
||||
// capture job handles staged-file cleanup on success instead.
|
||||
if (!isSubmittingRef.current) {
|
||||
const aid = archiveIdRef.current
|
||||
itemsRef.current.forEach(it => {
|
||||
if (it.kind !== 'file') return
|
||||
if (it.uploadStatus === 'uploading') {
|
||||
// Abort the XHR — 'Upload cancelled' rejection is swallowed in
|
||||
// handleFiles so no spurious error row appears after close.
|
||||
uploadXhrs.current.get(it.id)?.()
|
||||
uploadXhrs.current.delete(it.id)
|
||||
} else if (it.uploadStatus === 'done' && it.uploadLocator) {
|
||||
deleteUpload(aid, it.uploadLocator).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
isSubmittingRef.current = false
|
||||
onClose()
|
||||
}
|
||||
dialog.addEventListener('close', handler)
|
||||
|
|
@ -394,16 +451,31 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
const msg = e.message || 'Submission failed.'
|
||||
onToastRef.current(msg, locator)
|
||||
settleBatch(batchId, 'failed', locator)
|
||||
// Only delete the staged file when the server definitively rejected the
|
||||
// request (e.status set by submitCapture on non-2xx HTTP responses).
|
||||
// A network error (no e.status) means the response may have been lost
|
||||
// after the server accepted the job — deleting here would race the
|
||||
// in-flight capture and remove its input file.
|
||||
if (locator.startsWith('file://') && e.status) {
|
||||
deleteUpload(aid, locator).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleArchive() {
|
||||
const toSubmit = items.filter(it => it.locator.trim())
|
||||
// Guard against the Enter-key shortcut in CaptureRow bypassing the
|
||||
// disabled button — uploads must be complete before archiving starts.
|
||||
if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return
|
||||
const toSubmit = items.filter(it =>
|
||||
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
|
||||
)
|
||||
if (toSubmit.length === 0) return
|
||||
if (toSubmit.some(it => hasConflict(it))) return
|
||||
if (toSubmit.some(it => it.probeState === 'probing' ||
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))) return
|
||||
if (toSubmit.some(it => Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
||||
if (toSubmit.some(it => it.kind !== 'file' && hasConflict(it))) return
|
||||
if (toSubmit.some(it => it.kind !== 'file' && (
|
||||
it.probeState === 'probing' ||
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))))
|
||||
return
|
||||
if (toSubmit.some(it => it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
||||
const batchId = toSubmit.length > 1
|
||||
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
||||
: null
|
||||
|
|
@ -411,14 +483,22 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
batchRef.current.set(batchId, { total: toSubmit.length, archived: 0, warnings: 0, failed: 0, failedLocators: [], warningLocators: [] })
|
||||
}
|
||||
// Capture all submission data before any state changes
|
||||
const submissions = toSubmit.map(it => ({
|
||||
locator: it.locator.trim(),
|
||||
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
||||
extraExtensions: it.playlistItems !== null
|
||||
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
|
||||
: {},
|
||||
}))
|
||||
// Reset form and close dialog immediately
|
||||
const submissions = toSubmit.map(it => {
|
||||
if (it.kind === 'file') {
|
||||
return { locator: it.uploadLocator, quality: 'best', extraExtensions: {} }
|
||||
}
|
||||
return {
|
||||
locator: it.locator.trim(),
|
||||
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
||||
extraExtensions: it.playlistItems !== null
|
||||
? { per_item_quality: Object.fromEntries(it.playlistItems.map(pi => [pi.id, pi.quality])), sync: it.syncEnabled }
|
||||
: {},
|
||||
}
|
||||
})
|
||||
// Reset form and close dialog immediately.
|
||||
// Set isSubmittingRef before close() — the native 'close' event fires
|
||||
// synchronously and the handler checks this flag to skip staged-file cleanup.
|
||||
isSubmittingRef.current = true
|
||||
setItems([makeItem()])
|
||||
dialogRef.current?.close()
|
||||
// Submit each in background
|
||||
|
|
@ -434,6 +514,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
function removeRow(id) {
|
||||
clearTimeout(probeTimers.current.get(id))
|
||||
probeTimers.current.delete(id)
|
||||
const item = itemsRef.current.find(it => it.id === id)
|
||||
if (item?.kind === 'file') {
|
||||
if (item.uploadStatus === 'uploading') {
|
||||
// Abort the in-flight XHR; the server's partial-upload cleanup handles
|
||||
// any bytes already written to temp/uploads/.
|
||||
uploadXhrs.current.get(id)?.()
|
||||
uploadXhrs.current.delete(id)
|
||||
} else if (item.uploadStatus === 'done' && item.uploadLocator) {
|
||||
// Discard the fully staged file that was never submitted for capture.
|
||||
deleteUpload(archiveIdRef.current, item.uploadLocator).catch(() => {})
|
||||
}
|
||||
}
|
||||
setItems(prev => {
|
||||
const next = prev.filter(it => it.id !== id)
|
||||
return next.length === 0 ? [makeItem()] : next
|
||||
|
|
@ -532,22 +624,94 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
))
|
||||
}
|
||||
|
||||
function handleFiles(fileList) {
|
||||
const files = Array.from(fileList)
|
||||
if (files.length === 0) return
|
||||
files.forEach(file => {
|
||||
const newItem = makeFileItem(file.name)
|
||||
setItems(prev => {
|
||||
// Replace a sole empty URL row with the file item; otherwise append
|
||||
if (prev.length === 1 && prev[0].kind !== 'file' && !prev[0].locator.trim()) {
|
||||
return [newItem]
|
||||
}
|
||||
return [...prev, newItem]
|
||||
})
|
||||
const aid = archiveIdRef.current
|
||||
const { promise, abort } = uploadFile(aid, file, progress => {
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === newItem.id ? { ...it, uploadProgress: progress } : it
|
||||
))
|
||||
})
|
||||
uploadXhrs.current.set(newItem.id, abort)
|
||||
promise
|
||||
.then(result => {
|
||||
uploadXhrs.current.delete(newItem.id)
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === newItem.id
|
||||
? { ...it, uploadStatus: 'done', uploadLocator: result.locator, uploadProgress: 100 }
|
||||
: it
|
||||
))
|
||||
})
|
||||
.catch(err => {
|
||||
uploadXhrs.current.delete(newItem.id)
|
||||
// 'Upload cancelled' means we aborted deliberately (row removed / dialog
|
||||
// closed); skip the error-state update since the row is already gone.
|
||||
if (err.message === 'Upload cancelled') return
|
||||
setItems(prev => prev.map(it =>
|
||||
it.id === newItem.id
|
||||
? { ...it, uploadStatus: 'error', uploadError: err.message }
|
||||
: it
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.dataTransfer.types.includes('Files')) setDragOver(true)
|
||||
}
|
||||
function handleDragLeave(e) {
|
||||
// Only clear when leaving the dialog itself, not a child element
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false)
|
||||
}
|
||||
function handleDrop(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOver(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
function handleFileInput(e) {
|
||||
handleFiles(e.target.files)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const pendingCount = items.filter(it => it.locator.trim()).length
|
||||
const anyConflict = items.some(it => hasConflict(it))
|
||||
|
||||
const anyUploading = items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')
|
||||
const pendingCount = items.filter(it =>
|
||||
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
|
||||
).length
|
||||
const anyConflict = items.some(it => it.kind !== 'file' && hasConflict(it))
|
||||
// True if any playlist row has had all its videos deleted — archive would be a no-op.
|
||||
const anyEmptyPlaylist = items.some(it =>
|
||||
Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
||||
it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
||||
)
|
||||
const anyProbing = items.some(it =>
|
||||
it.probeState === 'probing' ||
|
||||
// For playlist sources block unless probe completed successfully:
|
||||
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
|
||||
it.kind !== 'file' && (
|
||||
it.probeState === 'probing' ||
|
||||
// For playlist sources block unless probe completed successfully:
|
||||
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done')
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="capture-dialog">
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={`capture-dialog${dragOver ? ' capture-dialog--dragover' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="capture-dialog-inner">
|
||||
<div className="capture-dialog-header">
|
||||
<h2 className="capture-dialog-title">Capture</h2>
|
||||
|
|
@ -565,29 +729,59 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
|
||||
<div className="capture-rows">
|
||||
{items.map((item, idx) => (
|
||||
<CaptureRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
autoFocus={idx === items.length - 1}
|
||||
onLocatorChange={val => updateLocator(item.id, val)}
|
||||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
onPlaylistQualityChange={q => updatePlaylistQuality(item.id, q)}
|
||||
onPlaylistItemQualityChange={(vid, q) => updatePlaylistItemQuality(item.id, vid, q)}
|
||||
onPlaylistToggle={() => togglePlaylistExpanded(item.id)}
|
||||
onSyncChange={val => updateSync(item.id, val)}
|
||||
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
||||
/>
|
||||
item.kind === 'file' ? (
|
||||
<CaptureFileRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<CaptureRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
autoFocus={idx === items.length - 1}
|
||||
onLocatorChange={val => updateLocator(item.id, val)}
|
||||
onQualityChange={val => updateQuality(item.id, val)}
|
||||
onRemove={() => removeRow(item.id)}
|
||||
onSubmit={handleArchive}
|
||||
onPlaylistQualityChange={q => updatePlaylistQuality(item.id, q)}
|
||||
onPlaylistItemQualityChange={(vid, q) => updatePlaylistItemQuality(item.id, vid, q)}
|
||||
onPlaylistToggle={() => togglePlaylistExpanded(item.id)}
|
||||
onSyncChange={val => updateSync(item.id, val)}
|
||||
onPlaylistItemDelete={(vid) => deletePlaylistItem(item.id, vid)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="capture-add-row" onClick={addRow}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
|
||||
</svg>
|
||||
Add another
|
||||
</button>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={handleFileInput}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
<div className="capture-add-row-group">
|
||||
<button type="button" className="capture-add-row" onClick={addRow}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/>
|
||||
</svg>
|
||||
Add URL
|
||||
</button>
|
||||
<button type="button" className="capture-add-row capture-add-file" onClick={() => fileInputRef.current?.click()}>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="1" width="10" height="14" rx="1.5"/>
|
||||
<line x1="5.5" y1="5.5" x2="10.5" y2="5.5"/>
|
||||
<line x1="5.5" y1="8" x2="10.5" y2="8"/>
|
||||
<line x1="5.5" y1="10.5" x2="8.5" y2="10.5"/>
|
||||
</svg>
|
||||
Upload file
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Advanced options ────────────────────────────── */}
|
||||
<div className="capture-advanced">
|
||||
|
|
@ -701,7 +895,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
|||
type="button"
|
||||
className="capture-submit"
|
||||
onClick={handleArchive}
|
||||
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist}
|
||||
disabled={pendingCount === 0 || anyConflict || anyProbing || anyEmptyPlaylist || anyUploading}
|
||||
>
|
||||
{pendingCount > 1 ? `Archive ${pendingCount}` : 'Archive'}
|
||||
</button>
|
||||
|
|
@ -887,3 +1081,61 @@ function CaptureRow({ item, autoFocus, onLocatorChange, onQualityChange, onRemov
|
|||
</div>
|
||||
)
|
||||
}
|
||||
function CaptureFileRow({ item, onRemove }) {
|
||||
const uploading = item.uploadStatus === 'uploading'
|
||||
const done = item.uploadStatus === 'done'
|
||||
const errored = item.uploadStatus === 'error'
|
||||
|
||||
return (
|
||||
<div className="capture-row">
|
||||
<div className="capture-row-main">
|
||||
<span className="capture-file-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
|
||||
<rect x="3" y="1" width="10" height="14" rx="1.5"/>
|
||||
<line x1="5.5" y1="5.5" x2="10.5" y2="5.5"/>
|
||||
<line x1="5.5" y1="8" x2="10.5" y2="8"/>
|
||||
<line x1="5.5" y1="10.5" x2="8.5" y2="10.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span className={`capture-file-name${errored ? ' capture-file-name--error' : ''}`}>
|
||||
{item.filename}
|
||||
</span>
|
||||
{uploading && (
|
||||
<span className="capture-file-progress-wrap" aria-label={`Uploading ${item.uploadProgress}%`}>
|
||||
<span className="capture-file-progress-bar">
|
||||
<span
|
||||
className="capture-file-progress-fill"
|
||||
style={{ width: `${item.uploadProgress}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="capture-file-progress-pct">{item.uploadProgress}%</span>
|
||||
</span>
|
||||
)}
|
||||
{done && (
|
||||
<span className="capture-file-badge capture-file-badge--done" aria-label="Upload complete">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 11, height: 11 }}>
|
||||
<polyline points="3 8.5 6.5 12 13 5"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{errored && (
|
||||
<span className="capture-file-badge capture-file-badge--error">!</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="capture-row-action capture-row-remove"
|
||||
onClick={onRemove}
|
||||
aria-label="Remove"
|
||||
disabled={uploading}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{errored && (
|
||||
<p className="capture-row-error">{item.uploadError || 'Upload failed'}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue