diff --git a/.env.example b/.env.example index 311e577..28232e2 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,7 @@ BUNNY_STORAGE_PASSWORD= BUNNY_STORAGE_ENDPOINT= BUNNY_STORAGE_CDN_HOST= BUNNY_COVER_MAX_UPLOAD_MB=10 +BUNNY_ASSET_MAX_UPLOAD_MB=100 # Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images. API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api diff --git a/PORTAINER.md b/PORTAINER.md index c046924..c87b904 100644 --- a/PORTAINER.md +++ b/PORTAINER.md @@ -38,9 +38,10 @@ BUNNY_STORAGE_PASSWORD=the-storage-zone-password BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net BUNNY_COVER_MAX_UPLOAD_MB=10 +BUNNY_ASSET_MAX_UPLOAD_MB=100 ``` -The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload only JPG, PNG, or WebP cover images; the API verifies the image bytes and uploads them under a generated `covers/` filename. The CDN URL is then saved on the course. +The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload JPG, PNG, or WebP cover images and PDF, DOCX, XLSX, CSV, or ZIP support materials; the API validates the file bytes and determines the material type and size automatically. Covers are stored under `covers/` and course materials under `materials/`. ### Bunny processing webhooks diff --git a/docker-compose.yml b/docker-compose.yml index c181bd0..aed5aa7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-} BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-} BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10} + BUNNY_ASSET_MAX_UPLOAD_MB: ${BUNNY_ASSET_MAX_UPLOAD_MB:-100} healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"] interval: 15s diff --git a/pages/ManageCourses.tsx b/pages/ManageCourses.tsx index 8289935..18e1f53 100644 --- a/pages/ManageCourses.tsx +++ b/pages/ManageCourses.tsx @@ -83,6 +83,10 @@ const CourseModal: React.FC<{ const [coverConfigured, setCoverConfigured] = useState(null); const [coverUploadMessage, setCoverUploadMessage] = useState(''); const [isCoverUploading, setIsCoverUploading] = useState(false); + const attachmentFileRef = useRef(null); + const [assetConfigured, setAssetConfigured] = useState(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([]); - // New Attachment State - const [newAttName, setNewAttName] = useState(''); - const [newAttType, setNewAttType] = useState('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) => { + 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<{

{initialData ? 'Editar curso' : 'Novo curso'}

{editorStep === 1 ? 'Informações do curso' : editorStep === 2 ? 'Estruture as aulas' : 'Materiais complementares'}

-

{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Inclua os arquivos e links de apoio.'}

+

{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.'}

@@ -667,61 +677,37 @@ const CourseModal: React.FC<{ - {/* Add attachment inputs */} -
-
- 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" - /> - void handleAttachmentUpload(event)} + /> +
+
+
+

Envie um arquivo de apoio

+

PDF, DOCX, XLSX, CSV ou ZIP. O formato e o tamanho são identificados automaticamente.

+
+
- -
- 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" - /> -
-
- 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" - /> - 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" - /> -
+ 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 &&

{assetUploadMessage}

}
{/* List of Attachments */} @@ -731,7 +717,7 @@ const CourseModal: React.FC<{
{getAttachmentIcon(att.type)} {att.name} - ({att.size || '1 MB'}) + {att.size && ({att.size})}