From 5b05d42782ce9325b47effe08804f95f3e76ad38 Mon Sep 17 00:00:00 2001 From: Yahya Kousa Date: Tue, 14 Jul 2026 20:27:33 -0500 Subject: [PATCH 1/6] Fix seek causing playback to reset to 0 and pause --- .../src/components/player/VideoJSPlayer.js | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/client/src/components/player/VideoJSPlayer.js b/app/client/src/components/player/VideoJSPlayer.js index a9361e20..efeaea96 100644 --- a/app/client/src/components/player/VideoJSPlayer.js +++ b/app/client/src/components/player/VideoJSPlayer.js @@ -131,6 +131,7 @@ function PlayerEffects({ sources, onSourceChange, onTimeUpdate, onReady, startTi let bufferTimestamps = [] let isSourceTransitioning = false let sourceTransitionTimer = null + let isSeeking = false const clearStallTimer = () => { if (bufferStallTimer) { @@ -182,8 +183,21 @@ function PlayerEffects({ sources, onSourceChange, onTimeUpdate, onReady, startTi clearTransitionTimer() } + const handleSeeking = () => { + isSeeking = true + clearStallTimer() + } + + const handleSeeked = () => { + isSeeking = false + clearStallTimer() + } + const handleWaiting = () => { - if (isSourceTransitioning) return + // Seeking to an unbuffered position naturally fires 'waiting' while the + // browser loads that range — that's not real network stalling and + // shouldn't count toward a quality downgrade. + if (isSourceTransitioning || isSeeking) return const now = Date.now() bufferTimestamps.push(now) @@ -213,7 +227,8 @@ function PlayerEffects({ sources, onSourceChange, onTimeUpdate, onReady, startTi media.addEventListener('waiting', handleWaiting) media.addEventListener('playing', handlePlayingOrPause) media.addEventListener('pause', handlePlayingOrPause) - media.addEventListener('seeked', handlePlayingOrPause) + media.addEventListener('seeking', handleSeeking) + media.addEventListener('seeked', handleSeeked) return () => { clearStallTimer() @@ -224,7 +239,8 @@ function PlayerEffects({ sources, onSourceChange, onTimeUpdate, onReady, startTi media.removeEventListener('waiting', handleWaiting) media.removeEventListener('playing', handlePlayingOrPause) media.removeEventListener('pause', handlePlayingOrPause) - media.removeEventListener('seeked', handlePlayingOrPause) + media.removeEventListener('seeking', handleSeeking) + media.removeEventListener('seeked', handleSeeked) } }, [media, sources, onSourceChange]) From bed5ff013233cc825358b4fc5e7d52b1ff1e9610 Mon Sep 17 00:00:00 2001 From: Yahya Kousa Date: Sat, 18 Jul 2026 18:47:44 -0500 Subject: [PATCH 2/6] Support uploading multiple videos at once with per-file progress --- app/client/src/components/cards/UploadCard.js | 588 +++++++++++------- .../components/utils/GlobalDragDropOverlay.js | 4 +- 2 files changed, 372 insertions(+), 220 deletions(-) diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index efcdff6e..d5be96e8 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -96,18 +96,16 @@ function LogoProgress({ progress, size = 44 }) { } const UploadCard = React.forwardRef(function UploadCard( - { authenticated, handleAlert, mini, onUploadComplete, onProgress, dropOnly = false }, + { authenticated, handleAlert, mini, onUploadComplete, dropOnly = false }, ref, ) { - const [selectedFile, setSelectedFile] = React.useState() - const [isSelected, setIsSelected] = React.useState(false) - const [progress, setProgress] = React.useState(0) - const [uploadRate, setUploadRate] = React.useState() + // Upload queue — supports multiple concurrent uploads, each with its own progress + const [uploadQueue, setUploadQueue] = React.useState([]) + const startedUploadsRef = React.useRef(new Set()) const uiConfig = getSetting('ui_config') - const lastProgressUpdate = React.useRef(0) // Pre-upload metadata dialog - const [pendingFile, setPendingFile] = React.useState(null) + const [pendingFiles, setPendingFiles] = React.useState([]) const [dialogOpen, setDialogOpen] = React.useState(false) const [allGames, setAllGames] = React.useState([]) const [allTags, setAllTags] = React.useState([]) @@ -128,17 +126,21 @@ const UploadCard = React.forwardRef(function UploadCard( const [previewPlayable, setPreviewPlayable] = React.useState(false) const [availableFolders, setAvailableFolders] = React.useState([]) const [selectedFolder, setSelectedFolder] = React.useState('') - // Stored metadata to attach on next upload - const pendingMetadata = React.useRef({ tag_ids: null, game_id: null, folder: null }) const imageThumbnailUrlRef = React.useRef(null) const previewUrlRef = React.useRef(null) + const intakeFiles = (files) => { + const accepted = (files || []).filter((f) => checkUploadLimit(f, handleAlert)) + if (accepted.length === 0) return + openMetadataDialog(accepted) + } + React.useImperativeHandle(ref, () => ({ openFile(file) { - if (!file || !checkUploadLimit(file, handleAlert)) return - setProgress(0) - lastProgressUpdate.current = 0 - openMetadataDialog(file) + intakeFiles(file ? [file] : []) + }, + openFiles(files) { + intakeFiles(files) }, })) @@ -199,8 +201,8 @@ const UploadCard = React.forwardRef(function UploadCard( video.load() } - const openMetadataDialog = (file) => { - setPendingFile(file) + const openMetadataDialog = (files) => { + setPendingFiles(files) setSelectedGame(null) setSelectedTags([]) setTagInput('') @@ -211,8 +213,8 @@ const UploadCard = React.forwardRef(function UploadCard( setEditingTitle(false) setTitleDraft('') - createPreviewUrl(file) - extractThumbnail(file) + createPreviewUrl(files[0]) + extractThumbnail(files[0]) const foldersFetch = authenticated ? VideoService.getUploadFolders() : uiConfig?.allow_public_folder_selection @@ -268,16 +270,25 @@ const UploadCard = React.forwardRef(function UploadCard( return res.data }), ) - pendingMetadata.current = { + const metadata = { tag_ids: resolvedTags.length ? resolvedTags.map((t) => t.id).join(',') : null, game_id: selectedGame ? selectedGame.id : null, folder: (uploadToGameFolder && selectedGame ? selectedGame.name : selectedFolder) || null, - title: titleInput.trim() || null, } + const isBatch = pendingFiles.length > 1 + const items = pendingFiles.map((file, idx) => ({ + id: `${Date.now()}-${idx}-${file.name}`, + file, + // A custom title only makes sense for a single file — batches keep their filenames + metadata: { ...metadata, title: !isBatch ? titleInput.trim() || null : null }, + progress: 0, + rate: null, + status: 'queued', + })) setDialogOpen(false) - setSelectedFile(pendingFile) - setIsSelected(true) - setPendingFile(null) + setPendingFiles([]) + setUploadQueue((prev) => [...prev, ...items]) + if (isBatch) clearPreviewUrl() if (imageThumbnailUrlRef.current) { URL.revokeObjectURL(imageThumbnailUrlRef.current) imageThumbnailUrlRef.current = null @@ -286,7 +297,7 @@ const UploadCard = React.forwardRef(function UploadCard( const handleDialogCancel = () => { setDialogOpen(false) - setPendingFile(null) + setPendingFiles([]) setSelectedGame(null) setSelectedTags([]) setTagInput('') @@ -359,50 +370,19 @@ const UploadCard = React.forwardRef(function UploadCard( } const changeHandler = (event) => { - const file = event.target.files[0] - if (!file || !checkUploadLimit(file, handleAlert)) return - setProgress(0) - lastProgressUpdate.current = 0 - openMetadataDialog(file) + intakeFiles(Array.from(event.target.files || [])) + // Allow re-selecting the same file(s) later + event.target.value = '' } - const uploadProgress = (progress, rate) => { - if (progress <= 1 && progress >= 0) { - const now = Date.now() - if (progress === 1 || now - lastProgressUpdate.current >= 1000) { - lastProgressUpdate.current = now - setProgress(progress) - setUploadRate(() => ({ ...rate })) - onProgress?.(progress, rate) - } - } - } - - const uploadProgressChunked = (progress, progressTotal, rate) => { - const now = Date.now() - const stale = now - lastProgressUpdate.current >= 1000 - if (progressTotal <= 1 && progressTotal >= 0) { - if (progressTotal === 1 || stale) { - lastProgressUpdate.current = now - setProgress(progressTotal) - setUploadRate(() => ({ ...rate })) - onProgress?.(progressTotal, rate) - } - } else if (progress <= 1 && progress >= 0 && (progress === 1 || stale)) { - lastProgressUpdate.current = now - setProgress(progress) - setUploadRate(() => ({ ...rate })) - onProgress?.(progress, rate) - } + const updateQueueItem = (id, patch) => { + setUploadQueue((prev) => prev.map((item) => (item.id === id ? { ...item, ...patch } : item))) } // Function to handle the drop event const dropHandler = (event) => { event.preventDefault() - const file = event.dataTransfer.files[0] - if (!file || !checkUploadLimit(file, handleAlert)) return - setProgress(0) - openMetadataDialog(file) + intakeFiles(Array.from(event.dataTransfer.files || [])) } // Prevent default behavior for drag events to enable dropping files @@ -410,132 +390,178 @@ const UploadCard = React.forwardRef(function UploadCard( event.preventDefault() } - React.useEffect(() => { - if (!selectedFile) return - + const runUpload = async (item) => { + const { file, metadata } = item const chunkSize = 90 * 1024 * 1024 // 90MB chunk size - const { tag_ids, game_id, folder, title } = pendingMetadata.current + const { tag_ids, game_id, folder, title } = metadata + + // Per-item throttled progress reporting + let lastUpdate = 0 + const reportProgress = (progress, rate) => { + if (progress < 0 || progress > 1) return + const now = Date.now() + if (progress === 1 || now - lastUpdate >= 1000) { + lastUpdate = now + updateQueueItem(item.id, { + progress, + rate: rate ? { ...rate } : null, + status: progress >= 1 ? 'processing' : 'uploading', + }) + } + } + const reportProgressChunked = (progress, progressTotal, rate) => { + if (progressTotal >= 0 && progressTotal <= 1) reportProgress(progressTotal, rate) + else reportProgress(progress, rate) + } - async function upload() { - const formData = new FormData() - formData.append('file', selectedFile) + const appendMetadata = (formData) => { if (tag_ids) formData.append('tag_ids', tag_ids) if (game_id) formData.append('game_id', game_id) if (folder) formData.append('folder', folder) if (title) formData.append('title', title) - try { - if (authenticated) { - await VideoService.upload(formData, uploadProgress) - } else { - await VideoService.publicUpload(formData, uploadProgress) - } - handleAlert({ - type: 'success', - message: 'Your upload will be available in a few seconds.', - autohideDuration: 3500, - open: true, - }) - if (onUploadComplete) onUploadComplete() - } catch (err) { - handleAlert({ - type: 'error', - message: `An error occurred while uploading your video.`, - open: true, - }) - } - setProgress(0) - setUploadRate(null) - setIsSelected(false) - setSelectedFile(null) - setThumbnail(null) - setThumbnailReady(false) - clearPreviewUrl() } - async function uploadChunked() { - if (!selectedFile) return - - const totalChunks = Math.ceil(selectedFile.size / chunkSize) + try { + if (file.size > chunkSize) { + const totalChunks = Math.ceil(file.size / chunkSize) - const fileInfo = `${selectedFile.name}-${selectedFile.size}-${selectedFile.lastModified}` - let checksum - if (crypto.subtle) { - checksum = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(fileInfo)).then((buf) => - Array.from(new Uint8Array(buf)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''), - ) - } else { - // Fallback for non-secure contexts (plain HTTP over network IP). - // A simple hash is sufficient here — it only correlates upload chunks. - let h = 0 - for (let i = 0; i < fileInfo.length; i++) { - h = (Math.imul(31, h) + fileInfo.charCodeAt(i)) | 0 + const fileInfo = `${file.name}-${file.size}-${file.lastModified}` + let checksum + if (crypto.subtle) { + checksum = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(fileInfo)).then((buf) => + Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ) + } else { + // Fallback for non-secure contexts (plain HTTP over network IP). + // A simple hash is sufficient here — it only correlates upload chunks. + let h = 0 + for (let i = 0; i < fileInfo.length; i++) { + h = (Math.imul(31, h) + fileInfo.charCodeAt(i)) | 0 + } + checksum = (h >>> 0).toString(16).padStart(8, '0') } - checksum = (h >>> 0).toString(16).padStart(8, '0') - } - try { for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { const start = chunkIndex * chunkSize - const end = Math.min(start + chunkSize, selectedFile.size) - const chunk = selectedFile.slice(start, end) + const end = Math.min(start + chunkSize, file.size) + const chunk = file.slice(start, end) const formData = new FormData() - formData.append('blob', chunk, selectedFile.name) + formData.append('blob', chunk, file.name) formData.append('chunkPart', chunkIndex + 1) formData.append('totalChunks', totalChunks) formData.append('checkSum', checksum) - formData.append('fileName', selectedFile.name) - formData.append('fileSize', selectedFile.size.toString()) - formData.append('lastModified', selectedFile.lastModified.toString()) - formData.append('fileType', selectedFile.type) - if (tag_ids) formData.append('tag_ids', tag_ids) - if (game_id) formData.append('game_id', game_id) - if (folder) formData.append('folder', folder) - if (title) formData.append('title', title) + formData.append('fileName', file.name) + formData.append('fileSize', file.size.toString()) + formData.append('lastModified', file.lastModified.toString()) + formData.append('fileType', file.type) + appendMetadata(formData) authenticated - ? await VideoService.uploadChunked(formData, uploadProgressChunked, selectedFile.size, start) - : await VideoService.publicUploadChunked(formData, uploadProgressChunked, selectedFile.size, start) + ? await VideoService.uploadChunked(formData, reportProgressChunked, file.size, start) + : await VideoService.publicUploadChunked(formData, reportProgressChunked, file.size, start) } + } else { + const formData = new FormData() + formData.append('file', file) + appendMetadata(formData) + if (authenticated) { + await VideoService.upload(formData, reportProgress) + } else { + await VideoService.publicUpload(formData, reportProgress) + } + } + updateQueueItem(item.id, { status: 'done', progress: 1 }) + if (onUploadComplete) onUploadComplete() + } catch (err) { + updateQueueItem(item.id, { status: 'error' }) + handleAlert({ + type: 'error', + message: `An error occurred while uploading ${file.name}.`, + open: true, + }) + } + } + + const MAX_CONCURRENT_UPLOADS = 3 + + React.useEffect(() => { + if (uploadQueue.length === 0) return + // Start queued uploads up to the concurrency limit + const active = uploadQueue.filter((i) => i.status === 'uploading' || i.status === 'processing').length + let slots = MAX_CONCURRENT_UPLOADS - active + for (const item of uploadQueue) { + if (slots <= 0) break + if (item.status !== 'queued' || startedUploadsRef.current.has(item.id)) continue + startedUploadsRef.current.add(item.id) + slots-- + updateQueueItem(item.id, { status: 'uploading' }) + runUpload(item) + } + + // Once every upload has finished, show a single summary alert and reset the card + if (uploadQueue.every((i) => i.status === 'done' || i.status === 'error')) { + const succeeded = uploadQueue.filter((i) => i.status === 'done').length + const failed = uploadQueue.length - succeeded + if (succeeded > 0) { handleAlert({ - type: 'success', - message: 'Your upload will be available in a few seconds.', + type: failed > 0 ? 'warning' : 'success', + message: + failed > 0 + ? `${succeeded} of ${uploadQueue.length} uploads succeeded — ${failed} failed.` + : succeeded === 1 + ? 'Your upload will be available in a few seconds.' + : `${succeeded} uploads will be available in a few seconds.`, autohideDuration: 3500, open: true, }) - if (onUploadComplete) onUploadComplete() - } catch (err) { - handleAlert({ - type: 'error', - message: `An error occurred while uploading your video.`, - open: true, - }) } - - setProgress(0) - setUploadRate(null) - setIsSelected(false) - setSelectedFile(null) + startedUploadsRef.current.clear() + setUploadQueue([]) setThumbnail(null) setThumbnailReady(false) clearPreviewUrl() } - - if (selectedFile.size > chunkSize) { - uploadChunked() - } else { - upload() - } // eslint-disable-next-line - }, [selectedFile]) + }, [uploadQueue]) - const filenameStem = pendingFile ? pendingFile.name.replace(/\.[^/.]+$/, '') : '' + const isBatchPending = pendingFiles.length > 1 + const filenameStem = pendingFiles[0] ? pendingFiles[0].name.replace(/\.[^/.]+$/, '') : '' const displayTitle = titleInput || filenameStem || 'Untitled' - const inlineTitleEl = ( + // Derived upload state across the whole queue + const isUploading = uploadQueue.length > 0 + const totalQueueBytes = uploadQueue.reduce((sum, i) => sum + i.file.size, 0) + const loadedQueueBytes = uploadQueue.reduce( + (sum, i) => sum + (i.status === 'done' || i.status === 'processing' ? i.file.size : i.progress * i.file.size), + 0, + ) + const aggregateProgress = totalQueueBytes > 0 ? Math.min(loadedQueueBytes / totalQueueBytes, 1) : 0 + const finishedCount = uploadQueue.filter((i) => i.status === 'done' || i.status === 'error').length + const allSent = isUploading && uploadQueue.every((i) => i.status !== 'queued' && i.status !== 'uploading') + const singleUpload = uploadQueue.length === 1 ? uploadQueue[0] : null + + const inlineTitleEl = isBatchPending ? ( + + + {pendingFiles.length} videos + + + Game, folder and tags below will be applied to all {pendingFiles.length} videos. + + + ) : ( {editingTitle ? ( ) + const pendingFilesCaption = isBatchPending ? ( + + {pendingFiles.map((f, idx) => ( + + {f.name} + + ))} + + ) : ( + + {pendingFiles[0]?.name} + + ) + const renderLocalVideoPreview = (sx = {}) => previewUrl && previewPlayable ? ( - Upload Video + {isBatchPending ? `Upload ${pendingFiles.length} Videos` : 'Upload Video'} @@ -697,19 +773,7 @@ const UploadCard = React.forwardRef(function UploadCard( )} - - {pendingFile?.name} - + {pendingFilesCaption} {/* Form fields — right column */} @@ -914,12 +978,12 @@ const UploadCard = React.forwardRef(function UploadCard( sx={{ position: 'relative', borderRadius: '13px', - padding: progress > 0 ? '2px' : '0px', + padding: isUploading ? '2px' : '0px', overflow: 'hidden', transition: 'padding 0.2s', '&::before': { content: '""', - display: progress > 0 ? 'block' : 'none', + display: isUploading ? 'block' : 'none', position: 'absolute', inset: '-100%', background: 'conic-gradient(#BC00E6DF, #FF3729D9, #0084ff, #BC00E6DF)', @@ -931,23 +995,25 @@ const UploadCard = React.forwardRef(function UploadCard( sx={{ position: 'relative', width: '100%', - height: mini ? '56px' : '90px', + // Grow to fit the per-file list when uploading multiple videos + height: mini ? '56px' : uploadQueue.length > 1 ? 'auto' : '90px', + minHeight: mini ? '56px' : '90px', cursor: 'pointer', background: '#001224', overflow: 'hidden', border: '2px solid', - borderColor: progress > 0 ? 'transparent' : 'rgba(38, 132, 255, 0.25)', + borderColor: isUploading ? 'transparent' : 'rgba(38, 132, 255, 0.25)', borderRadius: '12px', transition: 'border-color 0.2s, background 0.2s', '&:hover': { - borderColor: progress > 0 ? 'transparent' : 'rgba(38, 132, 255, 0.5)', - background: progress > 0 ? 'rgb(0, 32, 73)' : 'rgba(38, 132, 255, 0.1)', + borderColor: isUploading ? 'transparent' : 'rgba(38, 132, 255, 0.5)', + background: isUploading ? 'rgb(0, 32, 73)' : 'rgba(38, 132, 255, 0.1)', }, }} onDrop={dropHandler} onDragOver={dragOverHandler} > - {progress > 0 && !mini && previewUrl && previewPlayable && ( + {isUploading && !mini && previewUrl && previewPlayable && ( <> {renderLocalVideoPreview({ position: 'absolute', @@ -972,16 +1038,16 @@ const UploadCard = React.forwardRef(function UploadCard( justifyContent="center" spacing={0.5} > - {!isSelected && ( - - )} - {progress === 0 && !mini && ( + + + {!isUploading && !mini && ( <> @@ -989,36 +1055,134 @@ const UploadCard = React.forwardRef(function UploadCard( )} - {progress === 0 && mini && } - {progress > 0 && ( + {!isUploading && mini && } + {isUploading && ( <> {!mini ? ( - - - - 1 ? 1.5 : 0 }}> + + + + + {allSent + ? 'Processing...' + : `Uploading ${(100 * (singleUpload ? singleUpload.progress : aggregateProgress)).toFixed(0)}%`} + + + {allSent + ? 'Please wait...' + : singleUpload + ? singleUpload.rate + ? `${numberFormat.format(singleUpload.rate.loaded.toFixed(0))} / ${numberFormat.format(singleUpload.rate.total.toFixed(0))} MB` + : 'Starting...' + : `${finishedCount} of ${uploadQueue.length} complete`} + + + + {uploadQueue.length > 1 && ( + - {progress < 1 ? `Uploading ${(100 * progress).toFixed(0)}%` : 'Processing...'} - - - {progress < 1 - ? `${numberFormat.format(uploadRate.loaded.toFixed(0))} / ${numberFormat.format(uploadRate.total.toFixed(0))} MB` - : 'Please wait...'} - - + {uploadQueue.map((item) => ( + + + + {item.file.name} + + + {item.status === 'queued' + ? 'Queued' + : item.status === 'processing' + ? 'Processing' + : item.status === 'done' + ? 'Done' + : item.status === 'error' + ? 'Failed' + : `${(100 * item.progress).toFixed(0)}%`} + + + + + + + ))} + + )} ) : ( - {progress < 1 ? `${(100 * progress).toFixed(0)}%` : '100%'} + {aggregateProgress < 1 ? `${(100 * aggregateProgress).toFixed(0)}%` : '100%'} )} @@ -1035,7 +1199,7 @@ const UploadCard = React.forwardRef(function UploadCard( - Upload Video + {isBatchPending ? `Upload ${pendingFiles.length} Videos` : 'Upload Video'} @@ -1090,19 +1254,7 @@ const UploadCard = React.forwardRef(function UploadCard( )} - - {pendingFile?.name} - + {pendingFilesCaption} {/* Form fields — right column */} diff --git a/app/client/src/components/utils/GlobalDragDropOverlay.js b/app/client/src/components/utils/GlobalDragDropOverlay.js index cec6b550..691c5761 100644 --- a/app/client/src/components/utils/GlobalDragDropOverlay.js +++ b/app/client/src/components/utils/GlobalDragDropOverlay.js @@ -76,9 +76,9 @@ export default function GlobalDragDropOverlay({ children }) { if (imageFiles.length > 0 && registeredImageCardRef.current) { registeredImageCardRef.current.openFiles(imageFiles) } else if (videoFiles.length > 0 && registeredCardRef.current) { - registeredCardRef.current.openFile(videoFiles[0]) + registeredCardRef.current.openFiles(videoFiles) } else if (files.length > 0 && registeredCardRef.current) { - registeredCardRef.current.openFile(files[0]) + registeredCardRef.current.openFiles(files) } } From b45e40f64b730432f467b74b9cb3661826932a8e Mon Sep 17 00:00:00 2001 From: Yahya Kousa Date: Sat, 18 Jul 2026 19:02:46 -0500 Subject: [PATCH 3/6] Allow editing per-file titles when batch uploading videos --- app/client/src/components/cards/UploadCard.js | 96 ++++++++++++------- 1 file changed, 62 insertions(+), 34 deletions(-) diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index d5be96e8..fe22a07c 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -106,6 +106,8 @@ const UploadCard = React.forwardRef(function UploadCard( // Pre-upload metadata dialog const [pendingFiles, setPendingFiles] = React.useState([]) + // Per-file editable display titles, parallel to pendingFiles (batch uploads only) + const [pendingTitles, setPendingTitles] = React.useState([]) const [dialogOpen, setDialogOpen] = React.useState(false) const [allGames, setAllGames] = React.useState([]) const [allTags, setAllTags] = React.useState([]) @@ -203,6 +205,7 @@ const UploadCard = React.forwardRef(function UploadCard( const openMetadataDialog = (files) => { setPendingFiles(files) + setPendingTitles(files.map((f) => f.name.replace(/\.[^/.]+$/, ''))) setSelectedGame(null) setSelectedTags([]) setTagInput('') @@ -279,8 +282,8 @@ const UploadCard = React.forwardRef(function UploadCard( const items = pendingFiles.map((file, idx) => ({ id: `${Date.now()}-${idx}-${file.name}`, file, - // A custom title only makes sense for a single file — batches keep their filenames - metadata: { ...metadata, title: !isBatch ? titleInput.trim() || null : null }, + // Single file uses the big inline title; batches use the per-file title inputs + metadata: { ...metadata, title: (isBatch ? pendingTitles[idx]?.trim() : titleInput.trim()) || null }, progress: 0, rate: null, status: 'queued', @@ -631,39 +634,64 @@ const UploadCard = React.forwardRef(function UploadCard( ) + const updatePendingTitle = (idx, value) => { + setPendingTitles((prev) => { + const next = [...prev] + next[idx] = value + return next + }) + } + const pendingFilesCaption = isBatchPending ? ( - - {pendingFiles.map((f, idx) => ( - - {f.name} - - ))} + + Titles + + {pendingFiles.map((f, idx) => ( + updatePendingTitle(idx, e.target.value)} + maxLength={200} + title={f.name} + sx={{ + width: '100%', + boxSizing: 'border-box', + background: '#FFFFFF0D', + border: '1px solid #FFFFFF14', + borderRadius: '6px', + outline: 'none', + color: 'white', + fontSize: 12.5, + lineHeight: 1.4, + padding: '5px 8px', + fontFamily: 'inherit', + transition: 'border-color 0.15s, background 0.15s', + '&::placeholder': { color: '#FFFFFF4D' }, + '&:focus': { borderColor: '#2684FF80', background: '#FFFFFF14' }, + }} + /> + ))} + ) : ( Date: Sun, 19 Jul 2026 19:57:50 -0500 Subject: [PATCH 4/6] Support shift-click range selection when editing videos --- .../src/components/cards/CompactVideoCard.js | 9 +++++-- app/client/src/views/Dashboard.js | 24 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/client/src/components/cards/CompactVideoCard.js b/app/client/src/components/cards/CompactVideoCard.js index 79662010..e3e04e56 100644 --- a/app/client/src/components/cards/CompactVideoCard.js +++ b/app/client/src/components/cards/CompactVideoCard.js @@ -302,6 +302,11 @@ const CompactVideoCard = ({ } const handleMouseDown = (e) => { + if (editMode && e.shiftKey) { + // Keep shift-click range selection from extending the native text selection + e.preventDefault() + return + } if (e.button === 1) { window.open(`${PURL}${video.video_id}`, '_blank') } @@ -480,9 +485,9 @@ const CompactVideoCard = ({ /> { + onClick={(e) => { if (editMode) { - onSelect?.(video.video_id) + onSelect?.(video.video_id, e.shiftKey) return } if (isLocked) { diff --git a/app/client/src/views/Dashboard.js b/app/client/src/views/Dashboard.js index da1013b7..3deed6ab 100644 --- a/app/client/src/views/Dashboard.js +++ b/app/client/src/views/Dashboard.js @@ -59,6 +59,7 @@ const Dashboard = ({ // Edit mode state const [editMode, setEditMode] = React.useState(false) const [selectedVideos, setSelectedVideos] = React.useState(new Set()) + const lastSelectedIdRef = React.useRef(null) const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const [linkGameDialogOpen, setLinkGameDialogOpen] = React.useState(false) const [allGames, setAllGames] = React.useState([]) @@ -229,6 +230,7 @@ const Dashboard = ({ if (editMode) { setSelectedVideos(new Set()) } + lastSelectedIdRef.current = null } const allSelected = sortedVideos.length > 0 && selectedVideos.size === sortedVideos.length @@ -241,13 +243,33 @@ const Dashboard = ({ } } - const handleVideoSelect = (videoId) => { + const handleVideoSelect = (videoId, shiftKey = false) => { const newSelected = new Set(selectedVideos) + const anchorId = lastSelectedIdRef.current + if (shiftKey && anchorId !== null && anchorId !== videoId) { + const ids = sortedVideos.map((v) => v.video_id) + const anchorIndex = ids.indexOf(anchorId) + const clickedIndex = ids.indexOf(videoId) + if (anchorIndex !== -1 && clickedIndex !== -1) { + // Apply the anchor's state to the whole range, so shift-click extends + // a selection or a deselection depending on the last action + const selecting = newSelected.has(anchorId) + const [start, end] = anchorIndex < clickedIndex ? [anchorIndex, clickedIndex] : [clickedIndex, anchorIndex] + for (let i = start; i <= end; i++) { + if (selecting) newSelected.add(ids[i]) + else newSelected.delete(ids[i]) + } + lastSelectedIdRef.current = videoId + setSelectedVideos(newSelected) + return + } + } if (newSelected.has(videoId)) { newSelected.delete(videoId) } else { newSelected.add(videoId) } + lastSelectedIdRef.current = videoId setSelectedVideos(newSelected) } From 42e14010b2721c5c277d453807b5acc8b3b8a95c Mon Sep 17 00:00:00 2001 From: Yahya Kousa Date: Mon, 20 Jul 2026 00:27:58 -0500 Subject: [PATCH 5/6] Address review feedback, count failed uploads in aggregate progress, remove dead ref --- app/client/src/components/cards/UploadCard.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index fe22a07c..9e87f5e1 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -128,7 +128,6 @@ const UploadCard = React.forwardRef(function UploadCard( const [previewPlayable, setPreviewPlayable] = React.useState(false) const [availableFolders, setAvailableFolders] = React.useState([]) const [selectedFolder, setSelectedFolder] = React.useState('') - const imageThumbnailUrlRef = React.useRef(null) const previewUrlRef = React.useRef(null) const intakeFiles = (files) => { @@ -292,10 +291,6 @@ const UploadCard = React.forwardRef(function UploadCard( setPendingFiles([]) setUploadQueue((prev) => [...prev, ...items]) if (isBatch) clearPreviewUrl() - if (imageThumbnailUrlRef.current) { - URL.revokeObjectURL(imageThumbnailUrlRef.current) - imageThumbnailUrlRef.current = null - } } const handleDialogCancel = () => { @@ -311,10 +306,6 @@ const UploadCard = React.forwardRef(function UploadCard( setEditingTitle(false) setThumbnail(null) setThumbnailReady(false) - if (imageThumbnailUrlRef.current) { - URL.revokeObjectURL(imageThumbnailUrlRef.current) - imageThumbnailUrlRef.current = null - } clearPreviewUrl() } @@ -539,7 +530,9 @@ const UploadCard = React.forwardRef(function UploadCard( const isUploading = uploadQueue.length > 0 const totalQueueBytes = uploadQueue.reduce((sum, i) => sum + i.file.size, 0) const loadedQueueBytes = uploadQueue.reduce( - (sum, i) => sum + (i.status === 'done' || i.status === 'processing' ? i.file.size : i.progress * i.file.size), + // Anything past the uploading stage (processing, done, or failed) counts as fully transferred + (sum, i) => + sum + (i.status === 'queued' || i.status === 'uploading' ? i.progress * i.file.size : i.file.size), 0, ) const aggregateProgress = totalQueueBytes > 0 ? Math.min(loadedQueueBytes / totalQueueBytes, 1) : 0 From 2abc7ae46b7d6efe967fcab57f04ecfa2d31391c Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Sun, 26 Jul 2026 09:05:39 -0600 Subject: [PATCH 6/6] bump version --- app/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/client/package.json b/app/client/package.json index 81c900f2..7684fa9a 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.7.3", + "version": "1.7.4", "private": true, "dependencies": { "@emotion/react": "^11.9.0",