feat: add batch material uploads and links
All checks were successful
CI / Validate frontend and API (push) Successful in 39s
CI / Build and publish Docker images (push) Successful in 17s

This commit is contained in:
Cauê Faleiros
2026-09-09 14:24:54 -03:00
parent 6981092589
commit a861644f6c

View File

@@ -87,6 +87,7 @@ const CourseModal: React.FC<{
const [assetConfigured, setAssetConfigured] = useState<boolean | null>(null);
const [isAssetUploading, setIsAssetUploading] = useState(false);
const [assetUploadMessage, setAssetUploadMessage] = useState('');
const [isDraggingAsset, setIsDraggingAsset] = useState(false);
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
// Lesson Management
@@ -106,8 +107,8 @@ const CourseModal: React.FC<{
const [newTip, setNewTip] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
// Optional context saved alongside each uploaded material.
const [newAttDesc, setNewAttDesc] = useState('');
const [newLinkName, setNewLinkName] = useState('');
const [newLinkUrl, setNewLinkUrl] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -152,7 +153,8 @@ const CourseModal: React.FC<{
setBunnyUploadMessage('');
setCoverUploadMessage('');
setAssetUploadMessage('');
setNewAttDesc('');
setNewLinkName('');
setNewLinkUrl('');
instructorApi.bunnyConfiguration()
.then((response) => setBunnyConfigured(response.data.configured))
.catch(() => setBunnyConfigured(false));
@@ -396,40 +398,74 @@ const CourseModal: React.FC<{
setTips(tips.filter((_, i) => i !== idx));
};
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
const handleAttachmentUploads = async (files: FileList | File[]) => {
const selectedFiles = Array.from(files);
if (!selectedFiles.length) return;
if (!assetConfigured) {
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
return;
}
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 {
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';
const parsedUrl = new URL(url);
if (!['https:', 'http:'].includes(parsedUrl.protocol)) throw new Error();
setAttachments((current) => [...current, {
id: `asset-${response.data.key}`,
name: file.name,
type,
url: response.data.assetUrl,
sizeBytes: response.data.sizeBytes,
description: newAttDesc.trim(),
id: `link-${Date.now()}`,
name: newLinkName.trim() || parsedUrl.hostname,
type: 'link',
url,
description: '',
}]);
setNewAttDesc('');
setAssetUploadMessage('Material enviado com sucesso.');
} catch (error) {
setAssetUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar o material.');
} finally {
setIsAssetUploading(false);
setNewLinkName('');
setNewLinkUrl('');
} catch {
alert('Informe um link HTTP ou HTTPS válido.');
}
};
@@ -680,15 +716,21 @@ const CourseModal: React.FC<{
<input
ref={attachmentFileRef}
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"
className="hidden"
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>
<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>
<button
type="button"
@@ -700,14 +742,25 @@ const CourseModal: React.FC<{
{isAssetUploading ? 'Enviando...' : 'Enviar arquivo'}
</button>
</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
type="text"
value={newAttDesc}
onChange={(event) => setNewAttDesc(event.target.value)}
placeholder="Descrição opcional: como este material apoia o aprendizado?"
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"
value={newLinkName}
onChange={(event) => setNewLinkName(event.target.value)}
placeholder="Título do link (opcional)"
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>
{/* List of Attachments */}