fix: inline video upload in artefacts — drop modal, add drag-and-drop + progress bar
All checks were successful
Deploy / deploy (push) Successful in 12s

- Replace the two-step video modal with inline drag-and-drop zone + click-to-browse
- Add Google Drive URL input directly in the versions tab
- Add upload progress bar with percentage via XHR
- Support onUploadProgress in api.upload()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-11 11:34:11 +03:00
parent 51708267d3
commit 14751c42e4
4 changed files with 104 additions and 126 deletions

View File

@@ -73,10 +73,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
// File upload (for design/video) // File upload (for design/video)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
// Video modal (for video type with Drive link) // Video inline (Drive link input)
const [showVideoModal, setShowVideoModal] = useState(false)
const [videoMode, setVideoMode] = useState('upload') // 'upload' or 'drive'
const [driveUrl, setDriveUrl] = useState('') const [driveUrl, setDriveUrl] = useState('')
const [dragOver, setDragOver] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
// Comments // Comments
const [comments, setComments] = useState([]) const [comments, setComments] = useState([])
@@ -190,15 +190,20 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
} }
} }
const handleFileUpload = async (e) => { const handleFileUpload = async (fileOrEvent) => {
const file = e.target.files?.[0] const file = fileOrEvent instanceof File ? fileOrEvent : fileOrEvent.target?.files?.[0]
if (!file) return if (!file) return
setUploading(true) setUploading(true)
setUploadProgress(0)
try { try {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData) await api.upload(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/attachments`, formData, {
onUploadProgress: (e) => {
if (e.total) setUploadProgress(Math.round((e.loaded / e.total) * 100))
}
})
toast.success(t('artefacts.fileUploaded')) toast.success(t('artefacts.fileUploaded'))
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
@@ -206,6 +211,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
toast.error(t('artefacts.uploadFailed')) toast.error(t('artefacts.uploadFailed'))
} finally { } finally {
setUploading(false) setUploading(false)
setUploadProgress(0)
}
}
const handleVideoDrop = (e) => {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files?.[0]
if (file && file.type.startsWith('video/')) {
handleFileUpload(file)
} }
} }
@@ -221,7 +236,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
drive_url: driveUrl, drive_url: driveUrl,
}) })
toast.success(t('artefacts.videoLinkAdded')) toast.success(t('artefacts.videoLinkAdded'))
setShowVideoModal(false)
setDriveUrl('') setDriveUrl('')
loadVersionData(selectedVersion.Id) loadVersionData(selectedVersion.Id)
} catch (err) { } catch (err) {
@@ -608,68 +622,87 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
)} )}
{/* VIDEO TYPE: Files and Drive links */} {/* VIDEO TYPE: Files and Drive links — all inline */}
{artefact.type === 'video' && ( {artefact.type === 'video' && (
<div> <div>
<div className="flex items-center justify-between mb-3"> <h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.videosLabel')}</h4>
<button
onClick={() => setShowVideoModal(true)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
>
<Plus className="w-3 h-3" />
{t('artefacts.addVideoBtn')}
</button>
</div>
{versionData.attachments && versionData.attachments.length > 0 ? ( {/* Existing attachments */}
<div className="space-y-3"> {versionData.attachments && versionData.attachments.length > 0 && (
<div className="space-y-3 mb-4">
{versionData.attachments.map(att => ( {versionData.attachments.map(att => (
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border"> <div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
{att.drive_url ? ( {att.drive_url ? (
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span> <span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
<button <button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
<iframe <iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
src={getDriveEmbedUrl(att.drive_url)}
className="w-full h-64 rounded border border-border"
allow="autoplay"
/>
</div> </div>
) : ( ) : (
<div> <div>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span> <span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
<button <button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
onClick={() => setConfirmDeleteAttId(att.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
<video <video src={att.url} controls className="w-full rounded border border-border" />
src={att.url}
controls
className="w-full rounded border border-border"
/>
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<Film className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noVideos')}</p>
</div>
)} )}
{/* Drag-and-drop / click-to-upload zone */}
<label
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleVideoDrop}
>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
</>
) : (
<>
<Upload className="w-7 h-7 text-text-tertiary" />
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
</>
)}
<input type="file" className="hidden" accept="video/*" onChange={handleFileUpload} disabled={uploading} />
</label>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
<Globe className="w-4 h-4 text-text-tertiary shrink-0" />
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
onKeyDown={e => { if (e.key === 'Enter' && driveUrl.trim()) handleAddDriveVideo() }}
/>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-3 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shrink-0"
>
{t('artefacts.addLink')}
</button>
</div>
</div> </div>
)} )}
</div> </div>
@@ -896,84 +929,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
</Modal> </Modal>
{/* Video Modal */}
<Modal isOpen={showVideoModal} onClose={() => setShowVideoModal(false)} title={t('artefacts.addVideoTitle')} size="md">
<div className="space-y-4">
<div className="flex items-center gap-2 border-b border-border pb-3">
<button
onClick={() => setVideoMode('upload')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
videoMode === 'upload'
? 'bg-brand-primary text-white'
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
}`}
>
{t('artefacts.uploadFile')}
</button>
<button
onClick={() => setVideoMode('drive')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
videoMode === 'drive'
? 'bg-brand-primary text-white'
: 'bg-surface-secondary text-text-secondary hover:bg-surface-tertiary'
}`}
>
{t('artefacts.googleDriveLink')}
</button>
</div>
{videoMode === 'upload' ? (
<div>
<label className="flex flex-col items-center gap-3 px-6 py-8 border-2 border-dashed border-border rounded-lg hover:border-brand-primary/30 transition-colors cursor-pointer">
<Upload className="w-8 h-8 text-text-tertiary" />
<div className="text-center">
<span className="text-sm font-medium text-text-primary">
{uploading ? t('artefacts.uploading') : t('artefacts.chooseVideoFile')}
</span>
<p className="text-xs text-text-tertiary mt-1">{t('artefacts.videoFormats')}</p>
</div>
<input
type="file"
className="hidden"
accept="video/*"
onChange={handleFileUpload}
disabled={uploading}
/>
</label>
</div>
) : (
<div>
<label className="block text-sm font-medium text-text-primary mb-2">{t('artefacts.googleDriveUrl')}</label>
<input
type="text"
value={driveUrl}
onChange={e => setDriveUrl(e.target.value)}
placeholder="https://drive.google.com/file/d/..."
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<p className="text-xs text-text-tertiary mt-2">
{t('artefacts.publiclyAccessible')}
</p>
<div className="flex justify-end gap-3 mt-4">
<button
onClick={() => setShowVideoModal(false)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleAddDriveVideo}
disabled={uploading || !driveUrl.trim()}
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
>
{uploading ? t('artefacts.adding') : t('artefacts.addLink')}
</button>
</div>
</div>
)}
</div>
</Modal>
{/* Delete Language Confirmation */} {/* Delete Language Confirmation */}
<Modal <Modal
isOpen={!!confirmDeleteLangId} isOpen={!!confirmDeleteLangId}

View File

@@ -849,6 +849,7 @@
"artefacts.uploadFile": "رفع ملف", "artefacts.uploadFile": "رفع ملف",
"artefacts.chooseVideoFile": "اختر ملف فيديو", "artefacts.chooseVideoFile": "اختر ملف فيديو",
"artefacts.videoFormats": "MP4، MOV، AVI، إلخ.", "artefacts.videoFormats": "MP4، MOV، AVI، إلخ.",
"artefacts.dropOrClickVideo": "اسحب فيديو هنا أو انقر للتصفح",
"artefacts.googleDriveLink": "رابط Google Drive", "artefacts.googleDriveLink": "رابط Google Drive",
"artefacts.googleDriveUrl": "رابط Google Drive", "artefacts.googleDriveUrl": "رابط Google Drive",
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...", "artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",

View File

@@ -849,6 +849,7 @@
"artefacts.uploadFile": "Upload File", "artefacts.uploadFile": "Upload File",
"artefacts.chooseVideoFile": "Choose video file", "artefacts.chooseVideoFile": "Choose video file",
"artefacts.videoFormats": "MP4, MOV, AVI, etc.", "artefacts.videoFormats": "MP4, MOV, AVI, etc.",
"artefacts.dropOrClickVideo": "Drop a video here or click to browse",
"artefacts.googleDriveLink": "Google Drive Link", "artefacts.googleDriveLink": "Google Drive Link",
"artefacts.googleDriveUrl": "Google Drive URL", "artefacts.googleDriveUrl": "Google Drive URL",
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...", "artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",

View File

@@ -79,11 +79,32 @@ export const api = {
credentials: 'include', credentials: 'include',
}).then(r => handleResponse(r, `DELETE ${path}`)), }).then(r => handleResponse(r, `DELETE ${path}`)),
upload: (path, formData) => fetch(`${API}${path}`, { upload: (path, formData, opts = {}) => {
method: 'POST', if (opts.onUploadProgress) {
credentials: 'include', return new Promise((resolve, reject) => {
body: formData, const xhr = new XMLHttpRequest()
}).then(r => handleResponse(r, `UPLOAD ${path}`)), xhr.open('POST', `${API}${path}`)
xhr.withCredentials = true
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) opts.onUploadProgress({ loaded: e.loaded, total: e.total })
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(normalize(JSON.parse(xhr.responseText))) } catch { resolve(xhr.responseText) }
} else {
reject(new Error(`Upload failed: ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('Upload failed'))
xhr.send(formData)
})
}
return fetch(`${API}${path}`, {
method: 'POST',
credentials: 'include',
body: formData,
}).then(r => handleResponse(r, `UPLOAD ${path}`))
},
}; };
// Brand color palette — dynamically assigned from a rotating palette // Brand color palette — dynamically assigned from a rotating palette