fix: inline video upload in artefacts — drop modal, add drag-and-drop + progress bar
All checks were successful
Deploy / deploy (push) Successful in 12s
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:
@@ -73,10 +73,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
// File upload (for design/video)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
// Video modal (for video type with Drive link)
|
||||
const [showVideoModal, setShowVideoModal] = useState(false)
|
||||
const [videoMode, setVideoMode] = useState('upload') // 'upload' or 'drive'
|
||||
// Video inline (Drive link input)
|
||||
const [driveUrl, setDriveUrl] = useState('')
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
// Comments
|
||||
const [comments, setComments] = useState([])
|
||||
@@ -190,15 +190,20 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
const handleFileUpload = async (fileOrEvent) => {
|
||||
const file = fileOrEvent instanceof File ? fileOrEvent : fileOrEvent.target?.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
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'))
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
@@ -206,6 +211,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
toast.error(t('artefacts.uploadFailed'))
|
||||
} finally {
|
||||
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,
|
||||
})
|
||||
toast.success(t('artefacts.videoLinkAdded'))
|
||||
setShowVideoModal(false)
|
||||
setDriveUrl('')
|
||||
loadVersionData(selectedVersion.Id)
|
||||
} catch (err) {
|
||||
@@ -608,68 +622,87 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VIDEO TYPE: Files and Drive links */}
|
||||
{/* VIDEO TYPE: Files and Drive links — all inline */}
|
||||
{artefact.type === 'video' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.videosLabel')}</h4>
|
||||
|
||||
{versionData.attachments && versionData.attachments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{/* Existing attachments */}
|
||||
{versionData.attachments && versionData.attachments.length > 0 && (
|
||||
<div className="space-y-3 mb-4">
|
||||
{versionData.attachments.map(att => (
|
||||
<div key={att.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
{att.drive_url ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary">{t('artefacts.googleDriveVideo')}</span>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
src={getDriveEmbedUrl(att.drive_url)}
|
||||
className="w-full h-64 rounded border border-border"
|
||||
allow="autoplay"
|
||||
/>
|
||||
<iframe src={getDriveEmbedUrl(att.drive_url)} className="w-full h-64 rounded border border-border" allow="autoplay" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary truncate">{att.original_name}</span>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteAttId(att.Id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<button onClick={() => setConfirmDeleteAttId(att.Id)} className="text-red-600 hover:text-red-700">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<video
|
||||
src={att.url}
|
||||
controls
|
||||
className="w-full rounded border border-border"
|
||||
/>
|
||||
<video src={att.url} controls className="w-full rounded border border-border" />
|
||||
</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>
|
||||
@@ -896,84 +929,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</div>
|
||||
</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 */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteLangId}
|
||||
|
||||
@@ -849,6 +849,7 @@
|
||||
"artefacts.uploadFile": "رفع ملف",
|
||||
"artefacts.chooseVideoFile": "اختر ملف فيديو",
|
||||
"artefacts.videoFormats": "MP4، MOV، AVI، إلخ.",
|
||||
"artefacts.dropOrClickVideo": "اسحب فيديو هنا أو انقر للتصفح",
|
||||
"artefacts.googleDriveLink": "رابط Google Drive",
|
||||
"artefacts.googleDriveUrl": "رابط Google Drive",
|
||||
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
|
||||
|
||||
@@ -849,6 +849,7 @@
|
||||
"artefacts.uploadFile": "Upload File",
|
||||
"artefacts.chooseVideoFile": "Choose video file",
|
||||
"artefacts.videoFormats": "MP4, MOV, AVI, etc.",
|
||||
"artefacts.dropOrClickVideo": "Drop a video here or click to browse",
|
||||
"artefacts.googleDriveLink": "Google Drive Link",
|
||||
"artefacts.googleDriveUrl": "Google Drive URL",
|
||||
"artefacts.driveUrlPlaceholder": "https://drive.google.com/file/d/...",
|
||||
|
||||
@@ -79,11 +79,32 @@ export const api = {
|
||||
credentials: 'include',
|
||||
}).then(r => handleResponse(r, `DELETE ${path}`)),
|
||||
|
||||
upload: (path, formData) => fetch(`${API}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
}).then(r => handleResponse(r, `UPLOAD ${path}`)),
|
||||
upload: (path, formData, opts = {}) => {
|
||||
if (opts.onUploadProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user