feat: upload course materials to bunny storage
This commit is contained in:
@@ -83,6 +83,10 @@ const CourseModal: React.FC<{
|
||||
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
||||
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
||||
const [isCoverUploading, setIsCoverUploading] = useState(false);
|
||||
const attachmentFileRef = useRef<HTMLInputElement>(null);
|
||||
const [assetConfigured, setAssetConfigured] = useState<boolean | null>(null);
|
||||
const [isAssetUploading, setIsAssetUploading] = useState(false);
|
||||
const [assetUploadMessage, setAssetUploadMessage] = useState('');
|
||||
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
||||
|
||||
// Lesson Management
|
||||
@@ -102,11 +106,7 @@ const CourseModal: React.FC<{
|
||||
const [newTip, setNewTip] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
|
||||
// New Attachment State
|
||||
const [newAttName, setNewAttName] = useState('');
|
||||
const [newAttType, setNewAttType] = useState<AttachmentType>('pdf');
|
||||
const [newAttUrl, setNewAttUrl] = useState('');
|
||||
const [newAttSize, setNewAttSize] = useState('');
|
||||
// Optional context saved alongside each uploaded material.
|
||||
const [newAttDesc, setNewAttDesc] = useState('');
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -151,12 +151,17 @@ const CourseModal: React.FC<{
|
||||
setBunnyUploadState('idle');
|
||||
setBunnyUploadMessage('');
|
||||
setCoverUploadMessage('');
|
||||
setAssetUploadMessage('');
|
||||
setNewAttDesc('');
|
||||
instructorApi.bunnyConfiguration()
|
||||
.then((response) => setBunnyConfigured(response.data.configured))
|
||||
.catch(() => setBunnyConfigured(false));
|
||||
instructorApi.coverConfiguration()
|
||||
.then((response) => setCoverConfigured(response.data.configured))
|
||||
.catch(() => setCoverConfigured(false));
|
||||
instructorApi.assetConfiguration()
|
||||
.then((response) => setAssetConfigured(response.data.configured))
|
||||
.catch(() => setAssetConfigured(false));
|
||||
courseApi.categories()
|
||||
.then((response) => {
|
||||
setCategories(response.data);
|
||||
@@ -391,36 +396,41 @@ const CourseModal: React.FC<{
|
||||
setTips(tips.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleAddAttachment = () => {
|
||||
if (!newAttName.trim()) {
|
||||
alert("Informe o nome do arquivo para download.");
|
||||
return;
|
||||
}
|
||||
if (!newAttUrl.trim()) {
|
||||
alert('Informe a URL do material.');
|
||||
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (!assetConfigured) {
|
||||
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAssetUploading(true);
|
||||
setAssetUploadMessage(`Enviando ${file.name}...`);
|
||||
try {
|
||||
new URL(newAttUrl.trim());
|
||||
} catch {
|
||||
alert('Informe uma URL válida para o material.');
|
||||
return;
|
||||
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';
|
||||
setAttachments((current) => [...current, {
|
||||
id: `asset-${response.data.key}`,
|
||||
name: file.name,
|
||||
type,
|
||||
url: response.data.assetUrl,
|
||||
sizeBytes: response.data.sizeBytes,
|
||||
description: newAttDesc.trim(),
|
||||
}]);
|
||||
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);
|
||||
}
|
||||
|
||||
const att: Attachment = {
|
||||
id: `att-${Date.now()}`,
|
||||
name: newAttName.trim(),
|
||||
type: newAttType,
|
||||
url: newAttUrl.trim(),
|
||||
size: newAttSize.trim() || undefined,
|
||||
description: newAttDesc.trim()
|
||||
};
|
||||
|
||||
setAttachments([...attachments, att]);
|
||||
setNewAttName('');
|
||||
setNewAttSize('');
|
||||
setNewAttUrl('');
|
||||
setNewAttDesc('');
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
@@ -486,7 +496,7 @@ const CourseModal: React.FC<{
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
|
||||
<h2 className="mt-1 text-2xl font-extrabold text-white">{editorStep === 1 ? 'Informações do curso' : editorStep === 2 ? 'Estruture as aulas' : 'Materiais complementares'}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Inclua os arquivos e links de apoio.'}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Envie os arquivos de apoio para seus alunos.'}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="shrink-0 rounded-full bg-white/5 p-2.5 text-gray-300 hover:bg-white/10 hover:text-white" aria-label="Fechar editor"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
@@ -667,61 +677,37 @@ const CourseModal: React.FC<{
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Add attachment inputs */}
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttName}
|
||||
onChange={(e) => setNewAttName(e.target.value)}
|
||||
placeholder="Ex.: Planilha de acompanhamento de campanhas"
|
||||
className="col-span-2 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<select
|
||||
value={newAttType}
|
||||
onChange={(e) => setNewAttType(e.target.value as AttachmentType)}
|
||||
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none"
|
||||
<input
|
||||
ref={attachmentFileRef}
|
||||
type="file"
|
||||
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 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"
|
||||
disabled={!assetConfigured || isAssetUploading}
|
||||
onClick={() => attachmentFileRef.current?.click()}
|
||||
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="spreadsheet">Planilha</option>
|
||||
<option value="zip">Pacote ZIP</option>
|
||||
<option value="doc">Documento</option>
|
||||
<option value="link">Link</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={newAttUrl}
|
||||
onChange={(e) => setNewAttUrl(e.target.value)}
|
||||
placeholder="URL do material (https://...)"
|
||||
className="min-w-0 flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttSize}
|
||||
onChange={(e) => setNewAttSize(e.target.value)}
|
||||
placeholder="Tamanho (opcional)"
|
||||
className="w-28 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newAttDesc}
|
||||
onChange={(e) => setNewAttDesc(e.target.value)}
|
||||
placeholder="Explique como este material apoia o aprendizado."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAttachment}
|
||||
className="bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg text-white text-xs font-bold shrink-0 transition-colors"
|
||||
>
|
||||
Adicionar
|
||||
{isAssetUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
|
||||
{isAssetUploading ? 'Enviando...' : 'Enviar arquivo'}
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* List of Attachments */}
|
||||
@@ -731,7 +717,7 @@ const CourseModal: React.FC<{
|
||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||
{getAttachmentIcon(att.type)}
|
||||
<span className="text-gray-200 font-medium truncate">{att.name}</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">({att.size || '1 MB'})</span>
|
||||
{att.size && <span className="text-[10px] text-gray-500 font-mono">({att.size})</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user