diff --git a/components/MaterialCard.tsx b/components/MaterialCard.tsx index 814c222..768eaed 100644 --- a/components/MaterialCard.tsx +++ b/components/MaterialCard.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react'; import { Attachment, AttachmentType } from '../types'; import { useToast } from '../context/ToastContext'; +import { downloadCourseAsset } from '../services/api'; interface MaterialCardProps { attachment: Attachment; @@ -67,32 +68,29 @@ export const MaterialCard: React.FC = ({ const { showDownloadToast, showToast } = useToast(); const badge = getAttachmentBadge(attachment.type); - const handleAction = (e: React.MouseEvent) => { - setDownloaded(true); + const handleAction = async () => { if (attachment.type === 'link') { showToast(`Acessando link "${attachment.name}"`, 'info'); - } else { - showDownloadToast(attachment.name, attachment.size); + window.open(attachment.url, '_blank', 'noopener,noreferrer'); + return; } - if (onDownload) { - onDownload(attachment); - } - // If it has a real URL that is not '#', let the default anchor do its work - if (!attachment.url || attachment.url === '#') { - e.preventDefault(); - // Trigger a synthetic download notification reset - setTimeout(() => setDownloaded(false), 3000); + try { + await downloadCourseAsset(attachment.id, attachment.name); + setDownloaded(true); + showDownloadToast(attachment.name, attachment.size); + onDownload?.(attachment); + window.setTimeout(() => setDownloaded(false), 3000); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Não foi possível baixar este material.', 'error'); } }; if (compact) { return ( - void handleAction()} className="flex items-center justify-between p-3.5 rounded-xl bg-zinc-900/90 hover:bg-zinc-800/90 transition-all duration-200 group shadow-sm" >
@@ -119,8 +117,7 @@ export const MaterialCard: React.FC = ({ Baixado ) : ( - + )}
-
+ ); } @@ -169,11 +166,9 @@ export const MaterialCard: React.FC = ({ {attachment.size || 'Disponível'} - void handleAction()} className="inline-flex items-center gap-2 text-xs font-bold text-black bg-white hover:bg-orange-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 active:scale-95 shadow-md" > {attachment.type === 'link' ? ( @@ -187,7 +182,7 @@ export const MaterialCard: React.FC = ({ Baixar Arquivo )} - + ); diff --git a/pages/MaterialsPage.tsx b/pages/MaterialsPage.tsx index 7c8511f..14b9a69 100644 --- a/pages/MaterialsPage.tsx +++ b/pages/MaterialsPage.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, BookOpen, Download, ExternalLink, Filter, FolderOpen, Layout import { useNavigate } from 'react-router-dom'; import { Attachment, AttachmentType, Course } from '../types'; import { getCourses } from '../services/db'; +import { downloadCourseAsset } from '../services/api'; import { getAttachmentBadge, getAttachmentIcon } from '../components/MaterialCard'; import { useToast } from '../context/ToastContext'; @@ -54,7 +55,18 @@ export const MaterialsPage: React.FC = ({ onPlayCourse }) => }), [materials, query, selectedTrail, selectedCourse, selectedType, sort]); const hasFilters = Boolean(query) || selectedTrail !== 'all' || selectedCourse !== 'all' || selectedType !== 'all'; const clearFilters = () => { setQuery(''); setSelectedTrail('all'); setSelectedCourse('all'); setSelectedType('all'); }; - const openMaterial = (material: Material) => material.attachment.type === 'link' ? showToast(`Abrindo “${material.attachment.name}”.`, 'info') : showDownloadToast(material.attachment.name, material.attachment.size); + const openMaterial = async (material: Material) => { + if (material.attachment.type === 'link') { + showToast(`Abrindo “${material.attachment.name}”.`, 'info'); + return; + } + try { + await downloadCourseAsset(material.attachment.id, material.attachment.name); + showDownloadToast(material.attachment.name, material.attachment.size); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Não foi possível baixar este material.', 'error'); + } + }; return

Central de recursos

Biblioteca de materiais

Materiais disponibilizados pelos instrutores nos cursos e aulas da plataforma.

@@ -68,5 +80,5 @@ export const MaterialsPage: React.FC = ({ onPlayCourse }) => const FilterSelect: React.FC<{ value: string; label: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }> = ({ value, label, options, onChange }) => ; const EmptyState: React.FC<{ hasMaterials: boolean; onClear: () => void }> = ({ hasMaterials, onClear }) =>

{hasMaterials ? 'Nenhum material corresponde aos filtros' : 'A biblioteca ainda não tem materiais'}

{hasMaterials ? 'Tente alterar a busca ou os filtros selecionados.' : 'Quando um instrutor adicionar materiais a um curso ou aula, eles aparecerão aqui.'}

{hasMaterials && }
; const MaterialMeta: React.FC<{ material: Material }> = ({ material }) =>

{material.course.category}

{material.course.title}

{material.lessonTitle &&

Aula: {material.lessonTitle}

}
; -const MaterialGridCard: React.FC<{ material: Material; onOpen: (material: Material) => void; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const badge = getAttachmentBadge(attachment.type); const isLink = attachment.type === 'link'; return
{getAttachmentIcon(attachment.type)}
{badge.label}

{attachment.name}

{attachment.description &&

{attachment.description}

}
{attachment.size || 'Tamanho não informado'}{formatDate(attachment.createdAt)}
onOpen(material)} className="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-orange-500 px-3 py-2 text-xs font-semibold text-white transition hover:bg-orange-600">{isLink ? : }{isLink ? 'Abrir' : 'Baixar'}
; }; -const MaterialListRow: React.FC<{ material: Material; onOpen: (material: Material) => void; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const isLink = attachment.type === 'link'; return ; }; +const MaterialGridCard: React.FC<{ material: Material; onOpen: (material: Material) => void | Promise; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const badge = getAttachmentBadge(attachment.type); const isLink = attachment.type === 'link'; return
{getAttachmentIcon(attachment.type)}
{badge.label}

{attachment.name}

{attachment.description &&

{attachment.description}

}
{attachment.size || 'Tamanho não informado'}{formatDate(attachment.createdAt)}
{isLink ? void onOpen(material)} className="ml-auto inline-flex items-center gap-1.5 rounded-lg bg-orange-500 px-3 py-2 text-xs font-semibold text-white transition hover:bg-orange-600">Abrir : }
; }; +const MaterialListRow: React.FC<{ material: Material; onOpen: (material: Material) => void | Promise; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const isLink = attachment.type === 'link'; return
{getAttachmentIcon(attachment.type)}

{attachment.name}

{attachment.size || 'Tamanho não informado'}

{formatDate(attachment.createdAt)}

{isLink ? void onOpen(material)} className="inline-flex items-center gap-1.5 rounded-lg bg-orange-500 px-3 py-2 text-xs font-semibold text-white transition hover:bg-orange-600">Abrir : }
; }; diff --git a/server/src/providers/bunny-storage.ts b/server/src/providers/bunny-storage.ts index 02f7a41..930df04 100644 --- a/server/src/providers/bunny-storage.ts +++ b/server/src/providers/bunny-storage.ts @@ -73,6 +73,29 @@ export async function uploadBunnyAsset(asset: StoredAsset) { return { key, assetUrl: `${cdnHost}/${key}` }; } +export function bunnyAssetKeyFromUrl(url: string) { + if (!config.BUNNY_STORAGE_CDN_HOST) return null; + const cdnHost = config.BUNNY_STORAGE_CDN_HOST.replace(/\/$/, ''); + if (!url.startsWith(`${cdnHost}/materials/`)) return null; + const key = url.slice(cdnHost.length + 1); + return /^materials\/[0-9a-f-]{36}\.(?:pdf|docx|doc|xlsx|xls|csv|zip)$/i.test(key) ? key : null; +} + +export async function downloadBunnyAsset(key: string) { + const { zone, password, endpoint } = bunnyStorageConfiguration(); + const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, { + headers: { AccessKey: password }, + }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new BunnyStorageRequestError(`Bunny Storage download failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`); + } + return { + body: Buffer.from(await response.arrayBuffer()), + contentType: response.headers.get('content-type') || 'application/octet-stream', + }; +} + export async function downloadBunnyCover(filename: string) { const { zone, password, endpoint } = bunnyStorageConfiguration(); const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, { diff --git a/server/src/routes/courses.ts b/server/src/routes/courses.ts index 657bde1..7e07e05 100644 --- a/server/src/routes/courses.ts +++ b/server/src/routes/courses.ts @@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { pool } from '../db/pool.js'; import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js'; -import { BunnyStorageConfigurationError, BunnyStorageRequestError, downloadBunnyCover } from '../providers/bunny-storage.js'; +import { BunnyStorageConfigurationError, BunnyStorageRequestError, bunnyAssetKeyFromUrl, downloadBunnyAsset, downloadBunnyCover } from '../providers/bunny-storage.js'; const paramsSchema = z.object({ courseId: z.string().uuid(), @@ -14,6 +14,7 @@ const playbackParamsSchema = z.object({ const coverParamsSchema = z.object({ filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i), }); +const assetParamsSchema = z.object({ assetId: z.string().uuid() }); export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => ` select @@ -133,6 +134,36 @@ export const courseRoutes: FastifyPluginAsync = async (app) => { } }); + app.get('/assets/:assetId/download', { preHandler: app.authenticate }, async (request, reply) => { + const { assetId } = assetParamsSchema.parse(request.params); + const result = await pool.query<{ name: string; url: string; status: string; instructor_id: string }>( + `select a.name, a.url, c.status, c.instructor_id + from assets a + join courses c on c.id = coalesce(a.course_id, (select lesson.course_id from lessons lesson where lesson.id = a.lesson_id)) + where a.id = $1`, + [assetId], + ); + const asset = result.rows[0]; + if (!asset || (asset.status !== 'published' && request.user.role !== 'admin' && asset.instructor_id !== request.user.id)) { + return reply.code(404).send({ error: 'Material não encontrado.' }); + } + const key = bunnyAssetKeyFromUrl(asset.url); + if (!key) return reply.redirect(asset.url); + try { + const file = await downloadBunnyAsset(key); + const filename = asset.name.replace(/[\\/\r\n"]/g, '_'); + reply.header('Content-Type', file.contentType); + reply.header('Content-Disposition', `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`); + reply.header('Cache-Control', 'private, no-store'); + return reply.send(file.body); + } catch (error) { + if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) { + return reply.code(404).send({ error: 'Material não encontrado.' }); + } + throw error; + } + }); + app.get('/', async (request) => { const authenticated = await hasSession(request); const result = await pool.query( diff --git a/services/api.ts b/services/api.ts index 8c22c94..348d1b7 100644 --- a/services/api.ts +++ b/services/api.ts @@ -126,6 +126,26 @@ export function clearSession() { localStorage.removeItem(SESSION_KEY); } +export async function downloadCourseAsset(assetId: string, filename: string) { + const session = getSession(); + const headers = new Headers(); + if (session) headers.set('Authorization', `Bearer ${session.token}`); + const response = await fetch(`${API_URL}/courses/assets/${assetId}/download`, { headers }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new ApiError(body.error || 'Não foi possível baixar este material.', response.status); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + export async function apiRequest(path: string, options: RequestInit = {}): Promise { const session = getSession(); const headers = new Headers(options.headers);