feat: add batch material uploads and links
This commit is contained in:
@@ -87,6 +87,7 @@ const CourseModal: React.FC<{
|
|||||||
const [assetConfigured, setAssetConfigured] = useState<boolean | null>(null);
|
const [assetConfigured, setAssetConfigured] = useState<boolean | null>(null);
|
||||||
const [isAssetUploading, setIsAssetUploading] = useState(false);
|
const [isAssetUploading, setIsAssetUploading] = useState(false);
|
||||||
const [assetUploadMessage, setAssetUploadMessage] = useState('');
|
const [assetUploadMessage, setAssetUploadMessage] = useState('');
|
||||||
|
const [isDraggingAsset, setIsDraggingAsset] = useState(false);
|
||||||
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
||||||
|
|
||||||
// Lesson Management
|
// Lesson Management
|
||||||
@@ -106,8 +107,8 @@ const CourseModal: React.FC<{
|
|||||||
const [newTip, setNewTip] = useState('');
|
const [newTip, setNewTip] = useState('');
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
|
||||||
// Optional context saved alongside each uploaded material.
|
const [newLinkName, setNewLinkName] = useState('');
|
||||||
const [newAttDesc, setNewAttDesc] = useState('');
|
const [newLinkUrl, setNewLinkUrl] = useState('');
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -152,7 +153,8 @@ const CourseModal: React.FC<{
|
|||||||
setBunnyUploadMessage('');
|
setBunnyUploadMessage('');
|
||||||
setCoverUploadMessage('');
|
setCoverUploadMessage('');
|
||||||
setAssetUploadMessage('');
|
setAssetUploadMessage('');
|
||||||
setNewAttDesc('');
|
setNewLinkName('');
|
||||||
|
setNewLinkUrl('');
|
||||||
instructorApi.bunnyConfiguration()
|
instructorApi.bunnyConfiguration()
|
||||||
.then((response) => setBunnyConfigured(response.data.configured))
|
.then((response) => setBunnyConfigured(response.data.configured))
|
||||||
.catch(() => setBunnyConfigured(false));
|
.catch(() => setBunnyConfigured(false));
|
||||||
@@ -396,40 +398,74 @@ const CourseModal: React.FC<{
|
|||||||
setTips(tips.filter((_, i) => i !== idx));
|
setTips(tips.filter((_, i) => i !== idx));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAttachmentUploads = async (files: FileList | File[]) => {
|
||||||
const file = event.target.files?.[0];
|
const selectedFiles = Array.from(files);
|
||||||
event.target.value = '';
|
if (!selectedFiles.length) return;
|
||||||
if (!file) return;
|
|
||||||
if (!assetConfigured) {
|
if (!assetConfigured) {
|
||||||
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
|
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsAssetUploading(true);
|
setIsAssetUploading(true);
|
||||||
setAssetUploadMessage(`Enviando ${file.name}...`);
|
setAssetUploadMessage(selectedFiles.length === 1 ? `Enviando ${selectedFiles[0].name}...` : `Enviando ${selectedFiles.length} arquivos...`);
|
||||||
|
const uploadedAttachments: Attachment[] = [];
|
||||||
|
const failedFiles: string[] = [];
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
try {
|
||||||
|
const response = await instructorApi.uploadAsset(file);
|
||||||
|
const type: AttachmentType = response.data.kind === 'spreadsheet'
|
||||||
|
? 'spreadsheet'
|
||||||
|
: response.data.kind === 'archive'
|
||||||
|
? 'zip'
|
||||||
|
: file.name.toLowerCase().endsWith('.pdf')
|
||||||
|
? 'pdf'
|
||||||
|
: 'doc';
|
||||||
|
uploadedAttachments.push({
|
||||||
|
id: `asset-${response.data.key}`,
|
||||||
|
name: file.name,
|
||||||
|
type,
|
||||||
|
url: response.data.assetUrl,
|
||||||
|
sizeBytes: response.data.sizeBytes,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
failedFiles.push(file.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uploadedAttachments.length) setAttachments((current) => [...current, ...uploadedAttachments]);
|
||||||
|
setAssetUploadMessage(failedFiles.length
|
||||||
|
? `${uploadedAttachments.length} enviado(s). Não foi possível enviar: ${failedFiles.join(', ')}.`
|
||||||
|
: `${uploadedAttachments.length} material(is) enviado(s) com sucesso.`);
|
||||||
|
setIsAssetUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = event.target.files;
|
||||||
|
event.target.value = '';
|
||||||
|
if (!files) return;
|
||||||
|
await handleAttachmentUploads(files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLink = () => {
|
||||||
|
const url = newLinkUrl.trim();
|
||||||
|
if (!url) {
|
||||||
|
alert('Informe o link do material.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await instructorApi.uploadAsset(file);
|
const parsedUrl = new URL(url);
|
||||||
const type: AttachmentType = response.data.kind === 'spreadsheet'
|
if (!['https:', 'http:'].includes(parsedUrl.protocol)) throw new Error();
|
||||||
? 'spreadsheet'
|
|
||||||
: response.data.kind === 'archive'
|
|
||||||
? 'zip'
|
|
||||||
: file.name.toLowerCase().endsWith('.pdf')
|
|
||||||
? 'pdf'
|
|
||||||
: 'doc';
|
|
||||||
setAttachments((current) => [...current, {
|
setAttachments((current) => [...current, {
|
||||||
id: `asset-${response.data.key}`,
|
id: `link-${Date.now()}`,
|
||||||
name: file.name,
|
name: newLinkName.trim() || parsedUrl.hostname,
|
||||||
type,
|
type: 'link',
|
||||||
url: response.data.assetUrl,
|
url,
|
||||||
sizeBytes: response.data.sizeBytes,
|
description: '',
|
||||||
description: newAttDesc.trim(),
|
|
||||||
}]);
|
}]);
|
||||||
setNewAttDesc('');
|
setNewLinkName('');
|
||||||
setAssetUploadMessage('Material enviado com sucesso.');
|
setNewLinkUrl('');
|
||||||
} catch (error) {
|
} catch {
|
||||||
setAssetUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar o material.');
|
alert('Informe um link HTTP ou HTTPS válido.');
|
||||||
} finally {
|
|
||||||
setIsAssetUploading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -680,15 +716,21 @@ const CourseModal: React.FC<{
|
|||||||
<input
|
<input
|
||||||
ref={attachmentFileRef}
|
ref={attachmentFileRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.zip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,application/zip"
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.zip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,application/zip"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(event) => void handleAttachmentUpload(event)}
|
onChange={(event) => void handleAttachmentUpload(event)}
|
||||||
/>
|
/>
|
||||||
<div className="rounded-xl border border-dashed border-white/15 bg-white/[0.02] p-4">
|
<div
|
||||||
|
onDragEnter={(event) => { event.preventDefault(); setIsDraggingAsset(true); }}
|
||||||
|
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; setIsDraggingAsset(true); }}
|
||||||
|
onDragLeave={(event) => { event.preventDefault(); setIsDraggingAsset(false); }}
|
||||||
|
onDrop={(event) => { event.preventDefault(); setIsDraggingAsset(false); void handleAttachmentUploads(event.dataTransfer.files); }}
|
||||||
|
className={`rounded-xl border border-dashed p-5 transition ${isDraggingAsset ? 'border-orange-400 bg-orange-500/10' : 'border-white/15 bg-white/[0.02]'}`}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-white">Envie um arquivo de apoio</p>
|
<p className="text-sm font-semibold text-white">Envie um arquivo de apoio</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">PDF, DOCX, XLSX, CSV ou ZIP. O formato e o tamanho são identificados automaticamente.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -700,14 +742,25 @@ const CourseModal: React.FC<{
|
|||||||
{isAssetUploading ? 'Enviando...' : 'Enviar arquivo'}
|
{isAssetUploading ? 'Enviando...' : 'Enviar arquivo'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{assetUploadMessage && <p className={`mt-2 text-xs ${assetUploadMessage.includes('sucesso') ? 'text-emerald-400' : assetUploadMessage.includes('Enviando') ? 'text-gray-400' : 'text-amber-300'}`}>{assetUploadMessage}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-white/5 pt-4 sm:flex-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newAttDesc}
|
value={newLinkName}
|
||||||
onChange={(event) => setNewAttDesc(event.target.value)}
|
onChange={(event) => setNewLinkName(event.target.value)}
|
||||||
placeholder="Descrição opcional: como este material apoia o aprendizado?"
|
placeholder="Título do link (opcional)"
|
||||||
className="mt-3 w-full rounded-lg border border-white/10 bg-black/20 px-3 py-2.5 text-xs text-white outline-none focus:border-orange-500/50"
|
className="min-w-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-xs text-white outline-none focus:border-orange-500/50 sm:w-52"
|
||||||
/>
|
/>
|
||||||
{assetUploadMessage && <p className={`mt-2 text-xs ${assetUploadMessage.includes('sucesso') ? 'text-emerald-400' : assetUploadMessage.includes('Enviando') ? 'text-gray-400' : 'text-amber-300'}`}>{assetUploadMessage}</p>}
|
<input
|
||||||
|
type="url"
|
||||||
|
value={newLinkUrl}
|
||||||
|
onChange={(event) => setNewLinkUrl(event.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2.5 text-xs text-white outline-none focus:border-orange-500/50"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleAddLink} className="rounded-lg border border-white/10 px-4 py-2.5 text-xs font-bold text-white transition hover:bg-white/10">Adicionar link</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List of Attachments */}
|
{/* List of Attachments */}
|
||||||
|
|||||||
Reference in New Issue
Block a user