fix: download bunny materials through academy
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Attachment, AttachmentType } from '../types';
|
import { Attachment, AttachmentType } from '../types';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
|
import { downloadCourseAsset } from '../services/api';
|
||||||
|
|
||||||
interface MaterialCardProps {
|
interface MaterialCardProps {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
@@ -67,32 +68,29 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
|||||||
const { showDownloadToast, showToast } = useToast();
|
const { showDownloadToast, showToast } = useToast();
|
||||||
const badge = getAttachmentBadge(attachment.type);
|
const badge = getAttachmentBadge(attachment.type);
|
||||||
|
|
||||||
const handleAction = (e: React.MouseEvent) => {
|
const handleAction = async () => {
|
||||||
setDownloaded(true);
|
|
||||||
if (attachment.type === 'link') {
|
if (attachment.type === 'link') {
|
||||||
showToast(`Acessando link "${attachment.name}"`, 'info');
|
showToast(`Acessando link "${attachment.name}"`, 'info');
|
||||||
} else {
|
window.open(attachment.url, '_blank', 'noopener,noreferrer');
|
||||||
showDownloadToast(attachment.name, attachment.size);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onDownload) {
|
try {
|
||||||
onDownload(attachment);
|
await downloadCourseAsset(attachment.id, attachment.name);
|
||||||
}
|
setDownloaded(true);
|
||||||
// If it has a real URL that is not '#', let the default anchor do its work
|
showDownloadToast(attachment.name, attachment.size);
|
||||||
if (!attachment.url || attachment.url === '#') {
|
onDownload?.(attachment);
|
||||||
e.preventDefault();
|
window.setTimeout(() => setDownloaded(false), 3000);
|
||||||
// Trigger a synthetic download notification reset
|
} catch (error) {
|
||||||
setTimeout(() => setDownloaded(false), 3000);
|
showToast(error instanceof Error ? error.message : 'Não foi possível baixar este material.', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<a
|
<button
|
||||||
href={attachment.url || '#'}
|
type="button"
|
||||||
target={attachment.type === 'link' ? '_blank' : '_self'}
|
onClick={() => void handleAction()}
|
||||||
rel="noreferrer"
|
|
||||||
onClick={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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3.5 min-w-0 pr-3">
|
<div className="flex items-center gap-3.5 min-w-0 pr-3">
|
||||||
@@ -119,8 +117,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
|||||||
Baixado
|
Baixado
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<span
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1.5 text-xs font-semibold bg-white/10 hover:bg-orange-500 hover:text-white text-gray-200 px-3.5 py-1.5 rounded-lg transition-all active:scale-95"
|
className="flex items-center gap-1.5 text-xs font-semibold bg-white/10 hover:bg-orange-500 hover:text-white text-gray-200 px-3.5 py-1.5 rounded-lg transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
{attachment.type === 'link' ? (
|
{attachment.type === 'link' ? (
|
||||||
@@ -134,10 +131,10 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
|||||||
Baixar
|
Baixar
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +166,9 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
|||||||
{attachment.size || 'Disponível'}
|
{attachment.size || 'Disponível'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
href={attachment.url || '#'}
|
type="button"
|
||||||
target={attachment.type === 'link' ? '_blank' : '_self'}
|
onClick={() => void handleAction()}
|
||||||
rel="noreferrer"
|
|
||||||
onClick={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"
|
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' ? (
|
{attachment.type === 'link' ? (
|
||||||
@@ -187,7 +182,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
|||||||
Baixar Arquivo
|
Baixar Arquivo
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ArrowLeft, BookOpen, Download, ExternalLink, Filter, FolderOpen, Layout
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Attachment, AttachmentType, Course } from '../types';
|
import { Attachment, AttachmentType, Course } from '../types';
|
||||||
import { getCourses } from '../services/db';
|
import { getCourses } from '../services/db';
|
||||||
|
import { downloadCourseAsset } from '../services/api';
|
||||||
import { getAttachmentBadge, getAttachmentIcon } from '../components/MaterialCard';
|
import { getAttachmentBadge, getAttachmentIcon } from '../components/MaterialCard';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
|
|
||||||
@@ -54,7 +55,18 @@ export const MaterialsPage: React.FC<MaterialsPageProps> = ({ onPlayCourse }) =>
|
|||||||
}), [materials, query, selectedTrail, selectedCourse, selectedType, sort]);
|
}), [materials, query, selectedTrail, selectedCourse, selectedType, sort]);
|
||||||
const hasFilters = Boolean(query) || selectedTrail !== 'all' || selectedCourse !== 'all' || selectedType !== 'all';
|
const hasFilters = Boolean(query) || selectedTrail !== 'all' || selectedCourse !== 'all' || selectedType !== 'all';
|
||||||
const clearFilters = () => { setQuery(''); setSelectedTrail('all'); setSelectedCourse('all'); setSelectedType('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 <main className="min-h-screen bg-black pb-20 pt-24 text-white"><div className="mx-auto max-w-7xl px-5 sm:px-8">
|
return <main className="min-h-screen bg-black pb-20 pt-24 text-white"><div className="mx-auto max-w-7xl px-5 sm:px-8">
|
||||||
<header className="border-b border-white/10 pb-7"><button onClick={() => navigate('/')} className="mb-5 inline-flex items-center gap-1.5 text-sm text-gray-400 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Início</button><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Central de recursos</p><h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">Biblioteca de materiais</h1><p className="mt-3 max-w-2xl text-sm leading-6 text-gray-400">Materiais disponibilizados pelos instrutores nos cursos e aulas da plataforma.</p></div></header>
|
<header className="border-b border-white/10 pb-7"><button onClick={() => navigate('/')} className="mb-5 inline-flex items-center gap-1.5 text-sm text-gray-400 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Início</button><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Central de recursos</p><h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">Biblioteca de materiais</h1><p className="mt-3 max-w-2xl text-sm leading-6 text-gray-400">Materiais disponibilizados pelos instrutores nos cursos e aulas da plataforma.</p></div></header>
|
||||||
@@ -68,5 +80,5 @@ export const MaterialsPage: React.FC<MaterialsPageProps> = ({ onPlayCourse }) =>
|
|||||||
const FilterSelect: React.FC<{ value: string; label: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }> = ({ value, label, options, onChange }) => <label className="block"><span className="sr-only">{label}</span><select value={value} onChange={(event) => onChange(event.target.value)} className="w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-3 text-sm text-white outline-none transition focus:border-orange-500/70"><option value="" disabled>{label}</option>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
|
const FilterSelect: React.FC<{ value: string; label: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }> = ({ value, label, options, onChange }) => <label className="block"><span className="sr-only">{label}</span><select value={value} onChange={(event) => onChange(event.target.value)} className="w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-3 text-sm text-white outline-none transition focus:border-orange-500/70"><option value="" disabled>{label}</option>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
|
||||||
const EmptyState: React.FC<{ hasMaterials: boolean; onClear: () => void }> = ({ hasMaterials, onClear }) => <div className="flex min-h-80 flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 px-6 text-center"><div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-orange-500/10"><FolderOpen className="h-7 w-7 text-orange-400" /></div><h2 className="mt-5 text-lg font-bold text-white">{hasMaterials ? 'Nenhum material corresponde aos filtros' : 'A biblioteca ainda não tem materiais'}</h2><p className="mt-2 max-w-md text-sm leading-6 text-gray-500">{hasMaterials ? 'Tente alterar a busca ou os filtros selecionados.' : 'Quando um instrutor adicionar materiais a um curso ou aula, eles aparecerão aqui.'}</p>{hasMaterials && <button onClick={onClear} className="mt-5 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-600">Limpar filtros</button>}</div>;
|
const EmptyState: React.FC<{ hasMaterials: boolean; onClear: () => void }> = ({ hasMaterials, onClear }) => <div className="flex min-h-80 flex-col items-center justify-center rounded-2xl border border-dashed border-white/15 bg-zinc-950/50 px-6 text-center"><div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-orange-500/10"><FolderOpen className="h-7 w-7 text-orange-400" /></div><h2 className="mt-5 text-lg font-bold text-white">{hasMaterials ? 'Nenhum material corresponde aos filtros' : 'A biblioteca ainda não tem materiais'}</h2><p className="mt-2 max-w-md text-sm leading-6 text-gray-500">{hasMaterials ? 'Tente alterar a busca ou os filtros selecionados.' : 'Quando um instrutor adicionar materiais a um curso ou aula, eles aparecerão aqui.'}</p>{hasMaterials && <button onClick={onClear} className="mt-5 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-600">Limpar filtros</button>}</div>;
|
||||||
const MaterialMeta: React.FC<{ material: Material }> = ({ material }) => <div className="min-w-0"><p className="truncate text-xs font-semibold text-orange-300">{material.course.category}</p><p className="mt-1 flex items-center gap-1.5 truncate text-xs text-gray-400"><BookOpen className="h-3.5 w-3.5 shrink-0" />{material.course.title}</p>{material.lessonTitle && <p className="mt-1 truncate text-xs text-gray-500">Aula: {material.lessonTitle}</p>}</div>;
|
const MaterialMeta: React.FC<{ material: Material }> = ({ material }) => <div className="min-w-0"><p className="truncate text-xs font-semibold text-orange-300">{material.course.category}</p><p className="mt-1 flex items-center gap-1.5 truncate text-xs text-gray-400"><BookOpen className="h-3.5 w-3.5 shrink-0" />{material.course.title}</p>{material.lessonTitle && <p className="mt-1 truncate text-xs text-gray-500">Aula: {material.lessonTitle}</p>}</div>;
|
||||||
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 <article className="flex min-h-64 flex-col rounded-2xl border border-white/10 bg-zinc-950/80 p-5 transition hover:border-orange-500/35 hover:bg-zinc-900/80"><div className="flex items-start justify-between gap-4"><div className="rounded-xl bg-white/5 p-3">{getAttachmentIcon(attachment.type)}</div><span className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider ${badge.bg}`}>{badge.label}</span></div><div className="mt-5"><h2 className="line-clamp-2 text-base font-bold text-white">{attachment.name}</h2>{attachment.description && <p className="mt-2 line-clamp-2 text-sm leading-5 text-gray-400">{attachment.description}</p>}</div><div className="mt-5 border-t border-white/10 pt-4"><MaterialMeta material={material} /><div className="mt-4 flex items-center justify-between gap-3 text-xs text-gray-500"><span>{attachment.size || 'Tamanho não informado'}</span><span>{formatDate(attachment.createdAt)}</span></div></div><div className="mt-5 flex items-center gap-2"><button onClick={() => onCourse(material.course)} className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-xs font-semibold text-gray-300 transition hover:bg-white/10 hover:text-white"><Play className="h-3.5 w-3.5 text-orange-400" /> Abrir curso</button><a href={attachment.url} target="_blank" rel="noreferrer" onClick={() => 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 ? <ExternalLink className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}{isLink ? 'Abrir' : 'Baixar'}</a></div></article>; };
|
const MaterialGridCard: React.FC<{ material: Material; onOpen: (material: Material) => void | Promise<void>; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const badge = getAttachmentBadge(attachment.type); const isLink = attachment.type === 'link'; return <article className="flex min-h-64 flex-col rounded-2xl border border-white/10 bg-zinc-950/80 p-5 transition hover:border-orange-500/35 hover:bg-zinc-900/80"><div className="flex items-start justify-between gap-4"><div className="rounded-xl bg-white/5 p-3">{getAttachmentIcon(attachment.type)}</div><span className={`rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider ${badge.bg}`}>{badge.label}</span></div><div className="mt-5"><h2 className="line-clamp-2 text-base font-bold text-white">{attachment.name}</h2>{attachment.description && <p className="mt-2 line-clamp-2 text-sm leading-5 text-gray-400">{attachment.description}</p>}</div><div className="mt-5 border-t border-white/10 pt-4"><MaterialMeta material={material} /><div className="mt-4 flex items-center justify-between gap-3 text-xs text-gray-500"><span>{attachment.size || 'Tamanho não informado'}</span><span>{formatDate(attachment.createdAt)}</span></div></div><div className="mt-5 flex items-center gap-2"><button onClick={() => onCourse(material.course)} className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-xs font-semibold text-gray-300 transition hover:bg-white/10 hover:text-white"><Play className="h-3.5 w-3.5 text-orange-400" /> Abrir curso</button>{isLink ? <a href={attachment.url} target="_blank" rel="noreferrer" onClick={() => 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"><ExternalLink className="h-3.5 w-3.5" />Abrir</a> : <button type="button" onClick={() => 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"><Download className="h-3.5 w-3.5" />Baixar</button>}</div></article>; };
|
||||||
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 <article className="grid gap-4 border-b border-white/5 px-5 py-4 last:border-0 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_10rem_8rem] md:items-center"><div className="flex min-w-0 items-center gap-3"><div className="shrink-0">{getAttachmentIcon(attachment.type)}</div><div className="min-w-0"><p className="truncate text-sm font-semibold text-white">{attachment.name}</p><p className="mt-1 text-xs text-gray-500">{attachment.size || 'Tamanho não informado'}</p></div></div><MaterialMeta material={material} /><p className="text-xs text-gray-500">{formatDate(attachment.createdAt)}</p><div className="flex items-center gap-2 md:justify-end"><button onClick={() => onCourse(material.course)} className="rounded-lg p-2 text-gray-400 transition hover:bg-white/10 hover:text-orange-300" title="Abrir curso"><Play className="h-4 w-4" /></button><a href={attachment.url} target="_blank" rel="noreferrer" onClick={() => 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">{isLink ? <ExternalLink className="h-3.5 w-3.5" /> : <Download className="h-3.5 w-3.5" />}{isLink ? 'Abrir' : 'Baixar'}</a></div></article>; };
|
const MaterialListRow: React.FC<{ material: Material; onOpen: (material: Material) => void | Promise<void>; onCourse: (course: Course) => void }> = ({ material, onOpen, onCourse }) => { const { attachment } = material; const isLink = attachment.type === 'link'; return <article className="grid gap-4 border-b border-white/5 px-5 py-4 last:border-0 md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)_10rem_8rem] md:items-center"><div className="flex min-w-0 items-center gap-3"><div className="shrink-0">{getAttachmentIcon(attachment.type)}</div><div className="min-w-0"><p className="truncate text-sm font-semibold text-white">{attachment.name}</p><p className="mt-1 text-xs text-gray-500">{attachment.size || 'Tamanho não informado'}</p></div></div><MaterialMeta material={material} /><p className="text-xs text-gray-500">{formatDate(attachment.createdAt)}</p><div className="flex items-center gap-2 md:justify-end"><button onClick={() => onCourse(material.course)} className="rounded-lg p-2 text-gray-400 transition hover:bg-white/10 hover:text-orange-300" title="Abrir curso"><Play className="h-4 w-4" /></button>{isLink ? <a href={attachment.url} target="_blank" rel="noreferrer" onClick={() => 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"><ExternalLink className="h-3.5 w-3.5" />Abrir</a> : <button type="button" onClick={() => 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"><Download className="h-3.5 w-3.5" />Baixar</button>}</div></article>; };
|
||||||
|
|||||||
@@ -73,6 +73,29 @@ export async function uploadBunnyAsset(asset: StoredAsset) {
|
|||||||
return { key, assetUrl: `${cdnHost}/${key}` };
|
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) {
|
export async function downloadBunnyCover(filename: string) {
|
||||||
const { zone, password, endpoint } = bunnyStorageConfiguration();
|
const { zone, password, endpoint } = bunnyStorageConfiguration();
|
||||||
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {
|
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { pool } from '../db/pool.js';
|
import { pool } from '../db/pool.js';
|
||||||
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.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({
|
const paramsSchema = z.object({
|
||||||
courseId: z.string().uuid(),
|
courseId: z.string().uuid(),
|
||||||
@@ -14,6 +14,7 @@ const playbackParamsSchema = z.object({
|
|||||||
const coverParamsSchema = z.object({
|
const coverParamsSchema = z.object({
|
||||||
filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i),
|
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) => `
|
export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
|
||||||
select
|
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) => {
|
app.get('/', async (request) => {
|
||||||
const authenticated = await hasSession(request);
|
const authenticated = await hasSession(request);
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
|
|||||||
@@ -126,6 +126,26 @@ export function clearSession() {
|
|||||||
localStorage.removeItem(SESSION_KEY);
|
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<T>(path: string, options: RequestInit = {}): Promise<T> {
|
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const headers = new Headers(options.headers);
|
const headers = new Headers(options.headers);
|
||||||
|
|||||||
Reference in New Issue
Block a user