Compare commits
10 Commits
10c3d03d9c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a4bd4ac7 | ||
|
|
a861644f6c | ||
|
|
6981092589 | ||
|
|
61b7bba5af | ||
|
|
81a67bf123 | ||
|
|
d9184372ca | ||
|
|
61359a22a5 | ||
|
|
bdaff3f5df | ||
|
|
f5aa826b32 | ||
|
|
aec9c001d3 |
@@ -48,6 +48,7 @@ BUNNY_STORAGE_PASSWORD=
|
|||||||
BUNNY_STORAGE_ENDPOINT=
|
BUNNY_STORAGE_ENDPOINT=
|
||||||
BUNNY_STORAGE_CDN_HOST=
|
BUNNY_STORAGE_CDN_HOST=
|
||||||
BUNNY_COVER_MAX_UPLOAD_MB=10
|
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.
|
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
|
||||||
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
||||||
|
|||||||
4
App.tsx
4
App.tsx
@@ -14,6 +14,8 @@ import { AccessTokenPage } from './pages/AccessTokenPage';
|
|||||||
import { MyLearning } from './pages/MyLearning';
|
import { MyLearning } from './pages/MyLearning';
|
||||||
import { ProfilePage } from './pages/ProfilePage';
|
import { ProfilePage } from './pages/ProfilePage';
|
||||||
import { InstructorProfile } from './pages/InstructorProfile';
|
import { InstructorProfile } from './pages/InstructorProfile';
|
||||||
|
import { TrailDetail } from './pages/TrailDetail';
|
||||||
|
import { TrailPage } from './pages/TrailPage';
|
||||||
|
|
||||||
// Protected Route Component
|
// Protected Route Component
|
||||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
|
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
|
||||||
@@ -94,6 +96,8 @@ function AppContent() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/admin/trilhas/:trailId" element={<ProtectedRoute allowedRoles={['superadmin']}><TrailDetail /></ProtectedRoute>} />
|
||||||
|
<Route path="/trilhas/:trailId" element={<TrailPage onPlay={setSelectedCourse} />} />
|
||||||
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
|
<Route path="/meu-aprendizado" element={<ProtectedRoute><MyLearning onPlay={setSelectedCourse} /></ProtectedRoute>} />
|
||||||
<Route path="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
<Route path="/perfil" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||||
<Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
|
<Route path="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ BUNNY_STORAGE_PASSWORD=the-storage-zone-password
|
|||||||
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
|
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
|
||||||
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
|
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
|
||||||
BUNNY_COVER_MAX_UPLOAD_MB=10
|
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
|
### Bunny processing webhooks
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ services:
|
|||||||
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
|
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
|
||||||
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
|
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
|
||||||
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
|
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
|
||||||
|
BUNNY_ASSET_MAX_UPLOAD_MB: ${BUNNY_ASSET_MAX_UPLOAD_MB:-100}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getCommen
|
|||||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||||
import { CourseCoverImage } from '../components/CourseCoverImage';
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||||
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
import { BunnyVideo, courseApi, instructorApi, InstructorAnalytics } from '../services/api';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
const formatVideoDuration = (seconds: number | null | undefined) => {
|
const formatVideoDuration = (seconds: number | null | undefined) => {
|
||||||
if (!seconds || seconds < 1) return 'Processando';
|
if (!seconds || seconds < 1) return 'Processando';
|
||||||
@@ -82,6 +83,11 @@ const CourseModal: React.FC<{
|
|||||||
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
|
||||||
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
const [coverUploadMessage, setCoverUploadMessage] = useState('');
|
||||||
const [isCoverUploading, setIsCoverUploading] = useState(false);
|
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 [isDraggingAsset, setIsDraggingAsset] = useState(false);
|
||||||
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
|
||||||
|
|
||||||
// Lesson Management
|
// Lesson Management
|
||||||
@@ -101,12 +107,8 @@ const CourseModal: React.FC<{
|
|||||||
const [newTip, setNewTip] = useState('');
|
const [newTip, setNewTip] = useState('');
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
|
||||||
// New Attachment State
|
const [newLinkName, setNewLinkName] = useState('');
|
||||||
const [newAttName, setNewAttName] = useState('');
|
const [newLinkUrl, setNewLinkUrl] = useState('');
|
||||||
const [newAttType, setNewAttType] = useState<AttachmentType>('pdf');
|
|
||||||
const [newAttUrl, setNewAttUrl] = useState('');
|
|
||||||
const [newAttSize, setNewAttSize] = useState('');
|
|
||||||
const [newAttDesc, setNewAttDesc] = useState('');
|
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -150,12 +152,18 @@ const CourseModal: React.FC<{
|
|||||||
setBunnyUploadState('idle');
|
setBunnyUploadState('idle');
|
||||||
setBunnyUploadMessage('');
|
setBunnyUploadMessage('');
|
||||||
setCoverUploadMessage('');
|
setCoverUploadMessage('');
|
||||||
|
setAssetUploadMessage('');
|
||||||
|
setNewLinkName('');
|
||||||
|
setNewLinkUrl('');
|
||||||
instructorApi.bunnyConfiguration()
|
instructorApi.bunnyConfiguration()
|
||||||
.then((response) => setBunnyConfigured(response.data.configured))
|
.then((response) => setBunnyConfigured(response.data.configured))
|
||||||
.catch(() => setBunnyConfigured(false));
|
.catch(() => setBunnyConfigured(false));
|
||||||
instructorApi.coverConfiguration()
|
instructorApi.coverConfiguration()
|
||||||
.then((response) => setCoverConfigured(response.data.configured))
|
.then((response) => setCoverConfigured(response.data.configured))
|
||||||
.catch(() => setCoverConfigured(false));
|
.catch(() => setCoverConfigured(false));
|
||||||
|
instructorApi.assetConfiguration()
|
||||||
|
.then((response) => setAssetConfigured(response.data.configured))
|
||||||
|
.catch(() => setAssetConfigured(false));
|
||||||
courseApi.categories()
|
courseApi.categories()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setCategories(response.data);
|
setCategories(response.data);
|
||||||
@@ -390,36 +398,75 @@ const CourseModal: React.FC<{
|
|||||||
setTips(tips.filter((_, i) => i !== idx));
|
setTips(tips.filter((_, i) => i !== idx));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddAttachment = () => {
|
const handleAttachmentUploads = async (files: FileList | File[]) => {
|
||||||
if (!newAttName.trim()) {
|
const selectedFiles = Array.from(files);
|
||||||
alert("Informe o nome do arquivo para download.");
|
if (!selectedFiles.length) return;
|
||||||
|
if (!assetConfigured) {
|
||||||
|
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!newAttUrl.trim()) {
|
|
||||||
alert('Informe a URL do material.');
|
setIsAssetUploading(true);
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
new URL(newAttUrl.trim());
|
const parsedUrl = new URL(url);
|
||||||
|
if (!['https:', 'http:'].includes(parsedUrl.protocol)) throw new Error();
|
||||||
|
setAttachments((current) => [...current, {
|
||||||
|
id: `link-${Date.now()}`,
|
||||||
|
name: newLinkName.trim() || parsedUrl.hostname,
|
||||||
|
type: 'link',
|
||||||
|
url,
|
||||||
|
description: '',
|
||||||
|
}]);
|
||||||
|
setNewLinkName('');
|
||||||
|
setNewLinkUrl('');
|
||||||
} catch {
|
} catch {
|
||||||
alert('Informe uma URL válida para o material.');
|
alert('Informe um link HTTP ou HTTPS válido.');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
const removeAttachment = (id: string) => {
|
||||||
@@ -485,7 +532,7 @@ const CourseModal: React.FC<{
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
|
<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>
|
<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>
|
</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>
|
<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>
|
</div>
|
||||||
@@ -633,7 +680,7 @@ const CourseModal: React.FC<{
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Publicação</label>
|
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Publicação</label>
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value as Course['status'])} className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50">
|
<select value={status} onChange={(e) => setStatus(e.target.value as Course['status'])} className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50">
|
||||||
<option value="draft" className="bg-zinc-900">Salvar como rascunho</option>
|
<option value="draft" className="bg-zinc-900">Salvar como não publicado</option>
|
||||||
<option value="published" className="bg-zinc-900">Publicar agora</option>
|
<option value="published" className="bg-zinc-900">Publicar agora</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,61 +713,54 @@ const CourseModal: React.FC<{
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add attachment inputs */}
|
|
||||||
<div className="space-y-2 pt-1">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
ref={attachmentFileRef}
|
||||||
value={newAttName}
|
type="file"
|
||||||
onChange={(e) => setNewAttName(e.target.value)}
|
multiple
|
||||||
placeholder="Ex.: Planilha de acompanhamento de campanhas"
|
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="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"
|
className="hidden"
|
||||||
|
onChange={(event) => void handleAttachmentUpload(event)}
|
||||||
/>
|
/>
|
||||||
<select
|
<div
|
||||||
value={newAttType}
|
onDragEnter={(event) => { event.preventDefault(); setIsDraggingAsset(true); }}
|
||||||
onChange={(e) => setNewAttType(e.target.value as AttachmentType)}
|
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; setIsDraggingAsset(true); }}
|
||||||
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none"
|
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]'}`}
|
||||||
>
|
>
|
||||||
<option value="pdf">PDF</option>
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<option value="spreadsheet">Planilha</option>
|
<div>
|
||||||
<option value="zip">Pacote ZIP</option>
|
<p className="text-sm font-semibold text-white">Envie um arquivo de apoio</p>
|
||||||
<option value="doc">Documento</option>
|
|
||||||
<option value="link">Link</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddAttachment}
|
disabled={!assetConfigured || isAssetUploading}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Adicionar
|
{isAssetUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Paperclip className="h-4 w-4" />}
|
||||||
|
{isAssetUploading ? 'Enviando...' : 'Enviar arquivo'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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={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"
|
||||||
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* List of Attachments */}
|
{/* List of Attachments */}
|
||||||
@@ -730,7 +770,7 @@ const CourseModal: React.FC<{
|
|||||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||||
{getAttachmentIcon(att.type)}
|
{getAttachmentIcon(att.type)}
|
||||||
<span className="text-gray-200 font-medium truncate">{att.name}</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -744,7 +784,7 @@ const CourseModal: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-between">
|
<div className="mt-5 flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-between">
|
||||||
<button type="button" onClick={() => setEditorStep(2)} className="inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold text-gray-300 hover:bg-white/5 hover:text-white"><ChevronLeft className="w-4 h-4" /> Voltar para aulas</button>
|
<button type="button" onClick={() => setEditorStep(2)} className="inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold text-gray-300 hover:bg-white/5 hover:text-white"><ChevronLeft className="w-4 h-4" /> Voltar para aulas</button>
|
||||||
<button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="inline-flex items-center justify-center gap-2 rounded-xl bg-orange-500 px-6 py-3 text-sm font-extrabold text-white shadow-lg shadow-orange-500/20 transition-colors hover:bg-orange-600 disabled:opacity-60">{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como rascunho' : 'Salvar e publicar'}</button>
|
<button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="inline-flex items-center justify-center gap-2 rounded-xl bg-orange-500 px-6 py-3 text-sm font-extrabold text-white shadow-lg shadow-orange-500/20 transition-colors hover:bg-orange-600 disabled:opacity-60">{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como não publicado' : 'Salvar e publicar'}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -904,6 +944,7 @@ const CourseModal: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ManageCourses: React.FC = () => {
|
export const ManageCourses: React.FC = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [courses, setCourses] = useState<Course[]>([]);
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
||||||
@@ -936,6 +977,17 @@ export const ManageCourses: React.FC = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const courseId = searchParams.get('editar');
|
||||||
|
if (!courseId || courses.length === 0) return;
|
||||||
|
const course = courses.find((item) => item.id === courseId);
|
||||||
|
if (course) {
|
||||||
|
setEditingCourse(course);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
}, [courses, searchParams, setSearchParams]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingCourse(undefined);
|
setEditingCourse(undefined);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -957,7 +1009,7 @@ export const ManageCourses: React.FC = () => {
|
|||||||
const handleDuplicate = async (id: string) => {
|
const handleDuplicate = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await duplicateCourse(id);
|
await duplicateCourse(id);
|
||||||
showToast('Cópia criada como rascunho.', 'success');
|
showToast('Cópia criada como não publicada.', 'success');
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : 'Não foi possível duplicar este curso.', 'error');
|
showToast(error instanceof Error ? error.message : 'Não foi possível duplicar este curso.', 'error');
|
||||||
@@ -1110,7 +1162,7 @@ export const ManageCourses: React.FC = () => {
|
|||||||
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
|
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
|
||||||
{course.category}
|
{course.category}
|
||||||
</div>
|
</div>
|
||||||
{course.status === 'draft' && <div className="absolute top-3 right-3 bg-amber-500/20 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-amber-300 border border-amber-500/30">Rascunho</div>}
|
{course.status === 'draft' && <div className="absolute top-3 right-3 bg-amber-500/20 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-amber-300 border border-amber-500/30">Não publicado</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
|
|||||||
@@ -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,10 +55,21 @@ 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 className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between"><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><div className="rounded-2xl border border-white/10 bg-zinc-950 px-5 py-4 text-right"><p className="text-2xl font-bold text-white">{materials.length}</p><p className="text-xs text-gray-500">{materials.length === 1 ? 'material disponível' : 'materiais disponíveis'}</p></div></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>
|
||||||
|
|
||||||
<section className="mt-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-4 sm:p-5"><div className="grid gap-3 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,.7fr))]"><label className="relative block"><Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar por material, curso ou aula" className="w-full rounded-xl border border-white/10 bg-zinc-900 py-3 pl-10 pr-10 text-sm text-white outline-none transition focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15" />{query && <button onClick={() => setQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:text-white" aria-label="Limpar busca"><X className="h-4 w-4" /></button>}</label><FilterSelect value={selectedTrail} onChange={(value) => { setSelectedTrail(value); setSelectedCourse('all'); }} label="Trilha" options={[{ value: 'all', label: 'Todas as trilhas' }, ...trails.map((trail) => ({ value: trail, label: trail }))]} /><FilterSelect value={selectedCourse} onChange={setSelectedCourse} label="Curso" options={[{ value: 'all', label: 'Todos os cursos' }, ...courseOptions.map((course) => ({ value: course.id, label: course.title }))]} /><FilterSelect value={selectedType} onChange={(value) => setSelectedType(value as 'all' | AttachmentType)} label="Formato" options={filterTypes} /></div><div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-white/5 pt-4"><div className="flex items-center gap-2 text-xs text-gray-500"><Filter className="h-3.5 w-3.5 text-orange-400" /> {filteredMaterials.length} {filteredMaterials.length === 1 ? 'resultado' : 'resultados'}</div><div className="flex items-center gap-2">{hasFilters && <button onClick={clearFilters} className="rounded-lg px-3 py-2 text-xs font-semibold text-gray-300 transition hover:bg-white/10 hover:text-white">Limpar filtros</button>}<label className="sr-only" htmlFor="materials-sort">Ordenar por</label><select id="materials-sort" value={sort} onChange={(event) => setSort(event.target.value as SortOption)} className="rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-xs font-semibold text-white outline-none"><option value="recent">Mais recentes</option><option value="name">Nome</option><option value="type">Formato</option></select><div className="flex rounded-lg border border-white/10 bg-zinc-900 p-1"><button onClick={() => setView('grid')} aria-label="Visualização em grade" className={`rounded p-1.5 ${view === 'grid' ? 'bg-orange-500 text-white' : 'text-gray-400 hover:text-white'}`}><LayoutGrid className="h-4 w-4" /></button><button onClick={() => setView('list')} aria-label="Visualização em lista" className={`rounded p-1.5 ${view === 'list' ? 'bg-orange-500 text-white' : 'text-gray-400 hover:text-white'}`}><List className="h-4 w-4" /></button></div></div></div></section>
|
<section className="mt-6 rounded-2xl border border-white/10 bg-zinc-950/80 p-4 sm:p-5"><div className="grid gap-3 lg:grid-cols-[minmax(0,1.5fr)_repeat(3,minmax(0,.7fr))]"><label className="relative block"><Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar por material, curso ou aula" className="w-full rounded-xl border border-white/10 bg-zinc-900 py-3 pl-10 pr-10 text-sm text-white outline-none transition focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15" />{query && <button onClick={() => setQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 rounded p-1 text-gray-400 hover:text-white" aria-label="Limpar busca"><X className="h-4 w-4" /></button>}</label><FilterSelect value={selectedTrail} onChange={(value) => { setSelectedTrail(value); setSelectedCourse('all'); }} label="Trilha" options={[{ value: 'all', label: 'Todas as trilhas' }, ...trails.map((trail) => ({ value: trail, label: trail }))]} /><FilterSelect value={selectedCourse} onChange={setSelectedCourse} label="Curso" options={[{ value: 'all', label: 'Todos os cursos' }, ...courseOptions.map((course) => ({ value: course.id, label: course.title }))]} /><FilterSelect value={selectedType} onChange={(value) => setSelectedType(value as 'all' | AttachmentType)} label="Formato" options={filterTypes} /></div><div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-white/5 pt-4"><div className="flex items-center gap-2 text-xs text-gray-500"><Filter className="h-3.5 w-3.5 text-orange-400" /> {filteredMaterials.length} {filteredMaterials.length === 1 ? 'resultado' : 'resultados'}</div><div className="flex items-center gap-2">{hasFilters && <button onClick={clearFilters} className="rounded-lg px-3 py-2 text-xs font-semibold text-gray-300 transition hover:bg-white/10 hover:text-white">Limpar filtros</button>}<label className="sr-only" htmlFor="materials-sort">Ordenar por</label><select id="materials-sort" value={sort} onChange={(event) => setSort(event.target.value as SortOption)} className="rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-xs font-semibold text-white outline-none"><option value="recent">Mais recentes</option><option value="name">Nome</option><option value="type">Formato</option></select><div className="flex rounded-lg border border-white/10 bg-zinc-900 p-1"><button onClick={() => setView('grid')} aria-label="Visualização em grade" className={`rounded p-1.5 ${view === 'grid' ? 'bg-orange-500 text-white' : 'text-gray-400 hover:text-white'}`}><LayoutGrid className="h-4 w-4" /></button><button onClick={() => setView('list')} aria-label="Visualização em lista" className={`rounded p-1.5 ${view === 'list' ? 'bg-orange-500 text-white' : 'text-gray-400 hover:text-white'}`}><List className="h-4 w-4" /></button></div></div></div></section>
|
||||||
|
|
||||||
@@ -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>; };
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
112
pages/TrailDetail.tsx
Normal file
112
pages/TrailDetail.tsx
Normal file
File diff suppressed because one or more lines are too long
34
pages/TrailPage.tsx
Normal file
34
pages/TrailPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ArrowLeft, BookOpen, Loader2 } from 'lucide-react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Course } from '../types';
|
||||||
|
import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
|
||||||
|
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||||
|
import { CourseCard } from '../components/CourseCard';
|
||||||
|
|
||||||
|
export const TrailPage: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
|
||||||
|
const { trailId } = useParams<{ trailId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [trail, setTrail] = useState<PublicLearningPath | null>(null);
|
||||||
|
const [courses, setCourses] = useState<Course[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!trailId) return;
|
||||||
|
Promise.all([getLearningPaths(), getCourses()])
|
||||||
|
.then(([paths, availableCourses]) => {
|
||||||
|
const found = paths.find((item) => item.id === trailId) || null;
|
||||||
|
setTrail(found);
|
||||||
|
if (found) {
|
||||||
|
const byId = new Map(availableCourses.map((course) => [course.id, course]));
|
||||||
|
setCourses(found.courses.map((course) => byId.get(course.id)).filter((course): course is Course => Boolean(course)));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [trailId]);
|
||||||
|
|
||||||
|
if (isLoading) return <main className="flex min-h-[65vh] items-center justify-center pt-24"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
|
||||||
|
if (!trail) return <main className="mx-auto flex min-h-[65vh] max-w-6xl items-center px-6 pt-24"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha indisponível</p><h1 className="mt-3 text-3xl font-bold text-white">Esta trilha não está publicada.</h1></div></main>;
|
||||||
|
|
||||||
|
return <main className="min-h-screen pb-20 pt-20"><section className="relative overflow-hidden border-b border-white/10 bg-zinc-950"><div className="absolute inset-0 opacity-30">{trail.coverImageUrl && <CourseCoverImage source={trail.coverImageUrl} alt="" className="h-full w-full object-cover" style={{ transform: `scale(${trail.coverImageZoom})`, objectPosition: `${trail.coverImagePositionX}% ${trail.coverImagePositionY}%` }} />}</div><div className="absolute inset-0 bg-gradient-to-r from-black via-black/90 to-black/45" /><div className="relative mx-auto max-w-6xl px-6 py-14 sm:px-8 sm:py-20"><button onClick={() => navigate('/')} className="mb-8 inline-flex items-center gap-2 text-sm font-semibold text-gray-300 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Voltar para cursos</button><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha de cursos</p><h1 className="mt-3 max-w-3xl text-4xl font-bold tracking-tight text-white sm:text-5xl">{trail.title}</h1>{trail.description && <p className="mt-5 max-w-2xl text-base leading-7 text-gray-300">{trail.description}</p>}<div className="mt-7 inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-4 py-2 text-sm font-semibold text-white"><BookOpen className="h-4 w-4 text-orange-400" />{courses.length} {courses.length === 1 ? 'curso na trilha' : 'cursos na trilha'}</div></div></section><section className="mx-auto max-w-6xl px-6 py-12 sm:px-8"><div className="mb-7"><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua sequência</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos da trilha</h2></div>{courses.length ? <div className="grid gap-7 sm:grid-cols-2 lg:grid-cols-3">{courses.map((course, index) => <div key={course.id}><p className="mb-3 text-xs font-bold text-orange-300">{String(index + 1).padStart(2, '0')} · Curso</p><CourseCard course={course} variant="landscape" onClick={onPlay} /></div>)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-10 text-center text-sm text-gray-500">Nenhum curso publicado nesta trilha.</div>}</section></main>;
|
||||||
|
};
|
||||||
20
server/migrations/015_seed_trails_from_legacy_categories.sql
Normal file
20
server/migrations/015_seed_trails_from_legacy_categories.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Course categories were previously presented as trilhas but only stored a
|
||||||
|
-- label. Turn every existing label into a real ordered trilha without moving
|
||||||
|
-- or deleting any course.
|
||||||
|
insert into learning_paths (title, description, status, published_at)
|
||||||
|
select category.name, '', 'draft', null
|
||||||
|
from course_categories category
|
||||||
|
where not exists (
|
||||||
|
select 1 from learning_paths path where lower(path.title) = lower(category.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into learning_path_courses (learning_path_id, course_id, position)
|
||||||
|
select path.id, course.id, ranked.position
|
||||||
|
from (
|
||||||
|
select id, category, row_number() over (partition by category order by published_at nulls last, title, id)::integer as position
|
||||||
|
from courses
|
||||||
|
where status <> 'archived'
|
||||||
|
) ranked
|
||||||
|
join courses course on course.id = ranked.id
|
||||||
|
join learning_paths path on lower(path.title) = lower(ranked.category)
|
||||||
|
on conflict (learning_path_id, course_id) do nothing;
|
||||||
@@ -35,6 +35,18 @@ export function buildApp() {
|
|||||||
app.addContentTypeParser('application/octet-stream', rawUploadParser);
|
app.addContentTypeParser('application/octet-stream', rawUploadParser);
|
||||||
app.addContentTypeParser(/^video\/.+$/, rawUploadParser);
|
app.addContentTypeParser(/^video\/.+$/, rawUploadParser);
|
||||||
app.addContentTypeParser(/^image\/.+$/, { parseAs: 'buffer', bodyLimit: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
|
app.addContentTypeParser(/^image\/.+$/, { parseAs: 'buffer', bodyLimit: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
|
||||||
|
const assetContentTypes = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/zip',
|
||||||
|
'text/csv',
|
||||||
|
];
|
||||||
|
for (const contentType of assetContentTypes) {
|
||||||
|
app.addContentTypeParser(contentType, { parseAs: 'buffer', bodyLimit: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024 }, (_request, body, done) => done(null, body));
|
||||||
|
}
|
||||||
|
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: config.FRONTEND_ORIGIN,
|
origin: config.FRONTEND_ORIGIN,
|
||||||
@@ -43,7 +55,10 @@ export function buildApp() {
|
|||||||
|
|
||||||
app.setErrorHandler((error, _request, reply) => {
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
return reply.code(400).send({ error: 'Dados inválidos.', details: error.flatten() });
|
return reply.code(400).send({
|
||||||
|
error: error.issues[0]?.message || 'Dados inválidos.',
|
||||||
|
details: error.flatten(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const environmentSchema = z.object({
|
|||||||
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
|
BUNNY_STORAGE_ENDPOINT: optionalEnvironmentValue(z.string().url()),
|
||||||
BUNNY_STORAGE_CDN_HOST: optionalEnvironmentValue(z.string().url()),
|
BUNNY_STORAGE_CDN_HOST: optionalEnvironmentValue(z.string().url()),
|
||||||
BUNNY_COVER_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(50).default(10),
|
BUNNY_COVER_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(50).default(10),
|
||||||
|
BUNNY_ASSET_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(512).default(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = environmentSchema.parse(process.env);
|
export const config = environmentSchema.parse(process.env);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export class BunnyStorageConfigurationError extends Error {}
|
|||||||
export class BunnyStorageRequestError extends Error {}
|
export class BunnyStorageRequestError extends Error {}
|
||||||
|
|
||||||
type CoverImage = { body: Buffer; contentType: string };
|
type CoverImage = { body: Buffer; contentType: string };
|
||||||
|
type StoredAsset = { body: Buffer; contentType: string; extension: string };
|
||||||
|
|
||||||
function bunnyStorageConfiguration() {
|
function bunnyStorageConfiguration() {
|
||||||
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
|
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
|
||||||
@@ -53,6 +54,48 @@ export async function uploadBunnyCover(image: CoverImage) {
|
|||||||
return { key, coverImageUrl: `${cdnHost}/${key}` };
|
return { key, coverImageUrl: `${cdnHost}/${key}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadBunnyAsset(asset: StoredAsset) {
|
||||||
|
const { zone, password, endpoint, cdnHost } = bunnyStorageConfiguration();
|
||||||
|
const key = `materials/${randomUUID()}.${asset.extension}`;
|
||||||
|
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
AccessKey: password,
|
||||||
|
'Content-Type': asset.contentType,
|
||||||
|
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||||
|
},
|
||||||
|
body: asset.body,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => '');
|
||||||
|
throw new BunnyStorageRequestError(`Bunny Storage upload failed (${response.status})${detail ? `: ${detail.slice(0, 250)}` : ''}`);
|
||||||
|
}
|
||||||
|
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)}`, {
|
||||||
|
|||||||
@@ -58,7 +58,15 @@ const learningPathSchema = z.object({
|
|||||||
coverImagePositionX: z.number().min(0).max(100).default(50),
|
coverImagePositionX: z.number().min(0).max(100).default(50),
|
||||||
coverImagePositionY: z.number().min(0).max(100).default(50),
|
coverImagePositionY: z.number().min(0).max(100).default(50),
|
||||||
status: z.enum(['draft', 'published']).default('draft'),
|
status: z.enum(['draft', 'published']).default('draft'),
|
||||||
courseIds: z.array(z.string().uuid()).min(1).max(100),
|
courseIds: z.array(z.string().uuid()).max(100),
|
||||||
|
}).superRefine((input, context) => {
|
||||||
|
if (input.status === 'published' && input.courseIds.length === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['courseIds'],
|
||||||
|
message: 'Adicione pelo menos um curso antes de publicar a trilha.',
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type LearningPathInput = z.infer<typeof learningPathSchema>;
|
type LearningPathInput = z.infer<typeof learningPathSchema>;
|
||||||
@@ -143,6 +151,17 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
|
app.get('/paths', adminAccess, async () => ({ data: await getLearningPaths() }));
|
||||||
|
|
||||||
|
app.get('/path-courses', adminAccess, async () => {
|
||||||
|
const result = await pool.query<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }>(
|
||||||
|
`select id, title, status, category,
|
||||||
|
coalesce(cover_image_url, (select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl')) as "coverImageUrl"
|
||||||
|
from courses
|
||||||
|
where status <> 'archived'
|
||||||
|
order by category, title`,
|
||||||
|
);
|
||||||
|
return { data: result.rows };
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
|
app.get('/home-banners', adminAccess, async () => ({ data: await selectHomeBanners() }));
|
||||||
|
|
||||||
app.put('/home-banners', adminAccess, async (request) => {
|
app.put('/home-banners', adminAccess, async (request) => {
|
||||||
@@ -189,7 +208,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
|
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
@@ -223,7 +242,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return { data: paths.find((item) => item.id === pathId) };
|
return { data: paths.find((item) => item.id === pathId) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('rollback');
|
await client.query('rollback');
|
||||||
if (error instanceof Error && /course|Publish/.test(error.message)) return reply.code(400).send({ error: error.message });
|
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -271,7 +271,18 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const { courseId } = paramsSchema.parse(request.params);
|
const { courseId } = paramsSchema.parse(request.params);
|
||||||
const accessError = await assertCanManageCourse(courseId, request.user);
|
const accessError = await assertCanManageCourse(courseId, request.user);
|
||||||
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
|
if (accessError) return reply.code(accessError.statusCode).send({ error: accessError.error });
|
||||||
await pool.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
await client.query(`update courses set status = 'archived', published_at = null where id = $1`, [courseId]);
|
||||||
|
await client.query('delete from learning_path_courses where course_id = $1', [courseId]);
|
||||||
|
await client.query('commit');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'course.archived', subjectType: 'course', subjectId: courseId, ipAddress: request.ip });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
BunnyStorageConfigurationError,
|
BunnyStorageConfigurationError,
|
||||||
BunnyStorageRequestError,
|
BunnyStorageRequestError,
|
||||||
isBunnyStorageConfigured,
|
isBunnyStorageConfigured,
|
||||||
|
uploadBunnyAsset,
|
||||||
uploadBunnyCover,
|
uploadBunnyCover,
|
||||||
} from '../providers/bunny-storage.js';
|
} from '../providers/bunny-storage.js';
|
||||||
|
import { AssetUploadValidationError, validateAssetUpload } from '../uploads/asset.js';
|
||||||
|
|
||||||
const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
|
const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) });
|
||||||
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
||||||
@@ -31,6 +33,7 @@ function providerError(reply: { code: (status: number) => { send: (payload: obje
|
|||||||
}
|
}
|
||||||
|
|
||||||
function storageError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
function storageError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
||||||
|
if (error instanceof AssetUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
|
||||||
if (error instanceof BunnyStorageConfigurationError) return reply.code(503).send({ error: error.message });
|
if (error instanceof BunnyStorageConfigurationError) return reply.code(503).send({ error: error.message });
|
||||||
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
|
if (error instanceof BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -88,6 +91,13 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get('/assets/config', manageAccess, async () => ({
|
||||||
|
data: {
|
||||||
|
configured: isBunnyStorageConfigured(),
|
||||||
|
maxUploadBytes: config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
app.post('/covers', coverAccess, async (request, reply) => {
|
app.post('/covers', coverAccess, async (request, reply) => {
|
||||||
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
@@ -105,6 +115,20 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/assets', manageAccess, async (request, reply) => {
|
||||||
|
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
||||||
|
const body = request.body;
|
||||||
|
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de material.' });
|
||||||
|
try {
|
||||||
|
const asset = validateAssetUpload(body, contentType, config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024);
|
||||||
|
const stored = await uploadBunnyAsset({ body, contentType: asset.contentType, extension: asset.extension });
|
||||||
|
await recordAudit({ actorId: request.user.id, action: 'media.asset.uploaded', subjectType: 'asset', metadata: { key: stored.key, kind: asset.kind, sizeBytes: body.length }, ipAddress: request.ip });
|
||||||
|
return reply.code(201).send({ data: { ...stored, kind: asset.kind, sizeBytes: body.length } });
|
||||||
|
} catch (error) {
|
||||||
|
return storageError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/bunny/videos', manageAccess, async (request, reply) => {
|
app.post('/bunny/videos', manageAccess, async (request, reply) => {
|
||||||
const input = createVideoSchema.parse(request.body);
|
const input = createVideoSchema.parse(request.body);
|
||||||
try {
|
try {
|
||||||
|
|||||||
52
server/src/uploads/asset.ts
Normal file
52
server/src/uploads/asset.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
type AssetKind = 'document' | 'spreadsheet' | 'archive';
|
||||||
|
|
||||||
|
type AssetDefinition = {
|
||||||
|
kind: AssetKind;
|
||||||
|
extension: string;
|
||||||
|
needsZipSignature?: boolean;
|
||||||
|
needsOleSignature?: boolean;
|
||||||
|
needsPdfSignature?: boolean;
|
||||||
|
needsTextContent?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedAssetTypes = new Map<string, AssetDefinition>([
|
||||||
|
['application/pdf', { kind: 'document', extension: 'pdf', needsPdfSignature: true }],
|
||||||
|
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', { kind: 'document', extension: 'docx', needsZipSignature: true }],
|
||||||
|
['application/msword', { kind: 'document', extension: 'doc', needsOleSignature: true }],
|
||||||
|
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', { kind: 'spreadsheet', extension: 'xlsx', needsZipSignature: true }],
|
||||||
|
['application/vnd.ms-excel', { kind: 'spreadsheet', extension: 'xls', needsOleSignature: true }],
|
||||||
|
['text/csv', { kind: 'spreadsheet', extension: 'csv', needsTextContent: true }],
|
||||||
|
['application/zip', { kind: 'archive', extension: 'zip', needsZipSignature: true }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export class AssetUploadValidationError extends Error {
|
||||||
|
constructor(message: string, public readonly statusCode: 400 | 413 | 415) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipSignature = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||||
|
const oleSignature = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||||
|
|
||||||
|
export function validateAssetUpload(body: Buffer, contentType: string | undefined, maxBytes: number) {
|
||||||
|
const normalizedType = contentType?.split(';')[0]?.trim().toLowerCase();
|
||||||
|
const definition = normalizedType ? allowedAssetTypes.get(normalizedType) : undefined;
|
||||||
|
if (!definition) {
|
||||||
|
throw new AssetUploadValidationError('Envie apenas arquivos PDF, DOCX, XLSX, CSV ou ZIP.', 415);
|
||||||
|
}
|
||||||
|
if (body.length === 0) throw new AssetUploadValidationError('Escolha um arquivo para enviar.', 400);
|
||||||
|
if (body.length > maxBytes) throw new AssetUploadValidationError(`O arquivo excede o limite de ${Math.floor(maxBytes / 1024 / 1024)} MB.`, 413);
|
||||||
|
if (definition.needsPdfSignature && !body.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
|
||||||
|
throw new AssetUploadValidationError('O arquivo enviado não é um PDF válido.', 415);
|
||||||
|
}
|
||||||
|
if (definition.needsZipSignature && !body.subarray(0, 4).equals(zipSignature)) {
|
||||||
|
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
|
||||||
|
}
|
||||||
|
if (definition.needsOleSignature && !body.subarray(0, 8).equals(oleSignature)) {
|
||||||
|
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
|
||||||
|
}
|
||||||
|
if (definition.needsTextContent && body.subarray(0, Math.min(body.length, 4096)).includes(0)) {
|
||||||
|
throw new AssetUploadValidationError('O arquivo CSV deve conter texto.', 415);
|
||||||
|
}
|
||||||
|
return { kind: definition.kind, extension: definition.extension, contentType: normalizedType as string };
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ export interface LearningPathInput {
|
|||||||
status: 'draft' | 'published';
|
status: 'draft' | 'published';
|
||||||
courseIds: string[];
|
courseIds: string[];
|
||||||
}
|
}
|
||||||
|
export interface TrailCourseOption {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
category: string;
|
||||||
|
coverImageUrl: string | null;
|
||||||
|
}
|
||||||
export interface HomeBannerInput {
|
export interface HomeBannerInput {
|
||||||
kind: 'course' | 'custom';
|
kind: 'course' | 'custom';
|
||||||
courseId: string | null;
|
courseId: string | null;
|
||||||
@@ -119,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);
|
||||||
@@ -197,6 +224,7 @@ export const adminApi = {
|
|||||||
async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); },
|
async createCategory(name: string) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>('/admin/categories', { method: 'POST', body: JSON.stringify({ name }) }); },
|
||||||
async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); },
|
async updateCategory(categoryId: string, update: { name: string; isActive?: boolean; position?: number }) { return apiRequest<{ data: { id: string; name: string; position: number; isActive: boolean } }>(`/admin/categories/${categoryId}`, { method: 'PATCH', body: JSON.stringify(update) }); },
|
||||||
async listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
|
async listPaths() { return apiRequest<{ data: LearningPath[] }>('/admin/paths'); },
|
||||||
|
async listPathCourses() { return apiRequest<{ data: TrailCourseOption[] }>('/admin/path-courses'); },
|
||||||
async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); },
|
async createPath(input: LearningPathInput) { return apiRequest<{ data: LearningPath }>('/admin/paths', { method: 'POST', body: JSON.stringify(input) }); },
|
||||||
async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); },
|
async updatePath(pathId: string, input: LearningPathInput) { return apiRequest<{ data: LearningPath }>(`/admin/paths/${pathId}`, { method: 'PATCH', body: JSON.stringify(input) }); },
|
||||||
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
|
async deletePath(pathId: string) { return apiRequest<void>(`/admin/paths/${pathId}`, { method: 'DELETE' }); },
|
||||||
@@ -230,6 +258,8 @@ export const instructorApi = {
|
|||||||
},
|
},
|
||||||
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
|
async coverConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/covers/config'); },
|
||||||
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
|
async uploadCover(file: File) { return uploadImageRequest<{ data: { key: string; coverImageUrl: string } }>('/manage/media/covers', file); },
|
||||||
|
async assetConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/assets/config'); },
|
||||||
|
async uploadAsset(file: File) { return uploadImageRequest<{ data: { key: string; assetUrl: string; kind: 'document' | 'spreadsheet' | 'archive'; sizeBytes: number } }>('/manage/media/assets', file); },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const courseApi = {
|
export const courseApi = {
|
||||||
|
|||||||
Reference in New Issue
Block a user