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)
|
// 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}
|
||||||
|
|||||||
@@ -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/...",
|
||||||
|
|||||||
@@ -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/...",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user