Compare commits

...

39 Commits

Author SHA1 Message Date
Cauê Faleiros
95a4bd4ac7 fix: download bunny materials through academy
All checks were successful
CI / Validate frontend and API (push) Successful in 52s
CI / Build and publish Docker images (push) Successful in 24s
2026-09-09 16:20:49 -03:00
Cauê Faleiros
a861644f6c feat: add batch material uploads and links
All checks were successful
CI / Validate frontend and API (push) Successful in 39s
CI / Build and publish Docker images (push) Successful in 17s
2026-09-09 14:24:54 -03:00
Cauê Faleiros
6981092589 feat: upload course materials to bunny storage
All checks were successful
CI / Validate frontend and API (push) Successful in 2m1s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-09 14:16:00 -03:00
Cauê Faleiros
61b7bba5af fix: make trail course selection explicit
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-08 16:37:18 -03:00
Cauê Faleiros
81a67bf123 fix: load trail courses from course manager
All checks were successful
CI / Validate frontend and API (push) Successful in 30s
CI / Build and publish Docker images (push) Successful in 18s
2026-09-08 16:27:00 -03:00
Cauê Faleiros
d9184372ca fix: show specific validation errors 2026-09-08 16:24:15 -03:00
Cauê Faleiros
61359a22a5 fix: allow editing empty draft trails
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-08 16:10:53 -03:00
Cauê Faleiros
bdaff3f5df fix: rename draft status to not published 2026-09-08 16:03:47 -03:00
Cauê Faleiros
f5aa826b32 feat: add editable ordered trail pages
All checks were successful
CI / Validate frontend and API (push) Successful in 36s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-08 15:53:20 -03:00
Cauê Faleiros
aec9c001d3 fix: simplify materials library header 2026-09-08 15:41:42 -03:00
Cauê Faleiros
10c3d03d9c feat: simplify materials library and use real metadata
All checks were successful
CI / Validate frontend and API (push) Successful in 34s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-08 15:22:45 -03:00
Cauê Faleiros
54f598cd99 feat: let students customize public profiles
All checks were successful
CI / Validate frontend and API (push) Successful in 1m2s
CI / Build and publish Docker images (push) Successful in 23s
2026-09-08 14:46:16 -03:00
Cauê Faleiros
ad736758a7 fix: localize validation and api messages
All checks were successful
CI / Validate frontend and API (push) Successful in 39s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-08 13:40:38 -03:00
Cauê Faleiros
987eb210eb feat: standardize passwords at eight characters
All checks were successful
CI / Validate frontend and API (push) Successful in 38s
CI / Build and publish Docker images (push) Successful in 17s
2026-09-08 13:31:32 -03:00
Cauê Faleiros
8b94a25807 feat: harden media upload validation 2026-09-08 13:05:16 -03:00
Cauê Faleiros
3ae9c03614 chore: pass smtp settings to api container
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 46s
2026-09-08 12:40:32 -03:00
Cauê Faleiros
ee8d365321 feat: send welcome emails through smtp 2026-09-08 12:17:41 -03:00
Cauê Faleiros
03a6491989 feat: improve account management and welcome emails 2026-09-08 11:47:33 -03:00
Cauê Faleiros
7b2d25aabb feat: add public instructor profiles
All checks were successful
CI / Validate frontend and API (push) Successful in 54s
CI / Build and publish Docker images (push) Successful in 26s
2026-09-08 10:50:35 -03:00
Cauê Faleiros
07fbc7d009 feat: move account navigation into user menu 2026-09-08 10:31:21 -03:00
Cauê Faleiros
37ce8e8afb feat: filter cms courses by trail
All checks were successful
CI / Validate frontend and API (push) Successful in 53s
CI / Build and publish Docker images (push) Successful in 29s
2026-09-08 09:56:25 -03:00
Cauê Faleiros
815d3f93c1 feat: group cms courses by trail
All checks were successful
CI / Validate frontend and API (push) Successful in 2m3s
CI / Build and publish Docker images (push) Successful in 28s
2026-09-08 09:30:01 -03:00
Cauê Faleiros
a6f53ff0d3 fix: use existing course groups as trails 2026-09-04 18:15:40 -03:00
Cauê Faleiros
ae39fefa7d feat: manage categories with trails
All checks were successful
CI / Validate frontend and API (push) Successful in 36s
CI / Build and publish Docker images (push) Successful in 28s
2026-09-04 17:18:08 -03:00
Cauê Faleiros
c18e63f0e4 refactor: consolidate formations into trails
All checks were successful
CI / Validate frontend and API (push) Successful in 43s
CI / Build and publish Docker images (push) Successful in 17s
2026-09-04 17:04:26 -03:00
Cauê Faleiros
895b0dd00e fix: recover failed banner cover previews
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-04 16:52:29 -03:00
Cauê Faleiros
5b86d6ef0d feat: manage homepage banner carousel
All checks were successful
CI / Validate frontend and API (push) Successful in 35s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-04 16:44:34 -03:00
Cauê Faleiros
09d85a7123 feat: separate trails and formations in cms
All checks were successful
CI / Validate frontend and API (push) Successful in 41s
CI / Build and publish Docker images (push) Successful in 25s
2026-09-04 16:32:54 -03:00
Cauê Faleiros
79d1c70c44 feat: add managed learning paths
All checks were successful
CI / Validate frontend and API (push) Successful in 41s
CI / Build and publish Docker images (push) Successful in 23s
2026-09-04 16:22:09 -03:00
Cauê Faleiros
e18bb76630 fix: show short video durations accurately
All checks were successful
CI / Validate frontend and API (push) Successful in 38s
CI / Build and publish Docker images (push) Successful in 19s
2026-09-04 15:54:15 -03:00
Cauê Faleiros
ed00f60281 fix: fall back to Bunny Storage for cover images
All checks were successful
CI / Validate frontend and API (push) Successful in 51s
CI / Build and publish Docker images (push) Successful in 21s
2026-09-04 15:26:06 -03:00
Cauê Faleiros
66671f65c3 feat: delete lesson videos from Bunny 2026-09-04 15:18:10 -03:00
Cauê Faleiros
1a79fa447b feat: derive course durations from video media
All checks were successful
CI / Validate frontend and API (push) Successful in 38s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-04 14:52:47 -03:00
Cauê Faleiros
7a01be2b59 feat: add course cover positioning
All checks were successful
CI / Validate frontend and API (push) Successful in 37s
CI / Build and publish Docker images (push) Successful in 18s
2026-09-04 14:42:04 -03:00
Cauê Faleiros
ad860074fd feat: add resilient cover previews and zoom
All checks were successful
CI / Validate frontend and API (push) Successful in 1m10s
CI / Build and publish Docker images (push) Successful in 50s
2026-09-04 14:29:07 -03:00
Cauê Faleiros
1d5eafbbd0 feat: simplify instructor video uploads
All checks were successful
CI / Validate frontend and API (push) Successful in 46s
CI / Build and publish Docker images (push) Successful in 20s
2026-09-04 14:16:02 -03:00
Cauê Faleiros
b1bbf35751 feat: streamline course editor workflow 2026-09-04 13:53:35 -03:00
Cauê Faleiros
e15a44e733 feat: add Bunny Storage course cover uploads
All checks were successful
CI / Validate frontend and API (push) Successful in 44s
CI / Build and publish Docker images (push) Successful in 22s
2026-09-04 13:39:30 -03:00
Cauê Faleiros
3498d47182 fix: make course cover control actionable 2026-09-04 11:52:07 -03:00
56 changed files with 2639 additions and 1676 deletions

View File

@@ -20,6 +20,14 @@ JWT_SESSION_TTL=7d
INVITATION_TTL_HOURS=168 INVITATION_TTL_HOURS=168
PASSWORD_RESET_TTL_HOURS=24 PASSWORD_RESET_TTL_HOURS=24
AUDIT_LOG_PAGE_SIZE=50 AUDIT_LOG_PAGE_SIZE=50
# Optional welcome e-mails sent when a student creates their own account.
# Configure all five values together. Port 465 uses SSL; port 587 uses STARTTLS.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
MAIL_FROM=Compor HUB <noreply@your-domain.com>
SUPERADMIN_NAME=Compor HUB Superadmin SUPERADMIN_NAME=Compor HUB Superadmin
# Bunny Stream. Set all three in Portainer to enable instructor uploads and # Bunny Stream. Set all three in Portainer to enable instructor uploads and
@@ -33,6 +41,15 @@ BUNNY_EMBED_TOKEN_TTL_SECONDS=600
# Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf. # Must not exceed the 5 GB Nginx proxy limit declared in docker/nginx.conf.
BUNNY_MAX_UPLOAD_MB=5120 BUNNY_MAX_UPLOAD_MB=5120
# Bunny Storage course banners. Create a Storage Zone and linked Pull Zone;
# copy the Storage endpoint shown in Bunny's FTP & API Access panel.
BUNNY_STORAGE_ZONE=
BUNNY_STORAGE_PASSWORD=
BUNNY_STORAGE_ENDPOINT=
BUNNY_STORAGE_CDN_HOST=
BUNNY_COVER_MAX_UPLOAD_MB=10
BUNNY_ASSET_MAX_UPLOAD_MB=100
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images. # 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
WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web WEB_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-web

View File

@@ -12,6 +12,10 @@ import { Course, UserRole } from './types';
import { SuperAdmin } from './pages/SuperAdmin'; import { SuperAdmin } from './pages/SuperAdmin';
import { AccessTokenPage } from './pages/AccessTokenPage'; import { AccessTokenPage } from './pages/AccessTokenPage';
import { MyLearning } from './pages/MyLearning'; import { MyLearning } from './pages/MyLearning';
import { ProfilePage } from './pages/ProfilePage';
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 }) => {
@@ -92,7 +96,12 @@ 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="/instrutores/:instructorId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/perfis/:profileId" element={<InstructorProfile onPlay={setSelectedCourse} />} />
<Route path="/invite" element={<AccessTokenPage mode="invite" />} /> <Route path="/invite" element={<AccessTokenPage mode="invite" />} />
<Route path="/reset-password" element={<AccessTokenPage mode="reset" />} /> <Route path="/reset-password" element={<AccessTokenPage mode="reset" />} />

View File

@@ -28,6 +28,21 @@ After deploying the new images:
The API key and embed-token key never reach the browser. The instructor browser uploads to the authenticated Academy API, which sends the file to Bunny; learners receive only a signed iframe URL. The API key and embed-token key never reach the browser. The instructor browser uploads to the authenticated Academy API, which sends the file to Bunny; learners receive only a signed iframe URL.
## Bunny Storage course banners
Course banners use Bunny Storage, not the Stream library. Create one Standard Storage Zone in São Paulo and link one Standard Pull Zone to it. The Pull Zone hostname is the public CDN host for uploaded banners. Add these server-only variables to Portainer:
```env
BUNNY_STORAGE_ZONE=compor-academy-storage
BUNNY_STORAGE_PASSWORD=the-storage-zone-password
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
BUNNY_COVER_MAX_UPLOAD_MB=10
BUNNY_ASSET_MAX_UPLOAD_MB=100
```
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload 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
The editor can poll Bunny while a video encodes, but production should also configure Bunny's webhook so the Academy records the result even when no instructor page is open. In the library webhook settings, use: The editor can poll Bunny while a video encodes, but production should also configure Bunny's webhook so the Academy records the result even when no instructor page is open. In the library webhook settings, use:
@@ -55,7 +70,7 @@ The built-in Actions job token can be disabled or lack registry scope on self-ho
- `JWT_SECRET`: a unique random string of at least 32 characters. - `JWT_SECRET`: a unique random string of at least 32 characters.
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`. - `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
- `SUPERADMIN_EMAIL`: email address for the initial platform administrator. - `SUPERADMIN_EMAIL`: email address for the initial platform administrator.
- `SUPERADMIN_PASSWORD`: password for that administrator (at least 12 characters). - `SUPERADMIN_PASSWORD`: password for that administrator (at least 8 characters).
- `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` are optional login and public-auth throttling controls (defaults: 10 attempts per 900 seconds per source IP). - `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` are optional login and public-auth throttling controls (defaults: 10 attempts per 900 seconds per source IP).
- `JWT_SESSION_TTL`, `INVITATION_TTL_HOURS`, `PASSWORD_RESET_TTL_HOURS`, `BUNNY_EMBED_TOKEN_TTL_SECONDS`, and `AUDIT_LOG_PAGE_SIZE` tune operating policy without changing code. - `JWT_SESSION_TTL`, `INVITATION_TTL_HOURS`, `PASSWORD_RESET_TTL_HOURS`, `BUNNY_EMBED_TOKEN_TTL_SECONDS`, and `AUDIT_LOG_PAGE_SIZE` tune operating policy without changing code.

View File

@@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react'; import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react';
import { Course, SectionVariant } from '../types'; import { Course, SectionVariant } from '../types';
import { CourseCoverImage } from './CourseCoverImage';
import { useNavigate } from 'react-router-dom';
interface CourseCardProps { interface CourseCardProps {
course: Course; course: Course;
@@ -15,6 +17,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
onClick, onClick,
onOpenMaterials onOpenMaterials
}) => { }) => {
const navigate = useNavigate();
const widthClass = variant === 'landscape' const widthClass = variant === 'landscape'
? 'w-[320px] md:w-[420px]' ? 'w-[320px] md:w-[420px]'
: 'w-[200px] md:w-[260px]'; : 'w-[200px] md:w-[260px]';
@@ -37,13 +40,17 @@ export const CourseCard: React.FC<CourseCardProps> = ({
onClick={() => onClick?.(course)} onClick={() => onClick?.(course)}
className={`relative ${aspectRatioClass} w-full overflow-hidden rounded-[8px] bg-zinc-900 shadow-xl transition-all duration-300 ease-out group-hover:scale-[1.02] group-hover:shadow-2xl group-hover:shadow-orange-500/10 group-hover:z-10 ring-0 ring-white/0 group-hover:ring-2 group-hover:ring-orange-500/40`} className={`relative ${aspectRatioClass} w-full overflow-hidden rounded-[8px] bg-zinc-900 shadow-xl transition-all duration-300 ease-out group-hover:scale-[1.02] group-hover:shadow-2xl group-hover:shadow-orange-500/10 group-hover:z-10 ring-0 ring-white/0 group-hover:ring-2 group-hover:ring-orange-500/40`}
> >
<img <CourseCoverImage
src={course.thumbnail} source={course.thumbnail}
alt={course.title} alt={course.title}
loading="lazy" loading="lazy"
draggable={false} draggable={false}
onDragStart={(e) => e.preventDefault()} onDragStart={(e) => e.preventDefault()}
className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none" className="h-full w-full object-cover transition-opacity duration-300 opacity-90 group-hover:opacity-100 rounded-[8px] select-none pointer-events-none"
style={{
transform: `scale(${course.coverImageZoom || 1})`,
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
}}
/> />
{/* Badges on Thumbnail */} {/* Badges on Thumbnail */}
@@ -94,7 +101,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
{course.instructor && ( {course.instructor && (
<> <>
<span className="w-1 h-1 rounded-[980px] bg-gray-600"></span> <span className="w-1 h-1 rounded-[980px] bg-gray-600"></span>
<span className="truncate">{course.instructor}</span> {course.instructorId ? <button onClick={(event) => { event.stopPropagation(); navigate(`/instrutores/${course.instructorId}`); }} className="truncate text-left transition hover:text-orange-400 hover:underline">{course.instructor}</button> : <span className="truncate">{course.instructor}</span>}
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,39 @@
import React, { useEffect, useMemo, useState } from 'react';
type CourseCoverImageProps = Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src' | 'onError'> & {
source: string;
onFinalError?: () => void;
};
const fallbackCoverUrl = (source: string) => {
try {
const parsed = new URL(source, window.location.origin);
const filename = parsed.pathname.split('/').pop() || '';
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i.test(filename)) return null;
const apiBase = import.meta.env.VITE_API_URL || '/api/v1';
return `${apiBase}/courses/covers/${filename}`;
} catch {
return null;
}
};
export const CourseCoverImage: React.FC<CourseCoverImageProps> = ({ source, onFinalError, ...props }) => {
const fallbackUrl = useMemo(() => fallbackCoverUrl(source), [source]);
const [usingFallback, setUsingFallback] = useState(false);
useEffect(() => setUsingFallback(false), [source]);
return (
<img
{...props}
src={usingFallback && fallbackUrl ? fallbackUrl : source}
onError={() => {
if (!usingFallback && fallbackUrl) {
setUsingFallback(true);
return;
}
onFinalError?.();
}}
/>
);
};

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CourseRow } from './CourseRow'; import { CourseRow } from './CourseRow';
import { Section, Course } from '../types'; import { Section, Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react'; import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { LearningPathRow } from './LearningPathRow';
interface CourseGridProps { interface CourseGridProps {
onCourseSelect?: (course: Course) => void; onCourseSelect?: (course: Course) => void;
@@ -15,6 +16,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
selectedCategoryFilter selectedCategoryFilter
}) => { }) => {
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [paths, setPaths] = useState<PublicLearningPath[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState<string>('all'); const [activeCategory, setActiveCategory] = useState<string>('all');
const { user } = useAuth(); const { user } = useAuth();
@@ -39,7 +41,9 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
setCourses(await getCourses()); const [loadedCourses, loadedPaths] = await Promise.all([getCourses(), getLearningPaths()]);
setCourses(loadedCourses);
setPaths(loadedPaths);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -138,11 +142,6 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
variant: 'landscape', variant: 'landscape',
courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0)) courses: courses.filter(c => (c.attachments && c.attachments.length > 0) || c.lessons.some(l => l.attachments && l.attachments.length > 0))
}, },
{
title: 'Formações Recomendadas',
variant: 'portrait',
courses: courses
}
]; ];
return ( return (
@@ -238,6 +237,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
/> />
) )
))} ))}
{activeCategory === 'all' && paths.length > 0 && <LearningPathRow paths={paths} courses={courses} onCourseSelect={onCourseSelect} />}
</section> </section>
); );
}; };

View File

@@ -1,81 +1,24 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Play, Plus, Sparkles } from 'lucide-react'; import { ChevronLeft, ChevronRight, Play, Sparkles } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses, getHomeBanners, PublicHomeBanner } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface HeroProps { interface HeroProps { onPlay?: (course: Course) => void; }
onPlay?: (course: Course) => void;
}
export const Hero: React.FC<HeroProps> = ({ onPlay }) => { export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
const [featuredCourse, setFeaturedCourse] = useState<Course | null>(null); const [banners, setBanners] = useState<PublicHomeBanner[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
useEffect(() => { const [index, setIndex] = useState(0);
getCourses().then((courses) => setFeaturedCourse(courses[0] || null)).catch(() => setFeaturedCourse(null)); useEffect(() => { Promise.all([getHomeBanners(), getCourses()]).then(([loadedBanners, loadedCourses]) => { setBanners(loadedBanners); setCourses(loadedCourses); }).catch(() => setBanners([])); }, []);
}, []); useEffect(() => { if (banners.length < 2) return; const timer = window.setInterval(() => setIndex((current) => (current + 1) % banners.length), 7000); return () => window.clearInterval(timer); }, [banners.length]);
const fallback = courses[0];
if (!featuredCourse) return null; const banner = banners[index];
const course = banner?.courseId ? courses.find((item) => item.id === banner.courseId) : fallback;
return ( if (!banner && !fallback) return null;
<div className="relative w-full min-h-[75vh] lg:min-h-[82vh] max-h-[800px] h-auto overflow-hidden group flex flex-col justify-end pt-28 pb-12 sm:pb-16 px-6 md:px-12"> const title = banner ? (banner.kind === 'course' ? banner.courseTitle || banner.title : banner.title) : fallback.title;
{/* Background Image with Cinematic Gradient Overlay */} const description = banner ? (banner.kind === 'course' ? banner.courseDescription || banner.description : banner.description) : fallback.description;
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105"> const image = banner ? (banner.kind === 'course' ? banner.courseImageUrl : banner.imageUrl) : fallback.thumbnail;
<img const canPlay = Boolean(course && (banner?.kind !== 'custom' || banner.courseId));
src={featuredCourse.thumbnail} return <div className="relative flex h-[75vh] max-h-[800px] min-h-[560px] w-full flex-col justify-end overflow-hidden px-6 pb-12 pt-28 md:px-12"><div className="absolute inset-0">{image && <CourseCoverImage source={image} alt={title} className="h-full w-full object-cover" style={{ transform: `scale(${banner?.coverImageZoom || course?.coverImageZoom || 1})`, objectPosition: `${banner?.coverImagePositionX ?? course?.coverImagePositionX ?? 50}% ${banner?.coverImagePositionY ?? course?.coverImagePositionY ?? 50}%` }} />}<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" /><div className="absolute inset-0 bg-gradient-to-r from-black/95 via-black/60 to-transparent" /></div><div className="relative z-10 mx-auto w-full max-w-[1440px]"><div className="max-w-3xl"><div className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-white/90"><span className="flex items-center gap-1 rounded-full bg-orange-500 px-3 py-1"><Sparkles className="h-3 w-3" /> Destaque</span>{banner?.kind === 'course' && <span>{course?.category}</span>}</div><h1 className="font-display text-4xl font-extrabold leading-tight tracking-tight text-white md:text-6xl">{title}</h1>{description && <p className="mt-4 max-w-2xl text-base leading-relaxed text-gray-200 md:text-lg">{description}</p>}{canPlay && <button onClick={() => onPlay?.(course!)} className="mt-7 inline-flex items-center gap-3 rounded-full bg-white px-8 py-3.5 text-sm font-bold text-black"><Play className="h-5 w-5 fill-current" /> Assistir curso</button>}</div></div>{banners.length > 1 && <div className="absolute bottom-7 right-6 z-20 flex items-center gap-3 md:right-12"><button onClick={() => setIndex((index - 1 + banners.length) % banners.length)} className="rounded-full bg-black/50 p-2 text-white"><ChevronLeft className="h-5 w-5" /></button><div className="flex gap-1.5">{banners.map((item, itemIndex) => <button key={item.id} onClick={() => setIndex(itemIndex)} className={`h-2 rounded-full transition-all ${itemIndex === index ? 'w-6 bg-orange-500' : 'w-2 bg-white/50'}`} aria-label={`Banner ${itemIndex + 1}`} />)}</div><button onClick={() => setIndex((index + 1) % banners.length)} className="rounded-full bg-black/50 p-2 text-white"><ChevronRight className="h-5 w-5" /></button></div>}</div>;
alt={featuredCourse.title}
className="w-full h-full object-cover object-center"
/>
{/* Gradients for text readability and seamless transition */}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/60 to-black/75" />
<div className="absolute inset-0 bg-gradient-to-r from-black/95 via-black/65 to-transparent" />
</div>
{/* Content */}
<div className="relative z-10 max-w-[1440px] w-full mx-auto">
<div className="max-w-3xl lg:max-w-4xl animate-fade-in-up flex flex-col items-start">
{/* Metadata Badges / Tags */}
<div className="flex items-center gap-2.5 text-xs font-semibold uppercase tracking-wider text-white/90 mb-3.5">
<span className="bg-orange-500 text-white px-3.5 py-1 rounded-[980px] shadow-xl flex items-center gap-1">
<Sparkles className="w-3 h-3 fill-current" />
Destaque
</span>
<span className="text-gray-300">{featuredCourse.category}</span>
<span className="text-gray-500">•</span>
<span className="text-gray-300">{featuredCourse.duration}</span>
</div>
{/* Main Display Headline */}
<h1 className="font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-white tracking-tight leading-tight drop-shadow-2xl mb-4">
{featuredCourse.title}
</h1>
{/* Subhead / Lead Description */}
<p className="text-sm sm:text-base md:text-lg font-normal text-gray-200 line-clamp-2 sm:line-clamp-3 max-w-2xl leading-relaxed opacity-90 mb-6 sm:mb-8">
{featuredCourse.description}
</p>
{/* Action Buttons with comfortable width, borders, and spacing */}
<div className="flex flex-wrap items-center gap-4">
{/* Primary CTA */}
<button
onClick={() => onPlay?.(featuredCourse)}
className="flex items-center justify-center gap-3 bg-white text-black px-8 sm:px-10 py-3.5 sm:py-4 rounded-[980px] text-sm sm:text-base font-bold hover:bg-gray-100 active:scale-95 transition-all duration-200 shadow-xl min-w-[200px]"
>
<Play className="w-4 h-4 sm:w-5 sm:h-5 fill-current" />
<span>Assistir Aula 1</span>
</button>
{/* Add to List */}
<button
className="flex items-center justify-center w-12 h-12 sm:w-14 sm:h-14 rounded-[980px] bg-white/10 hover:bg-white/20 border border-white/20 backdrop-blur-xl text-white transition-all duration-200 active:scale-95 group shadow-xl shrink-0"
title="Adicionar aos Favoritos"
>
<Plus className="w-5 h-5 group-hover:scale-110 transition-transform" />
</button>
</div>
</div>
</div>
</div>
);
}; };

View File

@@ -0,0 +1,38 @@
import React, { useState } from 'react';
import { ArrowRight, Check, Layers3, X } from 'lucide-react';
import { Course } from '../types';
import { PublicLearningPath } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface LearningPathRowProps {
paths: PublicLearningPath[];
courses: Course[];
onCourseSelect?: (course: Course) => void;
}
export const LearningPathRow: React.FC<LearningPathRowProps> = ({ paths, courses, onCourseSelect }) => {
const [selectedPath, setSelectedPath] = useState<PublicLearningPath | null>(null);
const courseById = new Map(courses.map((course) => [course.id, course]));
return <>
<section className="mx-auto w-full max-w-[1440px] px-6 py-6 md:px-12">
<div className="mb-4 flex items-baseline gap-3">
<h2 className="font-display text-xl font-bold text-gray-100 md:text-2xl">Trilhas</h2>
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500">{paths.length} {paths.length === 1 ? 'trilha' : 'trilhas'}</span>
</div>
<div className="flex gap-5 overflow-x-auto pb-4 hide-scrollbar snap-x snap-mandatory">
{paths.map((path) => <button key={path.id} onClick={() => setSelectedPath(path)} className="group relative w-[300px] shrink-0 snap-start overflow-hidden rounded-xl border border-white/10 bg-zinc-950 text-left shadow-xl transition hover:-translate-y-1 hover:border-orange-500/60">
<div className="relative aspect-[16/8] overflow-hidden bg-zinc-900">
{path.coverImageUrl ? <CourseCoverImage source={path.coverImageUrl} alt={path.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-105" style={{ transform: `scale(${path.coverImageZoom || 1})`, objectPosition: `${path.coverImagePositionX ?? 50}% ${path.coverImagePositionY ?? 50}%` }} /> : <div className="flex h-full items-center justify-center bg-gradient-to-br from-orange-500/30 to-zinc-900"><Layers3 className="h-9 w-9 text-orange-300" /></div>}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
<span className="absolute bottom-3 left-3 rounded-full bg-black/60 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-white backdrop-blur">{path.courses.length} {path.courses.length === 1 ? 'curso' : 'cursos'}</span>
</div>
<div className="p-4"><h3 className="truncate font-semibold text-white group-hover:text-orange-300">{path.title}</h3><p className="mt-1 line-clamp-2 min-h-10 text-sm leading-relaxed text-gray-500">{path.description || 'Uma trilha organizada para avançar passo a passo.'}</p><span className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-orange-400">Ver trilha <ArrowRight className="h-4 w-4" /></span></div>
</button>)}
</div>
</section>
{selectedPath && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={selectedPath.title}>
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"><div className="flex items-start justify-between border-b border-white/10 p-6"><div><div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-orange-400"><Layers3 className="h-4 w-4" /> Formação</div><h2 className="mt-2 text-2xl font-bold text-white">{selectedPath.title}</h2><p className="mt-2 text-sm leading-relaxed text-gray-400">{selectedPath.description}</p></div><button onClick={() => setSelectedPath(null)} className="rounded-full p-2 text-gray-400 hover:bg-white/10 hover:text-white" aria-label="Fechar"><X className="h-5 w-5" /></button></div><ol className="space-y-2 p-5">{selectedPath.courses.map((pathCourse, index) => { const course = courseById.get(pathCourse.id); return <li key={pathCourse.id}><button onClick={() => { if (course) { setSelectedPath(null); onCourseSelect?.(course); } }} disabled={!course} className="flex w-full items-center gap-4 rounded-xl bg-white/[0.03] px-4 py-3 text-left transition hover:bg-white/[0.07] disabled:cursor-default"><span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-orange-500/15 text-xs font-bold text-orange-400">{index + 1}</span><span className="min-w-0 flex-1"><span className="block truncate font-semibold text-white">{pathCourse.title}</span><span className="mt-0.5 block text-xs text-gray-500">{pathCourse.category}</span></span>{course && <span className="text-xs font-semibold text-orange-400">Abrir curso</span>}</button></li>; })}</ol><div className="border-t border-white/10 p-4 text-center text-xs text-gray-500"><Check className="mr-1 inline h-3.5 w-3.5 text-emerald-400" /> Siga os cursos nesta ordem.</div></div>
</div>}
</>;
};

View File

@@ -25,6 +25,18 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
if (!email.trim() || !/^\S+@\S+\.\S+$/.test(email)) {
setError('Informe um e-mail válido.');
return;
}
if (isRegistering && name.trim().length < 2) {
setError('Informe seu nome.');
return;
}
if (password.length < 8) {
setError('A senha deve ter pelo menos 8 caracteres.');
return;
}
setIsSubmitting(true); setIsSubmitting(true);
if (isRegistering && role === 'professor') { if (isRegistering && role === 'professor') {
@@ -40,7 +52,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
if (!signedInUser) { if (!signedInUser) {
setError(isRegistering setError(isRegistering
? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 12 caracteres.' ? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 8 caracteres.'
: 'Credenciais inválidas. Verifique seu e-mail e senha.'); : 'Credenciais inválidas. Verifique seu e-mail e senha.');
return; return;
} }
@@ -83,11 +95,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-5"> <form noValidate onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-3"> <div className="space-y-3">
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus />} {isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" autoFocus />}
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus={!isRegistering} /> <input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" autoFocus={!isRegistering} />
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required /> <input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" />
</div> </div>
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>} {error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}

View File

@@ -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>
); );

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck } from 'lucide-react'; import { User as UserIcon, LogOut, Search, Folder, BookOpenCheck, ChevronDown, ShieldCheck, Settings, UserRound } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { LoginModal } from './LoginModal'; import { LoginModal } from './LoginModal';
import { SearchModal } from './SearchModal'; import { SearchModal } from './SearchModal';
@@ -15,6 +15,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
const [showSearchModal, setShowSearchModal] = useState(false); const [showSearchModal, setShowSearchModal] = useState(false);
const [showAccountMenu, setShowAccountMenu] = useState(false);
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -34,6 +35,10 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
const isMaterialsPage = location.pathname === '/materiais'; const isMaterialsPage = location.pathname === '/materiais';
const isHomePage = location.pathname === '/'; const isHomePage = location.pathname === '/';
const goTo = (path: string) => {
setShowAccountMenu(false);
navigate(path);
};
return ( return (
<> <>
@@ -100,37 +105,22 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
</button> </button>
) : ( ) : (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user.role === 'student' && <button onClick={() => navigate('/meu-aprendizado')} className={`hidden sm:inline-flex items-center gap-2 text-sm font-semibold px-4 py-2.5 rounded-[980px] transition-colors ${location.pathname === '/meu-aprendizado' ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' : 'text-gray-300 hover:text-white bg-white/5 hover:bg-white/10'}`}><BookOpenCheck className="w-4 h-4" /> Meu aprendizado</button>} <div className="relative">
{user.role === 'superadmin' ? ( <button onClick={() => setShowAccountMenu((current) => !current)} className="flex items-center gap-2 rounded-[980px] px-3 py-2 text-sm text-gray-300 hover:bg-white/10 hover:text-white transition-colors" aria-expanded={showAccountMenu} aria-haspopup="menu">
<button <span className="hidden md:block">Olá, <strong className="font-semibold text-white">{user.name.split(' ')[0]}</strong></span>
onClick={() => navigate('/admin')} <ChevronDown className={`h-4 w-4 transition-transform ${showAccountMenu ? 'rotate-180' : ''}`} />
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${
location.pathname === '/admin'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'text-gray-300 hover:text-white bg-white/5 hover:bg-white/10'
}`}
>
Painel Administrativo
</button> </button>
) : user.role === 'professor' && ( {showAccountMenu && <div role="menu" className="absolute right-0 top-full mt-2 w-56 overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-1.5 shadow-2xl shadow-black/60">
<button <button role="menuitem" onClick={() => goTo('/perfil')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><UserRound className="h-4 w-4 text-orange-400" /> Meu perfil</button>
onClick={() => navigate('/gerenciar')} {user.role === 'student' && <button role="menuitem" onClick={() => goTo('/meu-aprendizado')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><BookOpenCheck className="h-4 w-4 text-orange-400" /> Meu aprendizado</button>}
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${ {user.role === 'professor' && <button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Painel do instrutor</button>}
location.pathname === '/gerenciar' {user.role === 'superadmin' && <><button role="menuitem" onClick={() => goTo('/admin')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><ShieldCheck className="h-4 w-4 text-orange-400" /> Painel administrativo</button><button role="menuitem" onClick={() => goTo('/gerenciar')} className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm text-gray-200 hover:bg-white/10"><Settings className="h-4 w-4 text-orange-400" /> Gerenciar cursos</button></>}
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' </div>}
: 'text-gray-300 hover:text-white bg-white/5 hover:bg-white/10' </div>
}`}
>
Painel do Instrutor
</button>
)}
<div className="h-4 w-[1px] bg-white/20 hidden sm:block"></div> <div className="h-4 w-[1px] bg-white/20 hidden sm:block"></div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span className="text-sm text-gray-300 hidden md:block">
Olá, <strong className="text-white font-semibold">{user.name.split(' ')[0]}</strong>
</span>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex items-center gap-2 bg-red-500/10 hover:bg-red-500/20 active:scale-95 transition-all duration-300 px-4 py-2 rounded-[980px] border border-red-500/20 group text-xs font-semibold text-red-400" className="flex items-center gap-2 bg-red-500/10 hover:bg-red-500/20 active:scale-95 transition-all duration-300 px-4 py-2 rounded-[980px] border border-red-500/20 group text-xs font-semibold text-red-400"

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Loader2, PlayCircle, Download } from 'lucide-react'; import { Search, X, Loader2, PlayCircle, Download } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getCourses } from '../services/db'; import { getCourses } from '../services/db';
import { CourseCoverImage } from './CourseCoverImage';
interface SearchModalProps { interface SearchModalProps {
isOpen: boolean; isOpen: boolean;
@@ -132,10 +133,14 @@ export const SearchModal: React.FC<SearchModalProps> = ({ isOpen, onClose, onPla
className="cursor-pointer group bg-zinc-900/60 rounded-[8px] p-3 border border-white/5 hover:border-orange-500/40 transition-all duration-300 shadow-xl" className="cursor-pointer group bg-zinc-900/60 rounded-[8px] p-3 border border-white/5 hover:border-orange-500/40 transition-all duration-300 shadow-xl"
> >
<div className="aspect-video bg-gray-900 rounded-[8px] overflow-hidden mb-3 relative"> <div className="aspect-video bg-gray-900 rounded-[8px] overflow-hidden mb-3 relative">
<img <CourseCoverImage
src={course.thumbnail} source={course.thumbnail}
alt={course.title} alt={course.title}
className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]" className="w-full h-full object-cover opacity-80 group-hover:opacity-100 group-hover:scale-105 transition-all duration-300 rounded-[8px]"
style={{
transform: `scale(${course.coverImageZoom || 1})`,
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
}}
/> />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40"> <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/40">
<PlayCircle className="w-10 h-10 text-orange-500" /> <PlayCircle className="w-10 h-10 text-orange-500" />

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useState } from 'react';
import { Copy, Download, KeyRound, Loader2, Pencil, Plus, Trash2, UserCheck, UserX, X } from 'lucide-react';
import { ManagedUser } from '../services/api';
const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' };
const inputClass = 'w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white outline-none focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15';
type Props = {
users: ManagedUser[];
isLoading: boolean;
query: string;
setQuery: (value: string) => void;
accessLink: string;
inviteEmail: string;
setInviteEmail: (value: string) => void;
updatingUserId: string | null;
currentUserId?: string;
onInvite: (event: React.FormEvent) => void;
onUpdateUser: (account: ManagedUser, update: Partial<Pick<ManagedUser, 'name' | 'email' | 'role' | 'isActive'>>) => void;
onDeleteUser: (account: ManagedUser) => void;
onResetPassword: (account: ManagedUser) => void;
onExport: () => void;
};
export const UserManagementPanel: React.FC<Props> = ({ users, isLoading, query, setQuery, accessLink, inviteEmail, setInviteEmail, updatingUserId, currentUserId, onInvite, onUpdateUser, onDeleteUser, onResetPassword, onExport }) => {
const [editing, setEditing] = useState<ManagedUser | null>(null);
const [draft, setDraft] = useState({ name: '', email: '', role: 'student' as ManagedUser['role'], isActive: true });
const isSelf = editing?.id === currentUserId;
useEffect(() => {
if (editing) setDraft({ name: editing.name, email: editing.email, role: editing.role, isActive: editing.isActive });
}, [editing]);
const save = (event: React.FormEvent) => {
event.preventDefault();
if (!editing) return;
onUpdateUser(editing, isSelf ? { name: draft.name, email: draft.email } : draft);
setEditing(null);
};
return <>
<section className="mb-6 overflow-hidden rounded-2xl border border-orange-500/20 bg-gradient-to-r from-orange-500/10 via-zinc-950/80 to-zinc-950/80 p-5 sm:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Acesso de instrutores</p><h2 className="mt-2 text-xl font-bold text-white">Convidar instrutor</h2><p className="mt-1 max-w-xl text-sm text-gray-400">Alunos criam a própria conta na tela de entrada — sem aprovação manual — e recebem a mensagem de boas-vindas quando o e-mail estiver configurado.</p></div><form onSubmit={onInvite} className="flex w-full max-w-xl gap-2"><input required type="email" value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="E-mail do instrutor" className={inputClass} /><button className="inline-flex shrink-0 items-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-600"><Plus className="h-4 w-4" /> Gerar convite</button></form></div>
{accessLink && <div className="mt-5 flex flex-col gap-2 rounded-xl border border-white/10 bg-black/25 p-3 sm:flex-row"><input readOnly value={accessLink} className="min-w-0 flex-1 bg-transparent px-1 text-xs text-gray-300 outline-none" /><button type="button" onClick={() => void navigator.clipboard.writeText(accessLink)} className="inline-flex items-center justify-center gap-2 rounded-lg bg-white/10 px-3 py-2 text-xs font-semibold text-white hover:bg-white/15"><Copy className="h-3.5 w-3.5" /> Copiar link</button></div>}
</section>
<section className="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80"><div className="flex flex-col gap-4 border-b border-white/10 p-5 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="text-lg font-bold text-white">Pessoas e acessos</h2><p className="mt-1 text-sm text-gray-500">Edite dados, função e acesso sem precisar sair do painel.</p></div><div className="flex gap-2"><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar por nome ou e-mail" className="w-full min-w-0 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white sm:w-72" /><button onClick={onExport} className="rounded-xl bg-white/10 px-3 text-sm text-white hover:bg-white/15" title="Exportar CSV"><Download className="h-4 w-4" /></button></div></div>
{isLoading ? <div className="flex h-56 items-center justify-center"><Loader2 className="h-7 w-7 animate-spin text-orange-400" /></div> : <div className="overflow-x-auto"><table className="w-full min-w-[850px] text-left text-sm"><thead className="bg-white/[0.03] text-xs uppercase tracking-wider text-gray-500"><tr><th className="px-5 py-4">Pessoa</th><th className="px-5 py-4">Acesso</th><th className="px-5 py-4">Status</th><th className="px-5 py-4">Cadastro</th><th className="px-5 py-4 text-right">Ações</th></tr></thead><tbody className="divide-y divide-white/5">{users.map((account) => { const busy = updatingUserId === account.id; const current = account.id === currentUserId; return <tr key={account.id} className="text-gray-300"><td className="px-5 py-4"><p className="font-semibold text-white">{account.name}</p><p className="mt-1 text-xs text-gray-500">{account.email}</p></td><td className="px-5 py-4"><span className="rounded-full bg-white/5 px-2.5 py-1 text-xs font-semibold text-gray-300">{roleLabel[account.role]}</span></td><td className="px-5 py-4"><span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${account.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-300'}`}>{account.isActive ? <UserCheck className="h-3.5 w-3.5" /> : <UserX className="h-3.5 w-3.5" />}{account.isActive ? 'Ativo' : 'Desativado'}</span></td><td className="px-5 py-4 text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}</td><td className="px-5 py-4"><div className="flex justify-end gap-2"><button disabled={busy} onClick={() => setEditing(account)} className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-xs font-semibold text-gray-200 hover:bg-white/10 disabled:opacity-50"><Pencil className="h-3.5 w-3.5" /> Editar</button><button disabled={busy || current} onClick={() => onResetPassword(account)} className="rounded-lg border border-white/10 p-2 text-orange-300 hover:bg-orange-500/10 disabled:opacity-40" title="Gerar redefinição de senha"><KeyRound className="h-4 w-4" /></button><button disabled={busy || current} onClick={() => onDeleteUser(account)} className="rounded-lg border border-red-500/20 p-2 text-red-400 hover:bg-red-500/10 disabled:opacity-40" title={current ? 'Você não pode excluir a própria conta' : 'Excluir usuário'}><Trash2 className="h-4 w-4" /></button></div></td></tr>; })}</tbody></table></div>}
</section>
{editing && <div className="fixed inset-0 z-[110] flex items-center justify-center p-4"><button type="button" className="absolute inset-0 bg-black/75 backdrop-blur-sm" onClick={() => setEditing(null)} aria-label="Fechar" /><form onSubmit={save} className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"><div className="flex items-start justify-between border-b border-white/10 p-5"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Editar acesso</p><h2 className="mt-1 text-xl font-bold text-white">{editing.name}</h2></div><button type="button" onClick={() => setEditing(null)} className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white"><X className="h-5 w-5" /></button></div><div className="space-y-4 p-5"><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">Nome</span><input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} className={inputClass} required /></label><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">E-mail</span><input type="email" value={draft.email} onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))} className={inputClass} required /></label>{!isSelf && <><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">Função</span><select value={draft.role} onChange={(event) => setDraft((current) => ({ ...current, role: event.target.value as ManagedUser['role'] }))} className={inputClass}><option value="student">Aluno</option><option value="instructor">Instrutor</option><option value="admin">Superadmin</option></select></label><label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={draft.isActive} onChange={(event) => setDraft((current) => ({ ...current, isActive: event.target.checked }))} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Conta ativa</span><span className="mt-1 block text-xs text-gray-500">Desative para bloquear o login sem apagar os dados.</span></span></label></>}</div><div className="flex justify-end gap-3 border-t border-white/10 p-5"><button type="button" onClick={() => setEditing(null)} className="rounded-xl px-4 py-2.5 text-sm font-semibold text-gray-300 hover:bg-white/10">Cancelar</button><button className="rounded-xl bg-orange-500 px-5 py-2.5 text-sm font-semibold text-white hover:bg-orange-600">Salvar alterações</button></div></form></div>}
</>;
};

View File

@@ -1,12 +1,13 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import { User, UserRole } from '../types'; import { User, UserRole } from '../types';
import { authApi, clearSession, getSession, saveSession } from '../services/api'; import { ApiUser, authApi, clearSession, getSession, saveSession } from '../services/api';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
login: (email: string, password: string) => Promise<User | null>; login: (email: string, password: string) => Promise<User | null>;
register: (name: string, email: string, password: string) => Promise<User | null>; register: (name: string, email: string, password: string) => Promise<User | null>;
logout: () => void; logout: () => void;
updateCurrentUser: (user: ApiUser) => void;
isLoading: boolean; isLoading: boolean;
} }
@@ -67,8 +68,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
clearSession(); clearSession();
}; };
const updateCurrentUser = (apiUser: ApiUser) => {
const session = getSession();
if (session) saveSession({ ...session, user: apiUser });
setUser(toFrontendUser(apiUser));
};
return ( return (
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}> <AuthContext.Provider value={{ user, login, register, logout, updateCurrentUser, isLoading }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -34,12 +34,23 @@ services:
INVITATION_TTL_HOURS: ${INVITATION_TTL_HOURS:-168} INVITATION_TTL_HOURS: ${INVITATION_TTL_HOURS:-168}
PASSWORD_RESET_TTL_HOURS: ${PASSWORD_RESET_TTL_HOURS:-24} PASSWORD_RESET_TTL_HOURS: ${PASSWORD_RESET_TTL_HOURS:-24}
AUDIT_LOG_PAGE_SIZE: ${AUDIT_LOG_PAGE_SIZE:-50} AUDIT_LOG_PAGE_SIZE: ${AUDIT_LOG_PAGE_SIZE:-50}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
MAIL_FROM: ${MAIL_FROM:-}
BUNNY_STREAM_LIBRARY_ID: ${BUNNY_STREAM_LIBRARY_ID:-} BUNNY_STREAM_LIBRARY_ID: ${BUNNY_STREAM_LIBRARY_ID:-}
BUNNY_STREAM_API_KEY: ${BUNNY_STREAM_API_KEY:-} BUNNY_STREAM_API_KEY: ${BUNNY_STREAM_API_KEY:-}
BUNNY_EMBED_TOKEN_KEY: ${BUNNY_EMBED_TOKEN_KEY:-} BUNNY_EMBED_TOKEN_KEY: ${BUNNY_EMBED_TOKEN_KEY:-}
BUNNY_WEBHOOK_SECRET: ${BUNNY_WEBHOOK_SECRET:-} BUNNY_WEBHOOK_SECRET: ${BUNNY_WEBHOOK_SECRET:-}
BUNNY_EMBED_TOKEN_TTL_SECONDS: ${BUNNY_EMBED_TOKEN_TTL_SECONDS:-600} BUNNY_EMBED_TOKEN_TTL_SECONDS: ${BUNNY_EMBED_TOKEN_TTL_SECONDS:-600}
BUNNY_MAX_UPLOAD_MB: ${BUNNY_MAX_UPLOAD_MB:-5120} BUNNY_MAX_UPLOAD_MB: ${BUNNY_MAX_UPLOAD_MB:-5120}
BUNNY_STORAGE_ZONE: ${BUNNY_STORAGE_ZONE:-}
BUNNY_STORAGE_PASSWORD: ${BUNNY_STORAGE_PASSWORD:-}
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
BUNNY_ASSET_MAX_UPLOAD_MB: ${BUNNY_ASSET_MAX_UPLOAD_MB:-100}
healthcheck: 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

View File

@@ -68,6 +68,21 @@ h1, h2, h3, h4, h5, h6, .font-display, .font-heading {
user-select: none; user-select: none;
} }
/* Shared native controls used inside focused admin dialogs. */
.input {
width: 100%;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 0.75rem;
background: #18181b;
padding: 0.625rem 0.75rem;
color: #fff;
font-size: 0.875rem;
}
.input::placeholder {
color: #71717a;
}
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {
opacity: 0; opacity: 0;

View File

@@ -2,6 +2,23 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
// Covers uploaded to Bunny Storage are normally served directly by its CDN.
// If that CDN URL is unavailable in a browser, retry the safe Academy proxy.
// This also protects previews in administrative screens that render native img
// elements (for example a homepage-banner preview).
document.addEventListener('error', (event) => {
const image = event.target;
if (!(image instanceof HTMLImageElement) || image.dataset.coverFallbackApplied === 'true') return;
try {
const filename = new URL(image.currentSrc || image.src, window.location.origin).pathname.split('/').pop() || '';
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i.test(filename)) return;
image.dataset.coverFallbackApplied = 'true';
image.src = `${import.meta.env.VITE_API_URL || '/api/v1'}/courses/covers/${filename}`;
} catch {
// Keep the browser's normal failed-image behavior for non-cover assets.
}
}, true);
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
throw new Error("Could not find root element to mount to"); throw new Error("Could not find root element to mount to");

21
package-lock.json generated
View File

@@ -14,6 +14,7 @@
"fastify": "^5.3.2", "fastify": "^5.3.2",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"lucide-react": "0.344.0", "lucide-react": "0.344.0",
"nodemailer": "^10.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
@@ -22,6 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.15.4", "@types/pg": "^8.15.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"tsx": "^4.19.4", "tsx": "^4.19.4",
@@ -1475,6 +1477,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.23.1", "version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -2231,6 +2243,15 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.1.tgz",
"integrity": "sha512-c+gU9cL9HLDax3vjxL88kW+6NOgdtEUWaZ+AUtxdJR6LLhf0kGdCLExof7yiKW7zdO9EfXCSIgmhGyFmUM0mYQ==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/obliterator": { "node_modules/obliterator": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",

View File

@@ -23,6 +23,7 @@
"fastify": "^5.3.2", "fastify": "^5.3.2",
"fastify-plugin": "^5.0.1", "fastify-plugin": "^5.0.1",
"lucide-react": "0.344.0", "lucide-react": "0.344.0",
"nodemailer": "^10.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
@@ -31,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.15.4", "@types/pg": "^8.15.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"tsx": "^4.19.4", "tsx": "^4.19.4",

View File

@@ -11,7 +11,10 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
const [error, setError] = useState(''); const [error, setError] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); event.preventDefault(); setError('');
if (mode === 'invite' && name.trim().length < 2) { setError('Informe seu nome.'); return; }
if (password.length < 8) { setError('A senha deve ter pelo menos 8 caracteres.'); return; }
setSaving(true);
try { try {
if (mode === 'invite') { if (mode === 'invite') {
const session = await authApi.acceptInvitation(token, name, password); const session = await authApi.acceptInvitation(token, name, password);
@@ -20,5 +23,5 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); } } catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input required value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input required minLength={12} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 12 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>; return <main className="min-h-screen pt-32 px-6 flex justify-center"><form noValidate onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 8 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
}; };

View File

@@ -0,0 +1,34 @@
import React, { useEffect, useState } from 'react';
import { ExternalLink, Globe2, Instagram, Linkedin, Loader2, UserRound, Youtube } from 'lucide-react';
import { useParams } from 'react-router-dom';
import { Course } from '../types';
import { getPublicProfile, InstructorProfileData } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { CourseCard } from '../components/CourseCard';
const networks = [
{ key: 'websiteUrl', label: 'Site', icon: Globe2 },
{ key: 'linkedinUrl', label: 'LinkedIn', icon: Linkedin },
{ key: 'instagramUrl', label: 'Instagram', icon: Instagram },
{ key: 'youtubeUrl', label: 'YouTube', icon: Youtube },
] as const;
export const InstructorProfile: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const { instructorId, profileId } = useParams<{ instructorId?: string; profileId?: string }>();
const publicProfileId = instructorId || profileId;
const [profile, setProfile] = useState<InstructorProfileData | null>(null);
const [state, setState] = useState<'loading' | 'ready' | 'missing'>('loading');
useEffect(() => {
if (!publicProfileId) return;
setState('loading');
getPublicProfile(publicProfileId).then((data) => { setProfile(data); setState('ready'); }).catch(() => setState('missing'));
}, [publicProfileId]);
if (state === 'loading') return <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
if (state === 'missing' || !profile) return <main className="mx-auto flex min-h-[65vh] max-w-5xl items-center px-6 pt-24"><div><p className="text-sm font-semibold uppercase tracking-widest text-orange-400">Perfil indisponível</p><h1 className="mt-3 text-3xl font-bold">Este perfil não está público.</h1></div></main>;
const isInstructor = profile.role === 'instructor' || profile.role === 'admin';
return <main className="min-h-screen pb-20 pt-20"><section className="border-b border-white/10 bg-gradient-to-br from-orange-500/15 via-zinc-950 to-black"><div className="mx-auto grid max-w-6xl gap-7 px-6 py-12 sm:px-8 md:grid-cols-[auto_1fr] md:items-center md:py-16"><div className="h-32 w-32 overflow-hidden rounded-full border-4 border-zinc-900 bg-zinc-800 shadow-2xl shadow-black/50">{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-14 w-14 text-orange-300" /></div>}</div><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">{isInstructor ? 'Instrutor' : 'Aluno'}</p><h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-5xl">{profile.name}</h1>{profile.headline && <p className="mt-3 text-lg text-gray-300">{profile.headline}</p>}<div className="mt-5 flex flex-wrap gap-2">{networks.map(({ key, label, icon: Icon }) => { const url = profile[key]; return url ? <a key={key} href={url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3.5 py-2 text-sm font-semibold text-white transition hover:border-orange-500/40 hover:bg-orange-500/10"><Icon className="h-4 w-4 text-orange-400" />{label}<ExternalLink className="h-3 w-3 text-gray-500" /></a> : null; })}</div></div></div></section><div className={`mx-auto max-w-6xl gap-12 px-6 py-12 sm:px-8 ${isInstructor ? 'grid lg:grid-cols-[minmax(0,0.7fr)_minmax(0,1.3fr)]' : 'max-w-3xl'}`}><aside><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sobre</p><h2 className="mt-2 text-2xl font-bold text-white">Conheça {profile.name.split(' ')[0]}</h2><p className="mt-5 whitespace-pre-line text-sm leading-7 text-gray-300">{profile.bio || `${isInstructor ? 'Este instrutor' : 'Este aluno'} ainda não adicionou uma apresentação.`}</p></aside>{isInstructor && <section><div className="mb-6 flex items-end justify-between gap-4"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Conteúdo</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos publicados</h2></div><span className="rounded-full bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300">{profile.courses.length} {profile.courses.length === 1 ? 'curso' : 'cursos'}</span></div>{profile.courses.length ? <div className="grid gap-7 sm:grid-cols-2">{profile.courses.map((course) => <CourseCard key={course.id} course={course} variant="landscape" onClick={onPlay} />)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-8 text-center text-sm text-gray-500">Nenhum curso publicado ainda.</div>}</section>}</div></main>;
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
Plus, Plus,
Settings, Settings,
@@ -10,6 +10,8 @@ import {
MessageSquare, MessageSquare,
Send, Send,
Image as ImageIcon, Image as ImageIcon,
Minus,
PlusCircle,
Video, Video,
Loader2, Loader2,
FileText, FileText,
@@ -23,14 +25,38 @@ import {
Lock, Lock,
Unlock, Unlock,
Globe, Globe,
Download Download,
,Copy, ChevronUp, ChevronDown Copy,
ChevronUp,
ChevronDown,
ChevronLeft,
ChevronRight,
Move,
RotateCcw,
} from 'lucide-react'; } from 'lucide-react';
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types'; import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db'; import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db';
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard'; import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
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) => {
if (!seconds || seconds < 1) return 'Processando';
const totalSeconds = Math.round(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}min`;
if (minutes) return `${minutes}min`;
return `${remainingSeconds}s`;
};
const totalVideoDuration = (lessons: Lesson[]) => {
const seconds = lessons.reduce((total, lesson) => total + (lesson.media?.[0]?.durationSeconds || 0), 0);
return seconds ? formatVideoDuration(seconds) : 'Em processamento';
};
// 1. Create/Edit Course Modal // 1. Create/Edit Course Modal
const CourseModal: React.FC<{ const CourseModal: React.FC<{
@@ -44,44 +70,61 @@ const CourseModal: React.FC<{
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [status, setStatus] = useState<Course['status']>('draft'); const [status, setStatus] = useState<Course['status']>('draft');
const [thumbnailPreview, setThumbnailPreview] = useState<string>(''); const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
const [coverImageUrl, setCoverImageUrl] = useState<string>('');
const [coverImageZoom, setCoverImageZoom] = useState(1);
const [coverImagePositionX, setCoverImagePositionX] = useState(50);
const [coverImagePositionY, setCoverImagePositionY] = useState(50);
const [isAdjustingCoverPosition, setIsAdjustingCoverPosition] = useState(false);
const [coverPreviewError, setCoverPreviewError] = useState(false);
const coverFileRef = useRef<HTMLInputElement>(null);
const localCoverPreviewRef = useRef<string | null>(null);
const coverDragStartRef = useRef<{ pointerX: number; pointerY: number; positionX: number; positionY: number } | null>(null);
const [categories, setCategories] = useState<string[]>([]); const [categories, setCategories] = useState<string[]>([]);
const [coverConfigured, setCoverConfigured] = useState<boolean | null>(null);
const [coverUploadMessage, setCoverUploadMessage] = useState('');
const [isCoverUploading, setIsCoverUploading] = useState(false);
const attachmentFileRef = useRef<HTMLInputElement>(null);
const [assetConfigured, setAssetConfigured] = useState<boolean | null>(null);
const [isAssetUploading, setIsAssetUploading] = useState(false);
const [assetUploadMessage, setAssetUploadMessage] = useState('');
const [isDraggingAsset, setIsDraggingAsset] = useState(false);
const [editorStep, setEditorStep] = useState<1 | 2 | 3>(1);
// Lesson Management // Lesson Management
const [lessons, setLessons] = useState<Lesson[]>([]); const [lessons, setLessons] = useState<Lesson[]>([]);
// New Lesson State // New Lesson State
const [newLessonTitle, setNewLessonTitle] = useState(''); const [newLessonTitle, setNewLessonTitle] = useState('');
const [newLessonDuration, setNewLessonDuration] = useState('');
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
const [newLessonProvider, setNewLessonProvider] = useState('external');
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null); const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
const [newLessonIsFree, setNewLessonIsFree] = useState(false); const [newLessonIsFree, setNewLessonIsFree] = useState(false);
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null); const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle'); const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle');
const [bunnyUploadMessage, setBunnyUploadMessage] = useState(''); const [bunnyUploadMessage, setBunnyUploadMessage] = useState('');
const [deletingVideoId, setDeletingVideoId] = useState<string | null>(null);
// Tips & Global Attachments // Tips & Global Attachments
const [tips, setTips] = useState<string[]>([]); const [tips, setTips] = useState<string[]>([]);
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);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setEditorStep(1);
if (initialData) { if (initialData) {
setTitle(initialData.title); setTitle(initialData.title);
setDescription(initialData.description); setDescription(initialData.description);
setCategory(initialData.category); setCategory(initialData.category);
setStatus(initialData.status || 'published'); setStatus(initialData.status || 'published');
setThumbnailPreview(initialData.thumbnail); setThumbnailPreview(initialData.thumbnail);
setCoverImageUrl(initialData.thumbnail);
setCoverImageZoom(initialData.coverImageZoom || 1);
setCoverImagePositionX(initialData.coverImagePositionX ?? 50);
setCoverImagePositionY(initialData.coverImagePositionY ?? 50);
setTips(initialData.tips || []); setTips(initialData.tips || []);
setAttachments(initialData.attachments || []); setAttachments(initialData.attachments || []);
setLessons(initialData.lessons || []); setLessons(initialData.lessons || []);
@@ -91,16 +134,36 @@ const CourseModal: React.FC<{
setCategory(''); setCategory('');
setStatus('draft'); setStatus('draft');
setThumbnailPreview(''); setThumbnailPreview('');
setCoverImageUrl('');
setCoverImageZoom(1);
setCoverImagePositionX(50);
setCoverImagePositionY(50);
setTips([]); setTips([]);
setAttachments([]); setAttachments([]);
setLessons([]); setLessons([]);
} }
if (localCoverPreviewRef.current) {
URL.revokeObjectURL(localCoverPreviewRef.current);
localCoverPreviewRef.current = null;
}
setCoverPreviewError(false);
setIsAdjustingCoverPosition(false);
setNewLessonMedia(null); setNewLessonMedia(null);
setBunnyUploadState('idle'); setBunnyUploadState('idle');
setBunnyUploadMessage(''); setBunnyUploadMessage('');
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()
.then((response) => setCoverConfigured(response.data.configured))
.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);
@@ -110,6 +173,10 @@ const CourseModal: React.FC<{
} }
}, [isOpen, initialData]); }, [isOpen, initialData]);
useEffect(() => () => {
if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current);
}, []);
const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({ const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({
provider: 'bunny', provider: 'bunny',
externalId: video.id, externalId: video.id,
@@ -126,31 +193,82 @@ const CourseModal: React.FC<{
return; return;
} }
if (!bunnyConfigured) { if (!bunnyConfigured) {
alert('A integração com Bunny Stream ainda não foi configurada pelo administrador.'); alert('O envio de vídeos ainda não está disponível. Tente novamente mais tarde ou entre em contato com o administrador.');
return; return;
} }
setBunnyUploadState('uploading'); setBunnyUploadState('uploading');
setBunnyUploadMessage(`Enviando ${file.name} para Bunny Stream...`); setBunnyUploadMessage(`Enviando ${file.name}...`);
try { try {
const created = await instructorApi.createBunnyVideo(newLessonTitle.trim()); const created = await instructorApi.createBunnyVideo(newLessonTitle.trim());
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file); const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
const media = toBunnyMedia(uploaded.data); const media = toBunnyMedia(uploaded.data);
setNewLessonMedia(media); setNewLessonMedia(media);
if (!newLessonDuration.trim() && uploaded.data.durationSeconds) {
const minutes = Math.max(1, Math.round(uploaded.data.durationSeconds / 60));
setNewLessonDuration(`${minutes}m`);
}
setBunnyUploadState('uploaded'); setBunnyUploadState('uploaded');
setBunnyUploadMessage(uploaded.data.status === 'ready' setBunnyUploadMessage(uploaded.data.status === 'ready'
? 'Vídeo pronto para ser adicionado à aula.' ? 'Vídeo pronto para ser adicionado à aula.'
: 'Upload concluído. Bunny Stream está processando o vídeo; você já pode salvar a aula.'); : 'Upload concluído. O vídeo está sendo processado; você já pode salvar a aula.');
} catch (error) { } catch (error) {
setBunnyUploadState('error'); setBunnyUploadState('error');
setBunnyUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar o vídeo para Bunny Stream.'); setBunnyUploadMessage('Não foi possível enviar o vídeo. Tente novamente.');
} }
}; };
const handleCoverUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (!coverConfigured) {
setCoverUploadMessage('O armazenamento de imagens ainda não foi configurado pelo administrador.');
return;
}
setIsCoverUploading(true);
setCoverUploadMessage(`Enviando ${file.name}...`);
try {
const response = await instructorApi.uploadCover(file);
if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current);
const localPreviewUrl = URL.createObjectURL(file);
localCoverPreviewRef.current = localPreviewUrl;
setThumbnailPreview(localPreviewUrl);
setCoverImageUrl(response.data.coverImageUrl);
setCoverImageZoom(1);
setCoverImagePositionX(50);
setCoverImagePositionY(50);
setCoverPreviewError(false);
setCoverUploadMessage('Imagem de capa enviada com sucesso.');
} catch (error) {
setCoverUploadMessage(error instanceof Error ? error.message : 'Não foi possível enviar a imagem de capa.');
} finally {
setIsCoverUploading(false);
}
};
const startCoverPositioning = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
coverDragStartRef.current = {
pointerX: event.clientX,
pointerY: event.clientY,
positionX: coverImagePositionX,
positionY: coverImagePositionY,
};
};
const moveCoverPosition = (event: React.PointerEvent<HTMLDivElement>) => {
const start = coverDragStartRef.current;
if (!start) return;
const bounds = event.currentTarget.getBoundingClientRect();
const nextX = Math.min(100, Math.max(0, start.positionX - ((event.clientX - start.pointerX) / bounds.width) * 100));
const nextY = Math.min(100, Math.max(0, start.positionY - ((event.clientY - start.pointerY) / bounds.height) * 100));
setCoverImagePositionX(Number(nextX.toFixed(1)));
setCoverImagePositionY(Number(nextY.toFixed(1)));
};
const endCoverPositioning = (event: React.PointerEvent<HTMLDivElement>) => {
coverDragStartRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
const refreshLessonMedia = async (lessonId: string) => { const refreshLessonMedia = async (lessonId: string) => {
const lesson = lessons.find((item) => item.id === lessonId); const lesson = lessons.find((item) => item.id === lessonId);
const media = lesson?.media?.[0]; const media = lesson?.media?.[0];
@@ -160,7 +278,7 @@ const CourseModal: React.FC<{
const refreshed = toBunnyMedia(response.data); const refreshed = toBunnyMedia(response.data);
setLessons((current) => current.map((item) => item.id === lessonId ? { setLessons((current) => current.map((item) => item.id === lessonId ? {
...item, ...item,
duration: refreshed.durationSeconds ? `${Math.max(1, Math.round(refreshed.durationSeconds / 60))}m` : item.duration, duration: formatVideoDuration(refreshed.durationSeconds),
media: [refreshed], media: [refreshed],
} : item)); } : item));
} catch (error) { } catch (error) {
@@ -193,7 +311,7 @@ const CourseModal: React.FC<{
if (!update) return lesson; if (!update) return lesson;
return { return {
...lesson, ...lesson,
duration: update.media.durationSeconds ? `${Math.max(1, Math.round(update.media.durationSeconds / 60))}m` : lesson.duration, duration: formatVideoDuration(update.media.durationSeconds),
media: [update.media], media: [update.media],
}; };
})); }));
@@ -207,18 +325,18 @@ const CourseModal: React.FC<{
alert("Digite um título para a aula."); alert("Digite um título para a aula.");
return; return;
} }
if (!newLessonMedia && !newLessonVideoUrl.trim()) { if (!newLessonMedia) {
alert('Envie um vídeo para Bunny Stream ou informe uma URL de vídeo hospedado.'); alert('Envie o vídeo da aula antes de adicioná-la.');
return; return;
} }
const newLesson: Lesson = { const newLesson: Lesson = {
id: `lesson-${Date.now()}`, id: `lesson-${Date.now()}`,
title: newLessonTitle, title: newLessonTitle,
duration: newLessonDuration || '15m', duration: formatVideoDuration(newLessonMedia.durationSeconds),
videoUrl: newLessonMedia?.playbackUrl || newLessonVideoUrl, videoUrl: newLessonMedia.playbackUrl || '',
mediaProvider: newLessonMedia?.provider || newLessonProvider || 'external', mediaProvider: newLessonMedia.provider,
media: newLessonMedia ? [newLessonMedia] : undefined, media: [newLessonMedia],
isFree: newLessonIsFree, isFree: newLessonIsFree,
description: '' description: ''
}; };
@@ -226,17 +344,32 @@ const CourseModal: React.FC<{
setLessons([...lessons, newLesson]); setLessons([...lessons, newLesson]);
setNewLessonTitle(''); setNewLessonTitle('');
setNewLessonDuration('');
setNewLessonVideoUrl('');
setNewLessonProvider('external');
setNewLessonMedia(null); setNewLessonMedia(null);
setNewLessonIsFree(false); setNewLessonIsFree(false);
setBunnyUploadState('idle'); setBunnyUploadState('idle');
setBunnyUploadMessage(''); setBunnyUploadMessage('');
}; };
const removeLesson = (id: string) => { const removeLesson = async (lesson: Lesson) => {
setLessons(lessons.filter(l => l.id !== id)); const video = lesson.media?.find((media) => media.provider === 'bunny');
const message = video
? 'Excluir esta aula e o vídeo enviado? O vídeo será removido permanentemente e esta ação não poderá ser desfeita.'
: 'Excluir esta aula? Esta ação não poderá ser desfeita após salvar o curso.';
if (!window.confirm(message)) return;
if (video) {
setDeletingVideoId(video.externalId);
try {
await instructorApi.deleteBunnyVideo(video.externalId);
} catch (error) {
alert(error instanceof Error ? error.message : 'Não foi possível excluir o vídeo. Tente novamente.');
return;
} finally {
setDeletingVideoId(null);
}
}
setLessons((current) => current.filter((item) => item.id !== lesson.id));
}; };
const toggleLessonAccess = (id: string) => { const toggleLessonAccess = (id: string) => {
@@ -265,34 +398,94 @@ 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;
} }
const att: Attachment = { setIsAssetUploading(true);
id: `att-${Date.now()}`, setAssetUploadMessage(selectedFiles.length === 1 ? `Enviando ${selectedFiles[0].name}...` : `Enviando ${selectedFiles.length} arquivos...`);
name: newAttName.trim(), const uploadedAttachments: Attachment[] = [];
type: newAttType, const failedFiles: string[] = [];
url: newAttUrl.trim() || '#', for (const file of selectedFiles) {
size: newAttSize.trim() || '1.5 MB', try {
description: newAttDesc.trim() 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);
};
setAttachments([...attachments, att]); const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
setNewAttName(''); const files = event.target.files;
setNewAttSize(''); event.target.value = '';
setNewAttUrl(''); if (!files) return;
setNewAttDesc(''); await handleAttachmentUploads(files);
};
const handleAddLink = () => {
const url = newLinkUrl.trim();
if (!url) {
alert('Informe o link do material.');
return;
}
try {
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 {
alert('Informe um link HTTP ou HTTPS válido.');
}
}; };
const removeAttachment = (id: string) => { const removeAttachment = (id: string) => {
setAttachments(attachments.filter(a => a.id !== id)); setAttachments(attachments.filter(a => a.id !== id));
}; };
const handleSubmit = async (e: React.FormEvent) => { const goToEditorStep = (nextStep: 1 | 2 | 3) => {
e.preventDefault(); if (nextStep > 1 && (!title.trim() || !category)) {
alert('Informe o título e a categoria do curso antes de continuar.');
return;
}
if (nextStep > 2 && lessons.length === 0) {
alert('Adicione pelo menos uma aula antes de continuar.');
return;
}
setEditorStep(nextStep);
};
const handleSubmit = async () => {
if (lessons.length === 0) { if (lessons.length === 0) {
alert("Adicione pelo menos uma aula à trilha."); alert("Adicione pelo menos uma aula à trilha.");
return; return;
@@ -300,7 +493,7 @@ const CourseModal: React.FC<{
setIsSubmitting(true); setIsSubmitting(true);
const durationStr = `${lessons.length} Aulas`; const durationStr = totalVideoDuration(lessons);
try { try {
await onSave({ await onSave({
@@ -309,7 +502,10 @@ const CourseModal: React.FC<{
description, description,
category: category || categories[0], category: category || categories[0],
status, status,
thumbnail: thumbnailPreview || '/course-placeholder.svg', thumbnail: coverImageUrl || '/course-placeholder.svg',
coverImageZoom,
coverImagePositionX,
coverImagePositionY,
duration: durationStr, duration: durationStr,
instructor: initialData?.instructor || '', instructor: initialData?.instructor || '',
views: initialData?.views || 0, views: initialData?.views || 0,
@@ -331,42 +527,127 @@ const CourseModal: React.FC<{
return ( return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={onClose} /> <div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={onClose} />
<div className="relative w-full max-w-6xl bg-[#141416] rounded-3xl border border-white/10 p-6 md:p-8 shadow-2xl animate-fade-in-up max-h-[92vh] overflow-y-auto flex flex-col lg:flex-row gap-8"> <div className="relative w-full max-w-5xl bg-[#141416] rounded-3xl border border-white/10 p-5 md:p-8 shadow-2xl animate-fade-in-up max-h-[92vh] overflow-y-auto">
<div className="flex items-start justify-between gap-4 mb-7">
{/* Left Column: Course Details, Tips & Downloads */} <div>
<div className="w-full lg:w-5/12 space-y-6"> <p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
<div className="flex justify-between items-center lg:hidden mb-2"> <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="text-xl font-bold text-white">{initialData ? 'Editar Curso' : 'Criar Curso'}</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.' : 'Envie os arquivos de apoio para seus alunos.'}</p>
<button onClick={onClose}><X className="text-white" /></button>
</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>
</div>
<div className="mb-8 grid grid-cols-3 gap-2" aria-label="Etapas de criação do curso">
{[
{ step: 1, label: 'Curso' },
{ step: 2, label: 'Aulas' },
{ step: 3, label: 'Materiais' },
].map(({ step, label }) => (
<button key={step} type="button" onClick={() => goToEditorStep(step as 1 | 2 | 3)} className={`flex items-center gap-2 rounded-xl border px-3 py-3 text-left transition-colors ${editorStep === step ? 'border-orange-500/50 bg-orange-500/10 text-white' : editorStep > step ? 'border-emerald-500/20 bg-emerald-500/[0.04] text-emerald-300' : 'border-white/10 bg-black/20 text-gray-500 hover:text-gray-300'}`}>
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${editorStep === step ? 'bg-orange-500 text-white' : editorStep > step ? 'bg-emerald-500/20 text-emerald-300' : 'bg-white/5'}`}>{editorStep > step ? <Check className="w-3.5 h-3.5" /> : step}</span>
<span className="text-xs font-semibold uppercase tracking-wide">{label}</span>
</button>
))}
</div>
<div className={`w-full ${editorStep === 1 || editorStep === 3 ? 'block' : 'hidden'}`}>
<div className={editorStep === 1 ? 'space-y-6' : 'hidden'}>
{/* Thumbnail */} {/* Thumbnail */}
<div> <div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label> <label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
<div <div
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group" onClick={() => { if (!isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
onKeyDown={(event) => { if ((event.key === 'Enter' || event.key === ' ') && !isCoverUploading && coverConfigured) coverFileRef.current?.click(); }}
role="button"
tabIndex={0}
aria-label="Enviar imagem de capa"
className={`w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center transition-colors relative overflow-hidden group ${coverConfigured && !isCoverUploading ? 'cursor-pointer hover:border-orange-500/50' : 'cursor-not-allowed opacity-70'}`}
> >
{thumbnailPreview ? ( {thumbnailPreview ? (
<> <>
<img src={thumbnailPreview} alt="Preview" className="w-full h-full object-cover opacity-70 group-hover:opacity-40 transition-opacity" /> {coverPreviewError ? (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <div className="p-6 text-center">
<span className="bg-orange-500 px-4 py-2 rounded-xl text-xs font-bold shadow-lg">Alterar Capa</span> <ImageIcon className="mx-auto mb-2 h-9 w-9 text-amber-400" />
<p className="text-sm font-semibold text-white">Não foi possível carregar a imagem</p>
<p className="mt-1 text-xs text-gray-400">Tente enviá-la novamente. Se o problema continuar, verifique a configuração de entrega de imagens.</p>
</div>
) : (
<CourseCoverImage
source={thumbnailPreview}
alt="Prévia da imagem de capa"
onFinalError={() => setCoverPreviewError(true)}
className="h-full w-full object-cover opacity-80 transition-transform duration-200"
style={{
transform: `scale(${coverImageZoom})`,
objectPosition: `${coverImagePositionX}% ${coverImagePositionY}%`,
}}
/>
)}
{isAdjustingCoverPosition && !coverPreviewError && (
<div
className="absolute inset-0 z-10 cursor-grab touch-none active:cursor-grabbing"
onClick={(event) => event.stopPropagation()}
onPointerDown={startCoverPositioning}
onPointerMove={moveCoverPosition}
onPointerUp={endCoverPositioning}
onPointerCancel={endCoverPositioning}
>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg bg-black/75 px-4 py-2 text-center text-xs font-semibold text-white backdrop-blur-sm">
Arraste a imagem para ajustar o enquadramento
</div>
</div>
)}
<div className="absolute inset-x-3 bottom-3 z-20 flex items-center justify-between gap-3">
<span className="rounded-lg bg-black/70 px-3 py-2 text-xs font-semibold text-white backdrop-blur-sm">Clique na imagem para alterar</span>
<div className="flex items-center rounded-lg border border-white/10 bg-black/70 p-1 backdrop-blur-sm">
<button
type="button"
aria-label="Reduzir zoom da imagem"
disabled={coverImageZoom <= 1}
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.max(1, Number((current - 0.1).toFixed(1)))); }}
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
><Minus className="h-4 w-4" /></button>
<span className="min-w-12 text-center text-[11px] font-bold text-white">{Math.round(coverImageZoom * 100)}%</span>
<button
type="button"
aria-label="Aumentar zoom da imagem"
disabled={coverImageZoom >= 2.5}
onClick={(event) => { event.stopPropagation(); setCoverImageZoom((current) => Math.min(2.5, Number((current + 0.1).toFixed(1)))); }}
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-30"
><PlusCircle className="h-4 w-4" /></button>
<span className="mx-1 h-5 w-px bg-white/10" />
<button
type="button"
aria-label="Ajustar posição da imagem"
aria-pressed={isAdjustingCoverPosition}
onClick={(event) => { event.stopPropagation(); setIsAdjustingCoverPosition((current) => !current); }}
className={`rounded-md p-1.5 transition-colors ${isAdjustingCoverPosition ? 'bg-orange-500 text-white' : 'text-white hover:bg-white/10'}`}
><Move className="h-4 w-4" /></button>
<button
type="button"
aria-label="Redefinir zoom e posição da imagem"
onClick={(event) => { event.stopPropagation(); setCoverImageZoom(1); setCoverImagePositionX(50); setCoverImagePositionY(50); setIsAdjustingCoverPosition(false); }}
className="rounded-md p-1.5 text-white transition-colors hover:bg-white/10"
><RotateCcw className="h-4 w-4" /></button>
</div>
</div> </div>
</> </>
) : ( ) : (
<div className="text-center p-6"> <div className="text-center p-6">
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" /> <ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
<p className="text-gray-300 text-xs font-medium">Add a hosted cover image URL below</p> <p className="text-gray-300 text-xs font-medium">{isCoverUploading ? 'Enviando imagem...' : 'Clique para enviar uma imagem de capa'}</p>
</div> </div>
)} )}
</div> </div>
<input <input
type="url" ref={coverFileRef}
value={thumbnailPreview} type="file"
onChange={(event) => setThumbnailPreview(event.target.value)} accept="image/jpeg,image/png,image/webp"
placeholder="Hosted cover image URL" className="hidden"
className="w-full mt-2 bg-black/50 text-white text-xs border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50" disabled={!coverConfigured || isCoverUploading}
onChange={handleCoverUpload}
/> />
<p className={`mt-2 text-[11px] ${coverUploadMessage.includes('sucesso') ? 'text-emerald-300' : coverUploadMessage ? 'text-amber-300' : 'text-gray-500'}`}>{coverUploadMessage || (coverConfigured ? 'JPG, PNG ou WebP. Tamanho máximo: 10 MB.' : 'O envio de imagens estará disponível assim que o armazenamento de mídia for configurado.')}</p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -376,7 +657,7 @@ const CourseModal: React.FC<{
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Masterclass: Escala de Tráfego Pago" placeholder="Ex.: Estratégias de tráfego pago para e-commerce"
className="w-full bg-black/50 text-white text-base font-bold border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50" className="w-full bg-black/50 text-white text-base font-bold border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50"
required required
/> />
@@ -399,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>
@@ -412,14 +693,19 @@ const CourseModal: React.FC<{
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
rows={3} rows={3}
placeholder="O que os alunos vão aprender e dominar neste curso?" placeholder="Descreva os tópicos, as competências e os resultados esperados para os alunos."
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-4 py-2.5 text-xs focus:outline-none focus:border-orange-500/50 resize-none leading-relaxed" className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-4 py-2.5 text-xs focus:outline-none focus:border-orange-500/50 resize-none leading-relaxed"
/> />
</div> </div>
</div> </div>
<div className="flex justify-end border-t border-white/5 pt-5">
<button type="button" onClick={() => goToEditorStep(2)} disabled={!title.trim() || !category} className="inline-flex items-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-40">Continuar para aulas <ChevronRight className="w-4 h-4" /></button>
</div>
</div>
{/* Downloadable Materials Section */} {/* Downloadable Materials Section */}
<div className="bg-black/40 p-4 rounded-2xl border border-white/10 space-y-3"> <div className={editorStep === 3 ? 'bg-black/40 p-5 rounded-2xl border border-white/10 space-y-3' : 'hidden'}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-orange-400 uppercase tracking-wider flex items-center gap-1.5"> <h3 className="text-xs font-bold text-orange-400 uppercase tracking-wider flex items-center gap-1.5">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
@@ -427,52 +713,54 @@ const CourseModal: React.FC<{
</h3> </h3>
</div> </div>
{/* Add attachment inputs */} <input
<div className="space-y-2 pt-1"> ref={attachmentFileRef}
<div className="grid grid-cols-3 gap-2"> type="file"
<input multiple
type="text" 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"
value={newAttName} className="hidden"
onChange={(e) => setNewAttName(e.target.value)} onChange={(event) => void handleAttachmentUpload(event)}
placeholder="Nome do arquivo (ex: Planilha_ROAS.xlsx)" />
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" <div
/> onDragEnter={(event) => { event.preventDefault(); setIsDraggingAsset(true); }}
<select onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; setIsDraggingAsset(true); }}
value={newAttType} onDragLeave={(event) => { event.preventDefault(); setIsDraggingAsset(false); }}
onChange={(e) => setNewAttType(e.target.value as AttachmentType)} onDrop={(event) => { event.preventDefault(); setIsDraggingAsset(false); void handleAttachmentUploads(event.dataTransfer.files); }}
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none" 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> </div>
<option value="link">Link</option>
</select>
</div>
<div className="flex gap-2">
<input
type="text"
value={newAttSize}
onChange={(e) => setNewAttSize(e.target.value)}
placeholder="Tamanho (ex: 2.4 MB)"
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="Breve descrição do material..."
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 */}
@@ -482,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"
@@ -494,21 +782,16 @@ const CourseModal: React.FC<{
</div> </div>
))} ))}
</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">
<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 não publicado' : 'Salvar e publicar'}</button>
</div>
</div> </div>
</div> </div>
{/* Right Column: Lessons Builder */} {/* Right Column: Lessons Builder */}
<div className="flex-1 flex flex-col justify-between"> <div className={editorStep === 2 ? 'w-full flex flex-col justify-between' : 'hidden'}>
<div> <div>
<div className="hidden lg:flex justify-between items-center mb-6">
<h2 className="text-2xl font-extrabold text-white">
{initialData ? 'Gerenciar Trilha & Aulas' : 'Estruturar Grade de Aulas'}
</h2>
<button onClick={onClose} className="p-2 bg-white/5 rounded-full hover:bg-white/20 transition-colors">
<X className="w-5 h-5 text-white" />
</button>
</div>
<div className="bg-black/50 rounded-2xl border border-white/10 overflow-hidden mb-6"> <div className="bg-black/50 rounded-2xl border border-white/10 overflow-hidden mb-6">
<div className="p-4 border-b border-white/5 bg-zinc-900/50 flex items-center justify-between"> <div className="p-4 border-b border-white/5 bg-zinc-900/50 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -540,7 +823,7 @@ const CourseModal: React.FC<{
<span className="flex items-center gap-1"><Video className="w-3 h-3 text-orange-400/70"/> {lesson.duration}</span> <span className="flex items-center gap-1"><Video className="w-3 h-3 text-orange-400/70"/> {lesson.duration}</span>
{lesson.media?.[0]?.provider === 'bunny' && ( {lesson.media?.[0]?.provider === 'bunny' && (
<span className={`font-bold px-1.5 py-0.2 rounded text-[9px] uppercase ${lesson.media[0].status === 'ready' ? 'text-emerald-400 bg-emerald-500/10' : lesson.media[0].status === 'failed' ? 'text-red-400 bg-red-500/10' : 'text-amber-400 bg-amber-500/10'}`}> <span className={`font-bold px-1.5 py-0.2 rounded text-[9px] uppercase ${lesson.media[0].status === 'ready' ? 'text-emerald-400 bg-emerald-500/10' : lesson.media[0].status === 'failed' ? 'text-red-400 bg-red-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
Bunny: {lesson.media[0].status === 'ready' ? 'pronto' : lesson.media[0].status === 'failed' ? 'falhou' : 'processando'} Vídeo {lesson.media[0].status === 'ready' ? 'pronto' : lesson.media[0].status === 'failed' ? 'com erro' : 'processando'}
</span> </span>
)} )}
{lesson.isFree ? ( {lesson.isFree ? (
@@ -574,10 +857,12 @@ const CourseModal: React.FC<{
</button> </button>
<button <button
type="button" type="button"
onClick={() => removeLesson(lesson.id)} disabled={Boolean(lesson.media?.[0]?.provider === 'bunny' && deletingVideoId === lesson.media[0].externalId)}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors" onClick={() => void removeLesson(lesson)}
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors disabled:cursor-not-allowed disabled:opacity-40"
title="Excluir aula e vídeo"
> >
<Trash2 className="w-4 h-4" /> {lesson.media?.[0]?.provider === 'bunny' && deletingVideoId === lesson.media[0].externalId ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button> </button>
</div> </div>
</div> </div>
@@ -588,34 +873,27 @@ const CourseModal: React.FC<{
<div className="p-4 bg-zinc-950 border-t border-white/5 space-y-3"> <div className="p-4 bg-zinc-950 border-t border-white/5 space-y-3">
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5"> <h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5 text-orange-400" /> <Plus className="w-3.5 h-3.5 text-orange-400" />
Cadastrar Nova Aula Adicionar aula
</h4> </h4>
<div className="flex gap-2"> <div>
<input <input
type="text" type="text"
value={newLessonTitle} value={newLessonTitle}
onChange={(e) => setNewLessonTitle(e.target.value)} onChange={(e) => setNewLessonTitle(e.target.value)}
placeholder="Título da Aula (ex: 01. Configuração do Pixel)" placeholder="Título da aula"
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50" className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
/>
<input
type="text"
value={newLessonDuration}
onChange={(e) => setNewLessonDuration(e.target.value)}
placeholder="Duração (ex: 25m)"
className="w-28 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
/> />
</div> </div>
<div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.04] p-3 space-y-2"> <div className="rounded-xl border border-orange-500/20 bg-orange-500/[0.04] p-3 space-y-2">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-xs font-bold text-orange-200">Vídeo Bunny Stream</p> <p className="text-xs font-bold text-orange-200">Vídeo da aula</p>
<p className="text-[11px] text-gray-500">O vídeo será protegido e reproduzido no player seguro da Bunny.</p> <p className="text-[11px] text-gray-500">Envie o arquivo para adicioná-lo à aula. O processamento ocorre automaticamente.</p>
</div> </div>
<label className={`cursor-pointer rounded-lg px-3 py-2 text-xs font-bold transition-colors ${bunnyConfigured && bunnyUploadState !== 'uploading' ? 'bg-orange-500 hover:bg-orange-600 text-white' : 'bg-white/10 text-gray-500 cursor-not-allowed'}`}> <label className={`cursor-pointer rounded-lg px-3 py-2 text-xs font-bold transition-colors ${bunnyConfigured && bunnyUploadState !== 'uploading' ? 'bg-orange-500 hover:bg-orange-600 text-white' : 'bg-white/10 text-gray-500 cursor-not-allowed'}`}>
{bunnyUploadState === 'uploading' ? 'Enviando...' : 'Selecionar vídeo'} {bunnyUploadState === 'uploading' ? 'Enviando...' : 'Enviar vídeo'}
<input <input
type="file" type="file"
accept="video/*" accept="video/*"
@@ -625,32 +903,17 @@ const CourseModal: React.FC<{
/> />
</label> </label>
</div> </div>
{bunnyConfigured === false && <p className="text-[11px] text-amber-300">Bunny Stream não está configurado no servidor. Peça ao administrador para adicionar as variáveis no Portainer.</p>} {bunnyConfigured === false && <p className="text-[11px] text-amber-300">O envio de vídeos está indisponível no momento. Entre em contato com o administrador.</p>}
{bunnyUploadMessage && <p className={`text-[11px] ${bunnyUploadState === 'error' ? 'text-red-300' : bunnyUploadState === 'uploaded' ? 'text-emerald-300' : 'text-gray-400'}`}>{bunnyUploadMessage}</p>} {bunnyUploadMessage && <p className={`text-[11px] ${bunnyUploadState === 'error' ? 'text-red-300' : bunnyUploadState === 'uploaded' ? 'text-emerald-300' : 'text-gray-400'}`}>{bunnyUploadMessage}</p>}
{newLessonMedia && ( {newLessonMedia && (
<div className="flex items-center justify-between rounded-lg bg-black/30 px-3 py-2 text-[11px] text-gray-300"> <div className="flex items-center justify-between rounded-lg bg-black/30 px-3 py-2 text-[11px] text-gray-300">
<span>Vídeo Bunny vinculado ({newLessonMedia.status === 'ready' ? 'pronto' : 'processando'})</span> <span>Vídeo enviado ({newLessonMedia.status === 'ready' ? 'pronto' : 'processando'})</span>
<button type="button" onClick={() => setNewLessonMedia(null)} className="text-gray-400 hover:text-red-300">Remover</button> <button type="button" onClick={() => setNewLessonMedia(null)} className="text-gray-400 hover:text-red-300">Remover</button>
</div> </div>
)} )}
</div> </div>
<div className="flex gap-2 items-center pt-1"> <div className="flex justify-end pt-1">
<input
type="text"
value={newLessonProvider}
onChange={(event) => setNewLessonProvider(event.target.value)}
placeholder="Provider externo"
className="w-40 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
<input
type="url"
value={newLessonVideoUrl}
onChange={(event) => setNewLessonVideoUrl(event.target.value)}
placeholder="Ou cole uma URL externa de vídeo"
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
<button <button
type="button" type="button"
onClick={handleAddLesson} onClick={handleAddLesson}
@@ -663,14 +926,14 @@ const CourseModal: React.FC<{
</div> </div>
</div> </div>
{/* Submit Button */} <div className="flex flex-col-reverse gap-3 border-t border-white/5 pt-5 sm:flex-row sm:justify-between">
<div className="pt-4 border-t border-white/5"> <button type="button" onClick={() => setEditorStep(1)} 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 curso</button>
<button <button
onClick={handleSubmit} type="button"
disabled={isSubmitting} onClick={() => goToEditorStep(3)}
className="w-full bg-orange-500 hover:bg-orange-600 text-white font-extrabold text-base py-4 rounded-2xl active:scale-95 transition-all shadow-lg shadow-orange-500/25" 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"
> >
{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como Rascunho' : (initialData ? 'Salvar e Publicar Curso' : 'Publicar Curso & Materiais')} Continuar para materiais <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -681,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);
@@ -713,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);
@@ -734,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');
@@ -875,15 +1150,19 @@ export const ManageCourses: React.FC = () => {
<div> <div>
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]"> <div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]">
<img <CourseCoverImage
src={course.thumbnail} source={course.thumbnail}
alt={course.title} alt={course.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
style={{
transform: `scale(${course.coverImageZoom || 1})`,
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
}}
/> />
<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 */}
@@ -970,7 +1249,7 @@ export const ManageCourses: React.FC = () => {
<textarea <textarea
value={replyText} value={replyText}
onChange={(e) => setReplyText(e.target.value)} onChange={(e) => setReplyText(e.target.value)}
placeholder="Escreva a resposta para o aluno..." placeholder="Escreva uma resposta clara para o aluno..."
className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none" className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none"
rows={3} rows={3}
/> />

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react'; import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react';
import { Course } from '../types'; import { Course } from '../types';
import { getMyLearningCourses } from '../services/db'; import { getMyLearningCourses } from '../services/db';
import { CourseCoverImage } from '../components/CourseCoverImage';
export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => { export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
@@ -28,7 +29,7 @@ export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onP
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{courses.map((course) => ( {courses.map((course) => (
<button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors"> <button key={course.id} onClick={() => onPlay(course)} className="group overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 text-left hover:border-orange-500/50 transition-colors">
<div className="aspect-video relative bg-zinc-900"><img src={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 group-hover:opacity-100 transition-opacity" /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div> <div className="aspect-video relative overflow-hidden bg-zinc-900"><CourseCoverImage source={course.thumbnail} alt="" className="h-full w-full object-cover opacity-75 transition-opacity group-hover:opacity-100" style={{ transform: `scale(${course.coverImageZoom || 1})`, objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%` }} /><div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent" /><span className="absolute left-4 bottom-4 inline-flex items-center gap-2 rounded-full bg-orange-500 px-3 py-2 text-xs font-bold text-white"><PlayCircle className="w-4 h-4" /> Continuar</span></div>
<div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div> <div className="p-5"><p className="text-xs text-orange-400 font-semibold uppercase tracking-wider">{course.category}</p><h2 className="mt-2 font-bold text-white">{course.title}</h2><div className="mt-4 h-1.5 overflow-hidden rounded-full bg-white/10"><div className="h-full rounded-full bg-orange-500" style={{ width: `${course.progress || 0}%` }} /></div><p className="mt-2 text-xs text-gray-400">{course.progress || 0}% concluído</p></div>
</button> </button>
))} ))}

135
pages/ProfilePage.tsx Normal file
View File

@@ -0,0 +1,135 @@
import React, { useEffect, useRef, useState } from 'react';
import { Camera, ExternalLink, Globe2, Instagram, Linkedin, Loader2, Save, UserRound, Youtube } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { Profile, ProfileInput, instructorApi, profileApi } from '../services/api';
import { CourseCoverImage } from '../components/CourseCoverImage';
import { useToast } from '../context/ToastContext';
import { useNavigate } from 'react-router-dom';
const emptyProfile = (user: { id: string; email: string; name: string; role: string }): Profile => ({
id: user.id,
email: user.email,
name: user.name,
role: user.role === 'superadmin' ? 'admin' : user.role === 'professor' ? 'instructor' : 'student',
avatarImageUrl: null,
headline: '',
bio: '',
websiteUrl: null,
linkedinUrl: null,
instagramUrl: null,
youtubeUrl: null,
isPublic: true,
});
const inputClass = 'w-full rounded-xl border border-white/10 bg-zinc-900 px-4 py-3 text-sm text-white outline-none transition focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15';
export const ProfilePage: React.FC = () => {
const { user, updateCurrentUser } = useAuth();
const { showToast } = useToast();
const navigate = useNavigate();
const fileInput = useRef<HTMLInputElement>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (!user) return;
profileApi.me()
.then((response) => setProfile(response.data))
.catch(() => {
setProfile(emptyProfile(user));
showToast('Não foi possível carregar todos os dados do perfil.', 'error');
})
.finally(() => setIsLoading(false));
}, [showToast, user]);
if (isLoading || !profile || !user) {
return <main className="min-h-[65vh] pt-32 flex items-center justify-center"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
}
const update = <K extends keyof Profile>(key: K, value: Profile[K]) => setProfile((current) => current ? { ...current, [key]: value } : current);
const uploadAvatar = async (file?: File) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
showToast('Selecione uma imagem JPG, PNG ou WebP.', 'error');
return;
}
setIsUploading(true);
try {
const response = await instructorApi.uploadCover(file);
update('avatarImageUrl', response.data.coverImageUrl);
showToast('Foto enviada. Salve o perfil para publicar a alteração.', 'success');
} catch {
showToast('Não foi possível enviar a foto.', 'error');
} finally {
setIsUploading(false);
}
};
const save = async (event: React.FormEvent) => {
event.preventDefault();
const input: ProfileInput = {
name: profile.name,
avatarImageUrl: profile.avatarImageUrl,
headline: profile.headline,
bio: profile.bio,
websiteUrl: profile.websiteUrl,
linkedinUrl: profile.linkedinUrl,
instagramUrl: profile.instagramUrl,
youtubeUrl: profile.youtubeUrl,
isPublic: profile.isPublic,
};
setIsSaving(true);
try {
const response = await profileApi.update(input);
setProfile(response.data);
updateCurrentUser({ id: user.id, email: user.email, name: response.data.name, role: response.data.role });
showToast('Perfil salvo com sucesso.', 'success');
} catch {
showToast('Não foi possível salvar o perfil. Verifique os links informados.', 'error');
} finally {
setIsSaving(false);
}
};
return (
<main className="mx-auto min-h-screen w-full max-w-5xl px-5 pb-20 pt-28 sm:px-8">
<div className="mb-8 flex flex-col justify-between gap-5 sm:flex-row sm:items-end">
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua conta</p>
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">Meu perfil</h1>
<p className="mt-2 max-w-xl text-sm text-gray-400">Apresente quem você é, compartilhe seus links e escolha a visibilidade do seu perfil.</p>
</div>
{profile.isPublic && <button onClick={() => navigate(`/perfis/${user.id}`)} className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/10"><ExternalLink className="h-4 w-4" /> Ver perfil público</button>}
</div>
<form onSubmit={save} className="overflow-hidden rounded-3xl border border-white/10 bg-zinc-950/80 shadow-2xl shadow-black/30">
<section className="border-b border-white/10 bg-gradient-to-r from-orange-500/15 via-zinc-950 to-zinc-950 p-6 sm:p-8">
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<div className="relative h-28 w-28 shrink-0 overflow-hidden rounded-full border-4 border-zinc-950 bg-zinc-900 shadow-xl">
{profile.avatarImageUrl ? <CourseCoverImage source={profile.avatarImageUrl} alt={`Foto de ${profile.name}`} className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center bg-orange-500/15"><UserRound className="h-11 w-11 text-orange-300" /></div>}
<button type="button" disabled={isUploading} onClick={() => fileInput.current?.click()} className="absolute inset-x-0 bottom-0 flex h-9 items-center justify-center bg-black/70 text-xs font-semibold text-white transition hover:bg-black/90 disabled:opacity-60">{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <><Camera className="mr-1.5 h-3.5 w-3.5" /> Alterar</>}</button>
</div>
<div><h2 className="text-xl font-bold text-white">{profile.name}</h2><p className="mt-1 text-sm text-gray-400">{user.email}</p><p className="mt-3 text-xs text-orange-200/80">A foto e a apresentação ficam visíveis quando seu perfil estiver público.</p></div>
</div>
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={(event) => { void uploadAvatar(event.target.files?.[0]); event.currentTarget.value = ''; }} />
</section>
<div className="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1fr_0.8fr]">
<section className="space-y-5">
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Nome</span><input value={profile.name} onChange={(event) => update('name', event.target.value)} className={inputClass} maxLength={120} required /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Título profissional</span><input value={profile.headline} onChange={(event) => update('headline', event.target.value)} placeholder="Ex.: Especialista em crescimento e mídia paga" className={inputClass} maxLength={180} /></label>
<label className="block"><span className="mb-2 block text-sm font-semibold text-white">Sobre você</span><textarea value={profile.bio} onChange={(event) => update('bio', event.target.value)} placeholder="Conte brevemente sobre você, seus interesses ou sua experiência." className={`${inputClass} min-h-40 resize-y`} maxLength={3000} /></label>
</section>
<section className="rounded-2xl border border-white/10 bg-black/30 p-5 sm:p-6"><div className="mb-5"><h2 className="font-bold text-white">Links e visibilidade</h2><p className="mt-1 text-xs leading-relaxed text-gray-500">Use links completos, começando com https://.</p></div><div className="space-y-4"><UrlField icon={<Globe2 />} label="Site" value={profile.websiteUrl} onChange={(value) => update('websiteUrl', value || null)} placeholder="https://seusite.com" /><UrlField icon={<Linkedin />} label="LinkedIn" value={profile.linkedinUrl} onChange={(value) => update('linkedinUrl', value || null)} placeholder="https://linkedin.com/in/seu-perfil" /><UrlField icon={<Instagram />} label="Instagram" value={profile.instagramUrl} onChange={(value) => update('instagramUrl', value || null)} placeholder="https://instagram.com/seu-perfil" /><UrlField icon={<Youtube />} label="YouTube" value={profile.youtubeUrl} onChange={(value) => update('youtubeUrl', value || null)} placeholder="https://youtube.com/@seu-canal" /></div><label className="mt-6 flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={profile.isPublic} onChange={(event) => update('isPublic', event.target.checked)} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Perfil público</span><span className="mt-1 block text-xs leading-relaxed text-gray-500">Permite que outras pessoas vejam sua apresentação e seus links. Perfis de instrutores também exibem seus cursos publicados.</span></span></label></section>
</div>
<div className="flex justify-end border-t border-white/10 bg-black/20 p-5 sm:px-8"><button disabled={isSaving} className="inline-flex min-w-40 items-center justify-center gap-2 rounded-xl bg-orange-500 px-5 py-3 text-sm font-bold text-white shadow-lg shadow-orange-500/20 transition hover:bg-orange-600 disabled:opacity-60">{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} Salvar perfil</button></div>
</form>
</main>
);
};
const UrlField: React.FC<{ icon: React.ReactNode; label: string; value: string | null; placeholder: string; onChange: (value: string) => void }> = ({ icon, label, value, placeholder, onChange }) => <label className="block"><span className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400"><span className="text-orange-400 [&>svg]:h-4 [&>svg]:w-4">{icon}</span>{label}</span><input type="url" value={value || ''} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} className={inputClass} /></label>;

File diff suppressed because one or more lines are too long

112
pages/TrailDetail.tsx Normal file

File diff suppressed because one or more lines are too long

34
pages/TrailPage.tsx Normal file
View 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>;
};

View File

@@ -0,0 +1,3 @@
alter table courses
add column cover_image_zoom real not null default 1
check (cover_image_zoom >= 1 and cover_image_zoom <= 2.5);

View File

@@ -0,0 +1,5 @@
alter table courses
add column cover_image_position_x real not null default 50
check (cover_image_position_x >= 0 and cover_image_position_x <= 100),
add column cover_image_position_y real not null default 50
check (cover_image_position_y >= 0 and cover_image_position_y <= 100);

View File

@@ -0,0 +1,32 @@
-- A learning path is deliberately only a curated, ordered collection of
-- existing courses. Lessons remain owned by their courses, so a course edit
-- is reflected everywhere that course appears.
create table learning_paths (
id uuid primary key default gen_random_uuid(),
title text not null check (char_length(trim(title)) between 1 and 160),
description text not null default '',
cover_image_url text,
cover_image_zoom real not null default 1 check (cover_image_zoom between 1 and 2.5),
cover_image_position_x real not null default 50 check (cover_image_position_x between 0 and 100),
cover_image_position_y real not null default 50 check (cover_image_position_y between 0 and 100),
status course_status not null default 'draft',
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint learning_paths_published_at_check check (
(status = 'published' and published_at is not null) or status <> 'published'
)
);
create table learning_path_courses (
learning_path_id uuid not null references learning_paths(id) on delete cascade,
course_id uuid not null references courses(id) on delete restrict,
position integer not null check (position > 0),
primary key (learning_path_id, course_id),
unique (learning_path_id, position)
);
create index learning_paths_published_index on learning_paths (published_at desc) where status = 'published';
create index learning_path_courses_position_index on learning_path_courses (learning_path_id, position);
create trigger learning_paths_set_updated_at before update on learning_paths for each row execute function set_updated_at();

View File

@@ -0,0 +1,8 @@
-- Both content types are intentionally composed from existing courses. The
-- distinction is editorial: a "trilha" is a guided sequence and a
-- "formação" is a broader curriculum collection.
alter table learning_paths
add column kind text not null default 'trail'
check (kind in ('trail', 'formation'));
create index learning_paths_kind_index on learning_paths (kind, published_at desc);

View File

@@ -0,0 +1,21 @@
-- Homepage banners are independent editorial content. A banner can either
-- reference a course (and always use its current cover) or use an uploaded
-- image stored in Bunny Storage.
create table home_banners (
id uuid primary key default gen_random_uuid(),
kind text not null check (kind in ('course', 'custom')),
course_id uuid references courses(id) on delete cascade,
image_url text,
title text not null default '',
description text not null default '',
position integer not null check (position > 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint home_banners_source_check check (
(kind = 'course' and course_id is not null and image_url is null) or
(kind = 'custom' and course_id is null and image_url is not null)
),
unique (position)
);
create trigger home_banners_set_updated_at before update on home_banners for each row execute function set_updated_at();

View File

@@ -0,0 +1,5 @@
-- Trilhas and Formações described the same object: an ordered collection of
-- existing courses. Preserve every record while collapsing the duplicate type.
update learning_paths set kind = 'trail' where kind = 'formation';
drop index if exists learning_paths_kind_index;
alter table learning_paths drop column kind;

View File

@@ -0,0 +1,13 @@
alter table users
add column if not exists avatar_image_url text,
add column if not exists profile_headline text not null default '',
add column if not exists profile_bio text not null default '',
add column if not exists website_url text,
add column if not exists linkedin_url text,
add column if not exists instagram_url text,
add column if not exists youtube_url text,
add column if not exists profile_is_public boolean not null default true;
create index if not exists users_public_instructor_profiles_index
on users (id)
where profile_is_public and role in ('instructor', 'admin');

View File

@@ -0,0 +1,16 @@
create table if not exists bunny_video_uploads (
video_id text primary key,
owner_id uuid not null references users(id) on delete cascade,
created_at timestamptz not null default now()
);
create index if not exists bunny_video_uploads_owner_index on bunny_video_uploads (owner_id);
-- Preserve access to Bunny videos created before ownership was tracked.
insert into bunny_video_uploads (video_id, owner_id)
select distinct on (lm.external_id) lm.external_id, c.instructor_id
from lesson_media lm
join lessons l on l.id = lm.lesson_id
join courses c on c.id = l.course_id
where lm.provider = 'bunny'
on conflict (video_id) do nothing;

View 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;

View File

@@ -11,6 +11,7 @@ import { learningRoutes } from './routes/learning.js';
import { adminRoutes } from './routes/admin.js'; import { adminRoutes } from './routes/admin.js';
import { mediaRoutes } from './routes/media.js'; import { mediaRoutes } from './routes/media.js';
import { bunnyWebhookRoutes } from './routes/bunny-webhooks.js'; import { bunnyWebhookRoutes } from './routes/bunny-webhooks.js';
import { profileRoutes } from './routes/profiles.js';
export function buildApp() { export function buildApp() {
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
@@ -33,6 +34,19 @@ export function buildApp() {
const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload); const rawUploadParser = (_request: unknown, payload: unknown, done: (error: Error | null, body?: unknown) => void) => done(null, payload);
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));
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,
@@ -41,11 +55,14 @@ 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: 'Invalid request', 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);
return reply.code(500).send({ error: 'Internal server error' }); return reply.code(500).send({ error: 'Erro interno no servidor.' });
}); });
const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => { const checkDatabase = async (_request: unknown, reply: { code: (statusCode: number) => { send: (payload: object) => unknown } }) => {
@@ -61,6 +78,7 @@ export function buildApp() {
app.register(authPlugin); app.register(authPlugin);
app.register(authRoutes, { prefix: '/api/v1/auth' }); app.register(authRoutes, { prefix: '/api/v1/auth' });
app.register(profileRoutes, { prefix: '/api/v1/profiles' });
app.register(courseRoutes, { prefix: '/api/v1/courses' }); app.register(courseRoutes, { prefix: '/api/v1/courses' });
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' }); app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
app.register(mediaRoutes, { prefix: '/api/v1/manage/media' }); app.register(mediaRoutes, { prefix: '/api/v1/manage/media' });

View File

@@ -34,7 +34,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
try { try {
await request.jwtVerify(); await request.jwtVerify();
} catch { } catch {
reply.code(401).send({ error: 'Authentication required' }); reply.code(401).send({ error: 'Autenticação necessária.' });
return false; return false;
} }
@@ -44,7 +44,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
); );
const account = result.rows[0]; const account = result.rows[0];
if (!account) { if (!account) {
reply.code(401).send({ error: 'This account is no longer active' }); reply.code(401).send({ error: 'Esta conta não está mais ativa.' });
return false; return false;
} }
@@ -61,7 +61,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
if (!(await verifyActiveUser(request, reply))) return; if (!(await verifyActiveUser(request, reply))) return;
if (!roles.includes(request.user.role)) { if (!roles.includes(request.user.role)) {
return reply.code(403).send({ error: 'Insufficient permissions' }); return reply.code(403).send({ error: 'Você não tem permissão para esta ação.' });
} }
}); });
}; };

View File

@@ -11,7 +11,7 @@ const environmentSchema = z.object({
APP_ENV: z.enum(['development', 'test', 'production']).default('development'), APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'), JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()), SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)), SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(8)),
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'), SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10), AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900), AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
@@ -19,18 +19,29 @@ const environmentSchema = z.object({
INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7), INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7),
PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24), PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24),
AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50), AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50),
SMTP_HOST: optionalEnvironmentValue(z.string().trim().min(1).max(255)),
SMTP_PORT: optionalEnvironmentValue(z.coerce.number().int().min(1).max(65_535)),
SMTP_USER: optionalEnvironmentValue(z.string().trim().min(1).max(320)),
SMTP_PASS: optionalEnvironmentValue(z.string().min(1).max(1_000)),
MAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)),
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()), BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)), BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)), BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_WEBHOOK_SECRET: optionalEnvironmentValue(z.string().min(20)), BUNNY_WEBHOOK_SECRET: optionalEnvironmentValue(z.string().min(20)),
BUNNY_EMBED_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(86_400).default(600), BUNNY_EMBED_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(86_400).default(600),
BUNNY_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(5120).default(5120), BUNNY_MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(5120).default(5120),
BUNNY_STORAGE_ZONE: optionalEnvironmentValue(z.string().trim().min(1).max(120)),
BUNNY_STORAGE_PASSWORD: optionalEnvironmentValue(z.string().min(1)),
BUNNY_STORAGE_ENDPOINT: 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_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);
if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') { if (config.APP_ENV === 'production' && config.JWT_SECRET === 'development-only-secret-change-before-production') {
throw new Error('JWT_SECRET must be set to a unique value in production.'); throw new Error('JWT_SECRET deve ter um valor único em produção.');
} }
const bunnyConfigurationValues = [ const bunnyConfigurationValues = [
@@ -40,5 +51,21 @@ const bunnyConfigurationValues = [
]; ];
if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) { if (bunnyConfigurationValues.some(Boolean) && !bunnyConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY, and BUNNY_EMBED_TOKEN_KEY must be configured together.'); throw new Error('BUNNY_STREAM_LIBRARY_ID, BUNNY_STREAM_API_KEY e BUNNY_EMBED_TOKEN_KEY devem ser configurados juntos.');
}
const bunnyStorageConfigurationValues = [
config.BUNNY_STORAGE_ZONE,
config.BUNNY_STORAGE_PASSWORD,
config.BUNNY_STORAGE_ENDPOINT,
config.BUNNY_STORAGE_CDN_HOST,
];
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT e BUNNY_STORAGE_CDN_HOST devem ser configurados juntos.');
}
const smtpConfigurationValues = [config.SMTP_HOST, config.SMTP_PORT, config.SMTP_USER, config.SMTP_PASS, config.MAIL_FROM];
if (smtpConfigurationValues.some(Boolean) && !smtpConfigurationValues.every(Boolean)) {
throw new Error('SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS e MAIL_FROM devem ser configurados juntos.');
} }

View File

@@ -0,0 +1,112 @@
import { randomUUID } from 'node:crypto';
import { config } from '../config.js';
export class BunnyStorageConfigurationError extends Error {}
export class BunnyStorageRequestError extends Error {}
type CoverImage = { body: Buffer; contentType: string };
type StoredAsset = { body: Buffer; contentType: string; extension: string };
function bunnyStorageConfiguration() {
if (!config.BUNNY_STORAGE_ZONE || !config.BUNNY_STORAGE_PASSWORD || !config.BUNNY_STORAGE_ENDPOINT || !config.BUNNY_STORAGE_CDN_HOST) {
throw new BunnyStorageConfigurationError('O Bunny Storage não está configurado. Peça a um administrador para adicionar as variáveis da Storage Zone.');
}
return {
zone: config.BUNNY_STORAGE_ZONE,
password: config.BUNNY_STORAGE_PASSWORD,
endpoint: config.BUNNY_STORAGE_ENDPOINT.replace(/\/$/, ''),
cdnHost: config.BUNNY_STORAGE_CDN_HOST.replace(/\/$/, ''),
};
}
export function isBunnyStorageConfigured() {
return Boolean(config.BUNNY_STORAGE_ZONE && config.BUNNY_STORAGE_PASSWORD && config.BUNNY_STORAGE_ENDPOINT && config.BUNNY_STORAGE_CDN_HOST);
}
function imageExtension(image: CoverImage) {
const { body, contentType } = image;
const isPng = body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
const isJpeg = body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff;
const isWebp = body.length >= 12 && body.subarray(0, 4).toString('ascii') === 'RIFF' && body.subarray(8, 12).toString('ascii') === 'WEBP';
if (contentType === 'image/png' && isPng) return 'png';
if (contentType === 'image/jpeg' && isJpeg) return 'jpg';
if (contentType === 'image/webp' && isWebp) return 'webp';
throw new BunnyStorageRequestError('Envie apenas imagens JPG, PNG ou WebP válidas.');
}
export async function uploadBunnyCover(image: CoverImage) {
const { zone, password, endpoint, cdnHost } = bunnyStorageConfiguration();
const extension = imageExtension(image);
const key = `covers/${randomUUID()}.${extension}`;
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/${key}`, {
method: 'PUT',
headers: {
AccessKey: password,
'Content-Type': image.contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
},
body: image.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, 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) {
const { zone, password, endpoint } = bunnyStorageConfiguration();
const response = await fetch(`${endpoint}/${encodeURIComponent(zone)}/covers/${encodeURIComponent(filename)}`, {
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',
};
}

View File

@@ -27,7 +27,7 @@ export class BunnyRequestError extends Error {}
function getBunnyConfiguration() { function getBunnyConfiguration() {
if (!config.BUNNY_STREAM_LIBRARY_ID || !config.BUNNY_STREAM_API_KEY || !config.BUNNY_EMBED_TOKEN_KEY) { if (!config.BUNNY_STREAM_LIBRARY_ID || !config.BUNNY_STREAM_API_KEY || !config.BUNNY_EMBED_TOKEN_KEY) {
throw new BunnyConfigurationError('Bunny Stream is not configured. Ask an administrator to configure the Bunny environment variables.'); throw new BunnyConfigurationError('O Bunny Stream não está configurado. Peça a um administrador para configurar as variáveis de ambiente do Bunny.');
} }
return { return {
@@ -66,7 +66,7 @@ function toVideo(video: BunnyApiVideo): BunnyVideo {
status: bunnyMediaStatus(video.status), status: bunnyMediaStatus(video.status),
providerStatus: video.status, providerStatus: video.status,
encodeProgress: Math.max(0, Math.min(100, Math.round(video.encodeProgress ?? 0))), encodeProgress: Math.max(0, Math.min(100, Math.round(video.encodeProgress ?? 0))),
durationSeconds: typeof video.length === 'number' && video.length > 0 ? Math.round(video.length) : null, durationSeconds: typeof video.length === 'number' && video.length > 0 ? Math.max(1, Math.round(video.length)) : null,
}; };
} }
@@ -114,6 +114,10 @@ export async function getBunnyVideo(videoId: string) {
return toVideo(await response.json() as BunnyApiVideo); return toVideo(await response.json() as BunnyApiVideo);
} }
export async function deleteBunnyVideo(videoId: string) {
await bunnyRequest(`/videos/${encodeURIComponent(videoId)}`, { method: 'DELETE' });
}
export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) { export function signedBunnyEmbedUrl(videoId: string, validitySeconds = config.BUNNY_EMBED_TOKEN_TTL_SECONDS) {
const { libraryId, embedTokenKey } = getBunnyConfiguration(); const { libraryId, embedTokenKey } = getBunnyConfiguration();
const expires = Math.floor(Date.now() / 1000) + validitySeconds; const expires = Math.floor(Date.now() / 1000) + validitySeconds;

View File

@@ -1,4 +1,5 @@
import type { FastifyPluginAsync } from 'fastify'; import type { FastifyPluginAsync } from 'fastify';
import type { PoolClient } from 'pg';
import { z } from 'zod'; import { z } from 'zod';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { createRawToken, hashToken } from '../auth/account-tokens.js'; import { createRawToken, hashToken } from '../auth/account-tokens.js';
@@ -10,12 +11,14 @@ const userParamsSchema = z.object({
}); });
const updateUserSchema = z.object({ const updateUserSchema = z.object({
name: z.string().trim().min(2).max(120).optional(),
email: z.string().email().transform((email) => email.toLowerCase()).optional(),
role: z.enum(['student', 'instructor', 'admin']).optional(), role: z.enum(['student', 'instructor', 'admin']).optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
}).refine((input) => input.role !== undefined || input.isActive !== undefined, { }).refine((input) => input.name !== undefined || input.email !== undefined || input.role !== undefined || input.isActive !== undefined, {
message: 'Provide at least one field to update', message: 'Informe pelo menos um campo para atualizar.',
}); });
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') }); const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.literal('instructor').default('instructor') });
const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() }); const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() });
const categoryParamsSchema = z.object({ categoryId: z.string().uuid() }); const categoryParamsSchema = z.object({ categoryId: z.string().uuid() });
const homeConfigurationSchema = z.object({ const homeConfigurationSchema = z.object({
@@ -23,6 +26,95 @@ const homeConfigurationSchema = z.object({
courseOrder: z.array(z.string().uuid()).max(500), courseOrder: z.array(z.string().uuid()).max(500),
defaultCoverImageUrl: z.string().url().nullable(), defaultCoverImageUrl: z.string().url().nullable(),
}); });
const homeBannersSchema = z.object({
banners: z.array(z.object({
kind: z.enum(['course', 'custom']),
courseId: z.string().uuid().nullable(),
imageUrl: z.string().url().nullable(),
title: z.string().trim().max(180).default(''),
description: z.string().trim().max(500).default(''),
}).superRefine((banner, context) => {
if (banner.kind === 'course' && !banner.courseId) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners de curso precisam de um curso.' });
if (banner.kind === 'custom' && !banner.imageUrl) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Banners personalizados precisam de uma imagem.' });
})).max(10),
});
const selectHomeBanners = async () => {
const result = await pool.query(
`select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position,
c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl"
from home_banners b
left join courses c on c.id = b.course_id
order by b.position`,
);
return result.rows;
};
const learningPathParamsSchema = z.object({ pathId: z.string().uuid() });
const learningPathSchema = z.object({
title: z.string().trim().min(1).max(160),
description: z.string().trim().max(2_000).default(''),
coverImageUrl: z.string().url().nullable().default(null),
coverImageZoom: z.number().min(1).max(2.5).default(1),
coverImagePositionX: z.number().min(0).max(100).default(50),
coverImagePositionY: z.number().min(0).max(100).default(50),
status: z.enum(['draft', 'published']).default('draft'),
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>;
const getLearningPaths = async () => {
const result = await pool.query(
`select
p.id, p.title, p.description, p.cover_image_url as "coverImageUrl",
p.cover_image_zoom as "coverImageZoom",
p.cover_image_position_x as "coverImagePositionX",
p.cover_image_position_y as "coverImagePositionY",
p.status, p.published_at as "publishedAt", p.created_at as "createdAt",
coalesce(jsonb_agg(jsonb_build_object(
'id', c.id, 'title', c.title, 'status', c.status,
'coverImageUrl', c.cover_image_url, 'position', pc.position
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
from learning_paths p
left join learning_path_courses pc on pc.learning_path_id = p.id
left join courses c on c.id = pc.course_id
group by p.id
order by p.created_at desc`,
);
return result.rows;
};
const validatePathCourses = async (client: PoolClient, input: LearningPathInput) => {
const courseIds = [...new Set(input.courseIds)];
if (courseIds.length !== input.courseIds.length) throw new Error('Um curso só pode aparecer uma vez em uma trilha.');
const courses = await client.query<{ id: string; status: string }>(
`select id, status from courses where id = any($1::uuid[]) and status <> 'archived'`,
[courseIds],
);
if (courses.rowCount !== courseIds.length) throw new Error('Todos os cursos da trilha devem existir e não podem estar arquivados.');
if (input.status === 'published' && courses.rows.some((course) => course.status !== 'published')) {
throw new Error('Publique todos os cursos desta trilha antes de publicá-la.');
}
return courseIds;
};
const writePathCourses = async (client: PoolClient, pathId: string, courseIds: string[]) => {
await client.query('delete from learning_path_courses where learning_path_id = $1', [pathId]);
for (const [index, courseId] of courseIds.entries()) {
await client.query(
`insert into learning_path_courses (learning_path_id, course_id, position) values ($1, $2, $3)`,
[pathId, courseId, index + 1],
);
}
};
export const adminRoutes: FastifyPluginAsync = async (app) => { export const adminRoutes: FastifyPluginAsync = async (app) => {
const adminAccess = { preHandler: app.requireRoles(['admin']) }; const adminAccess = { preHandler: app.requireRoles(['admin']) };
@@ -57,6 +149,114 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
return { data: result.rows }; return { data: result.rows };
}); });
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.put('/home-banners', adminAccess, async (request) => {
const input = homeBannersSchema.parse(request.body);
const courseIds = input.banners.filter((banner) => banner.kind === 'course').map((banner) => banner.courseId!);
if (courseIds.length) {
const courses = await pool.query(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [courseIds]);
if (courses.rowCount !== courseIds.length) return { error: 'Banners de curso devem referenciar cursos publicados.' };
}
const client = await pool.connect();
try {
await client.query('begin');
await client.query('delete from home_banners');
for (const [index, banner] of input.banners.entries()) {
await client.query(
`insert into home_banners (kind, course_id, image_url, title, description, position)
values ($1, $2, $3, $4, $5, $6)`,
[banner.kind, banner.courseId, banner.imageUrl, banner.title, banner.description, index + 1],
);
}
await client.query('commit');
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
await recordAudit({ actorId: request.user.id, action: 'home.banners_updated', subjectType: 'platform', metadata: { count: input.banners.length }, ipAddress: request.ip });
return { data: await selectHomeBanners() };
});
app.post('/paths', adminAccess, async (request, reply) => {
const input = learningPathSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const courseIds = await validatePathCourses(client, input);
const path = await client.query<{ id: string }>(
`insert into learning_paths (
title, description, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, published_at
) values ($1, $2, $3, $4, $5, $6, $7::course_status, case when $7 = 'published' then now() else null end)
returning id`,
[input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
);
await writePathCourses(client, path.rows[0].id, courseIds);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'path.created', subjectType: 'learning_path', subjectId: path.rows[0].id, metadata: { title: input.title }, ipAddress: request.ip });
const paths = await getLearningPaths();
return reply.code(201).send({ data: paths.find((item) => item.id === path.rows[0].id) });
} catch (error) {
await client.query('rollback');
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
}
});
app.patch('/paths/:pathId', adminAccess, async (request, reply) => {
const { pathId } = learningPathParamsSchema.parse(request.params);
const input = learningPathSchema.parse(request.body);
const client = await pool.connect();
try {
await client.query('begin');
const courseIds = await validatePathCourses(client, input);
const path = await client.query<{ id: string }>(
`update learning_paths set
title = $2, description = $3, cover_image_url = $4, cover_image_zoom = $5,
cover_image_position_x = $6, cover_image_position_y = $7, status = $8::course_status,
published_at = case when $8 = 'published' then coalesce(published_at, now()) else null end
where id = $1
returning id`,
[pathId, input.title, input.description, input.coverImageUrl, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
);
if (!path.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Trilha não encontrada.' });
}
await writePathCourses(client, pathId, courseIds);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'path.updated', subjectType: 'learning_path', subjectId: pathId, metadata: { title: input.title }, ipAddress: request.ip });
const paths = await getLearningPaths();
return { data: paths.find((item) => item.id === pathId) };
} catch (error) {
await client.query('rollback');
if (error instanceof Error && /curso|publique/i.test(error.message)) return reply.code(400).send({ error: error.message });
throw error;
} finally {
client.release();
}
});
app.delete('/paths/:pathId', adminAccess, async (request, reply) => {
const { pathId } = learningPathParamsSchema.parse(request.params);
const result = await pool.query(`delete from learning_paths where id = $1 returning id, title`, [pathId]);
if (!result.rows[0]) return reply.code(404).send({ error: 'Trilha não encontrada.' });
await recordAudit({ actorId: request.user.id, action: 'path.deleted', subjectType: 'learning_path', subjectId: pathId, metadata: { title: result.rows[0].title }, ipAddress: request.ip });
return reply.code(204).send();
});
app.post('/categories', adminAccess, async (request, reply) => { app.post('/categories', adminAccess, async (request, reply) => {
const input = categorySchema.parse(request.body); const input = categorySchema.parse(request.body);
const result = await pool.query( const result = await pool.query(
@@ -79,7 +279,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
returning id, name, position, is_active as "isActive"`, returning id, name, position, is_active as "isActive"`,
[categoryId, input.name, input.position ?? null, input.isActive ?? null], [categoryId, input.name, input.position ?? null, input.isActive ?? null],
); );
if (!result.rows[0]) return reply.code(404).send({ error: 'Category not found' }); if (!result.rows[0]) return reply.code(404).send({ error: 'Trilha não encontrada.' });
await recordAudit({ actorId: request.user.id, action: 'category.updated', subjectType: 'category', subjectId: categoryId, metadata: input, ipAddress: request.ip }); await recordAudit({ actorId: request.user.id, action: 'category.updated', subjectType: 'category', subjectId: categoryId, metadata: input, ipAddress: request.ip });
return { data: result.rows[0] }; return { data: result.rows[0] };
}); });
@@ -87,7 +287,11 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
app.get('/home-configuration', adminAccess, async () => { app.get('/home-configuration', adminAccess, async () => {
const [settings, courses] = await Promise.all([ const [settings, courses] = await Promise.all([
pool.query<{ key: string; value: unknown }>(`select key, value from platform_settings where key in ('home.featuredCourseId', 'home.courseOrder', 'media.defaultCoverImageUrl')`), pool.query<{ key: string; value: unknown }>(`select key, value from platform_settings where key in ('home.featuredCourseId', 'home.courseOrder', 'media.defaultCoverImageUrl')`),
pool.query<{ id: string; title: string; status: string }>(`select id, title, status from courses where status <> 'archived' order by title`), 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`,
),
]); ]);
const values = new Map(settings.rows.map((row) => [row.key, row.value])); const values = new Map(settings.rows.map((row) => [row.key, row.value]));
return { data: { return { data: {
@@ -103,7 +307,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])]; const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])];
if (ids.length) { if (ids.length) {
const result = await pool.query<{ id: string }>(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [ids]); const result = await pool.query<{ id: string }>(`select id from courses where id = any($1::uuid[]) and status = 'published'`, [ids]);
if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Featured courses must exist and be published' }); if (result.rowCount !== ids.length) return reply.code(400).send({ error: 'Os cursos em destaque devem existir e estar publicados.' });
} }
const client = await pool.connect(); const client = await pool.connect();
try { try {
@@ -151,14 +355,14 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
(select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt" (select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt"
from users u where u.id = $1`, [userId], from users u where u.id = $1`, [userId],
); );
if (!result.rows[0]) return reply.code(404).send({ error: 'User not found' }); if (!result.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
return { data: result.rows[0] }; return { data: result.rows[0] };
}); });
app.post('/invitations', adminAccess, async (request, reply) => { app.post('/invitations', adminAccess, async (request, reply) => {
const input = invitationSchema.parse(request.body); const input = invitationSchema.parse(request.body);
const existing = await pool.query('select 1 from users where email = $1', [input.email]); const existing = await pool.query('select 1 from users where email = $1', [input.email]);
if (existing.rowCount) return reply.code(409).send({ error: 'This email already has an account' }); if (existing.rowCount) return reply.code(409).send({ error: 'Este e-mail já possui uma conta.' });
const rawToken = createRawToken(); const rawToken = createRawToken();
await pool.query( await pool.query(
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by) `insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
@@ -172,7 +376,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => { app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params); const { userId } = userParamsSchema.parse(request.params);
const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]); const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]);
if (!account.rows[0]) return reply.code(404).send({ error: 'User not found' }); if (!account.rows[0]) return reply.code(404).send({ error: 'Usuário não encontrado.' });
const rawToken = createRawToken(); const rawToken = createRawToken();
await pool.query( await pool.query(
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by) `insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
@@ -188,20 +392,63 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const input = updateUserSchema.parse(request.body); const input = updateUserSchema.parse(request.body);
if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) { if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) {
return reply.code(400).send({ error: 'You cannot remove your own superadmin access' }); return reply.code(400).send({ error: 'Você não pode remover seu próprio acesso de superadmin.' });
}
if (input.email) {
const duplicate = await pool.query('select 1 from users where email = $1 and id <> $2', [input.email, userId]);
if (duplicate.rowCount) return reply.code(409).send({ error: 'Este e-mail já é usado por outra conta.' });
} }
const result = await pool.query( const result = await pool.query(
`update users `update users
set role = coalesce($2::user_role, role), set display_name = coalesce($2, display_name),
is_active = coalesce($3, is_active) email = coalesce($3, email),
role = coalesce($4::user_role, role),
is_active = coalesce($5, is_active)
where id = $1 where id = $1
returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`, returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`,
[userId, input.role ?? null, input.isActive ?? null], [userId, input.name ?? null, input.email ?? null, input.role ?? null, input.isActive ?? null],
); );
const account = result.rows[0]; const account = result.rows[0];
if (!account) return reply.code(404).send({ error: 'User not found' }); if (!account) return reply.code(404).send({ error: 'Usuário não encontrado.' });
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip }); await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
return { data: account }; return { data: account };
}); });
app.delete('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
if (userId === request.user.id) return reply.code(400).send({ error: 'Você não pode excluir sua própria conta de superadmin.' });
const client = await pool.connect();
try {
await client.query('begin');
const account = await client.query<{ id: string; email: string; role: 'student' | 'instructor' | 'admin' }>('select id, email, role from users where id = $1 for update', [userId]);
if (!account.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'Usuário não encontrado.' });
}
if (account.rows[0].role === 'admin') {
const admins = await client.query<{ count: string }>("select count(*) from users where role = 'admin' and is_active");
if (Number(admins.rows[0].count) <= 1) {
await client.query('rollback');
return reply.code(400).send({ error: 'O último superadmin ativo não pode ser excluído.' });
}
}
// Courses remain available. Their ownership is transferred to the admin
// performing the deletion instead of deleting lesson and Bunny media.
await client.query('update courses set instructor_id = $2 where instructor_id = $1', [userId, request.user.id]);
await client.query('delete from comment_replies where author_id = $1', [userId]);
await client.query('delete from comments where author_id = $1', [userId]);
await client.query('delete from account_access_tokens where created_by = $1 or email = $2', [userId, account.rows[0].email]);
await client.query('delete from users where id = $1', [userId]);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'user.deleted', subjectType: 'user', subjectId: userId, metadata: { email: account.rows[0].email, role: account.rows[0].role }, ipAddress: request.ip });
return reply.code(204).send();
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
}; };

View File

@@ -5,16 +5,17 @@ import { hashToken } from '../auth/account-tokens.js';
import type { AuthUser } from '../auth/plugin.js'; import type { AuthUser } from '../auth/plugin.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { sendWelcomeEmail } from '../services/email.js';
const credentialsSchema = z.object({ const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()), email: z.string().email().transform((email) => email.toLowerCase()),
password: z.string().min(12).max(200), password: z.string().min(8).max(200),
}); });
const registerSchema = credentialsSchema.extend({ const registerSchema = credentialsSchema.extend({
name: z.string().trim().min(2).max(120), name: z.string().trim().min(2).max(120),
}); });
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() }); const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(8).max(200), name: z.string().trim().min(2).max(120).optional() });
type UserRow = { type UserRow = {
id: string; id: string;
@@ -56,7 +57,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}; };
const rejectIfRateLimited = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => { const rejectIfRateLimited = async (ip: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }) => {
if (await allowPublicAuthAttempt(ip)) return false; if (await allowPublicAuthAttempt(ip)) return false;
reply.code(429).send({ error: 'Too many attempts. Please try again later.' }); reply.code(429).send({ error: 'Muitas tentativas. Tente novamente mais tarde.' });
return true; return true;
}; };
@@ -73,7 +74,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
); );
if (!token.rows[0]) { if (!token.rows[0]) {
await client.query('rollback'); await client.query('rollback');
return reply.code(400).send({ error: 'This invitation is invalid or expired' }); return reply.code(400).send({ error: 'Este convite é inválido ou expirou.' });
} }
const result = await client.query<UserRow>( const result = await client.query<UserRow>(
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4) `insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
@@ -85,7 +86,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user }); return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }), user });
} catch { } catch {
await client.query('rollback'); await client.query('rollback');
return reply.code(409).send({ error: 'This invitation email already has an account' }); return reply.code(409).send({ error: 'Este e-mail de convite já possui uma conta.' });
} finally { } finally {
client.release(); client.release();
} }
@@ -104,7 +105,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
); );
if (!token.rows[0]) { if (!token.rows[0]) {
await client.query('rollback'); await client.query('rollback');
return reply.code(400).send({ error: 'This reset link is invalid or expired' }); return reply.code(400).send({ error: 'Este link de redefinição é inválido ou expirou.' });
} }
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]); await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
await client.query('commit'); await client.query('commit');
@@ -130,10 +131,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
); );
const user = serializeUser(result.rows[0]); const user = serializeUser(result.rows[0]);
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }); const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
try {
const sent = await sendWelcomeEmail({ name: user.name, email: user.email });
if (!sent && config.SMTP_HOST) request.log.warn({ email: user.email }, 'Welcome email was rejected by the SMTP server');
} catch (error) {
request.log.error(error, 'Welcome email could not be sent');
}
return reply.code(201).send({ token, user }); return reply.code(201).send({ token, user });
} catch (error: unknown) { } catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {
return reply.code(409).send({ error: 'An account with this email already exists' }); return reply.code(409).send({ error: 'Já existe uma conta com este e-mail.' });
} }
throw error; throw error;
} }
@@ -149,7 +156,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const account = result.rows[0]; const account = result.rows[0];
if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) { if (!account || !account.is_active || !(await verifyPassword(input.password, account.password_hash))) {
return reply.code(401).send({ error: 'Invalid email or password' }); return reply.code(401).send({ error: 'E-mail ou senha inválidos.' });
} }
const user = serializeUser(account); const user = serializeUser(account);

View File

@@ -14,16 +14,16 @@ const webhookSchema = z.object({
export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => { export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => {
app.post('/bunny', async (request, reply) => { app.post('/bunny', async (request, reply) => {
if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Bunny webhooks are not configured' }); if (!isBunnyWebhookConfigured()) return reply.code(503).send({ error: 'Os webhooks do Bunny não estão configurados.' });
const rawBody = (request as typeof request & { rawBody?: Buffer }).rawBody; const rawBody = (request as typeof request & { rawBody?: Buffer }).rawBody;
const signatureHeader = request.headers.signature || request.headers['x-bunny-signature']; const signatureHeader = request.headers.signature || request.headers['x-bunny-signature'];
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader; const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
if (!rawBody || !verifyBunnyWebhookSignature(rawBody, signature)) { if (!rawBody || !verifyBunnyWebhookSignature(rawBody, signature)) {
return reply.code(401).send({ error: 'Invalid Bunny webhook signature' }); return reply.code(401).send({ error: 'Assinatura do webhook do Bunny inválida.' });
} }
const input = webhookSchema.parse(request.body); const input = webhookSchema.parse(request.body);
if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) { if (input.VideoLibraryId !== config.BUNNY_STREAM_LIBRARY_ID) {
return reply.code(400).send({ error: 'Unexpected Bunny video library' }); return reply.code(400).send({ error: 'Biblioteca de vídeos Bunny inesperada.' });
} }
const status = bunnyMediaStatus(input.Status); const status = bunnyMediaStatus(input.Status);
await pool.query( await pool.query(

View File

@@ -2,6 +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, bunnyAssetKeyFromUrl, downloadBunnyAsset, downloadBunnyCover } from '../providers/bunny-storage.js';
const paramsSchema = z.object({ const paramsSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),
@@ -10,6 +11,10 @@ const playbackParamsSchema = z.object({
courseId: z.string().uuid(), courseId: z.string().uuid(),
lessonId: z.string().uuid(), lessonId: z.string().uuid(),
}); });
const coverParamsSchema = z.object({
filename: z.string().regex(/^[0-9a-f]{8}-[0-9a-f-]{27}\.(?:jpg|png|webp)$/i),
});
const assetParamsSchema = z.object({ assetId: z.string().uuid() });
export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => ` export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) => `
select select
@@ -21,6 +26,9 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
coalesce(c.cover_image_url, ( coalesce(c.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl' select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)) as "coverImageUrl", )) as "coverImageUrl",
c.cover_image_zoom as "coverImageZoom",
c.cover_image_position_x as "coverImagePositionX",
c.cover_image_position_y as "coverImagePositionY",
c.status, c.status,
c.published_at as "publishedAt", c.published_at as "publishedAt",
jsonb_build_object( jsonb_build_object(
@@ -42,6 +50,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
'name', a.name, 'name', a.name,
'kind', a.kind, 'kind', a.kind,
'url', a.url, 'url', a.url,
'sizeBytes', a.size_bytes,
'createdAt', a.created_at,
'description', a.description, 'description', a.description,
'accessLevel', a.access_level 'accessLevel', a.access_level
) order by a.created_at) ) order by a.created_at)
@@ -74,6 +84,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
'name', a.name, 'name', a.name,
'kind', a.kind, 'kind', a.kind,
'url', a.url, 'url', a.url,
'sizeBytes', a.size_bytes,
'createdAt', a.created_at,
'description', a.description, 'description', a.description,
'accessLevel', a.access_level 'accessLevel', a.access_level
) order by a.created_at) ) order by a.created_at)
@@ -107,6 +119,51 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
return { data: result.rows.map((row) => row.name) }; return { data: result.rows.map((row) => row.name) };
}); });
app.get('/covers/:filename', async (request, reply) => {
const { filename } = coverParamsSchema.parse(request.params);
try {
const cover = await downloadBunnyCover(filename);
reply.header('Content-Type', cover.contentType);
reply.header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800');
return reply.send(cover.body);
} catch (error) {
if (error instanceof BunnyStorageConfigurationError || error instanceof BunnyStorageRequestError) {
return reply.code(404).send({ error: 'Imagem de capa não encontrada.' });
}
throw error;
}
});
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 +183,47 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) }; return { data: authenticated ? result.rows : result.rows.map(withoutProtectedMedia) };
}); });
// Paths intentionally reference existing courses instead of copying them.
// Only a path and its published courses are visible to visitors.
app.get('/paths', async () => {
const result = await pool.query(
`select
p.id, p.title, p.description,
coalesce(p.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)) as "coverImageUrl",
p.cover_image_zoom as "coverImageZoom",
p.cover_image_position_x as "coverImagePositionX",
p.cover_image_position_y as "coverImagePositionY",
coalesce(jsonb_agg(jsonb_build_object(
'id', c.id, 'title', c.title, 'category', c.category,
'coverImageUrl', coalesce(c.cover_image_url, (
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
)), 'position', pc.position
) order by pc.position) filter (where c.id is not null), '[]'::jsonb) as courses
from learning_paths p
join learning_path_courses pc on pc.learning_path_id = p.id
join courses c on c.id = pc.course_id and c.status = 'published'
where p.status = 'published'
group by p.id
order by p.published_at desc`,
);
return { data: result.rows };
});
app.get('/home-banners', async () => {
const result = await pool.query(
`select b.id, b.kind, b.course_id as "courseId", b.image_url as "imageUrl", b.title, b.description, b.position,
c.title as "courseTitle", c.description as "courseDescription", c.cover_image_url as "courseImageUrl",
c.cover_image_zoom as "coverImageZoom", c.cover_image_position_x as "coverImagePositionX", c.cover_image_position_y as "coverImagePositionY"
from home_banners b
left join courses c on c.id = b.course_id and c.status = 'published'
where b.kind = 'custom' or c.id is not null
order by b.position`,
);
return { data: result.rows };
});
app.get('/me/learning', { preHandler: app.authenticate }, async (request) => { app.get('/me/learning', { preHandler: app.authenticate }, async (request) => {
const result = await pool.query( const result = await pool.query(
`select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity" `select base.*, coalesce(progress.progress, 0)::int as progress, progress."lastActivity"
@@ -156,7 +254,7 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
const course = result.rows[0]; const course = result.rows[0];
if (!course) { if (!course) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
return { data: authenticated ? course : withoutProtectedMedia(course) }; return { data: authenticated ? course : withoutProtectedMedia(course) };
@@ -181,22 +279,22 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
[courseId, lessonId], [courseId, lessonId],
); );
const media = result.rows[0]; const media = result.rows[0];
if (!media) return reply.code(404).send({ error: 'Lesson media was not found' }); if (!media) return reply.code(404).send({ error: 'Mídia da aula não encontrada.' });
if (media.status === 'processing') return reply.code(409).send({ error: 'This video is still being processed by Bunny Stream.' }); if (media.status === 'processing') return reply.code(409).send({ error: 'Este vídeo ainda está sendo processado pelo Bunny Stream.' });
if (media.status === 'failed') return reply.code(409).send({ error: 'This video could not be processed. Please contact the instructor.' }); if (media.status === 'failed') return reply.code(409).send({ error: 'Este vídeo não pôde ser processado. Entre em contato com o instrutor.' });
if (media.provider === 'bunny') { if (media.provider === 'bunny') {
try { try {
const playback = signedBunnyEmbedUrl(media.external_id); const playback = signedBunnyEmbedUrl(media.external_id);
return { data: { provider: 'bunny', kind: 'embed', ...playback } }; return { data: { provider: 'bunny', kind: 'embed', ...playback } };
} catch (error) { } catch (error) {
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: 'Bunny playback is not configured yet.' }); if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: 'A reprodução do Bunny ainda não está configurada.' });
throw error; throw error;
} }
} }
const source = media.playback_url || media.embed_url; const source = media.playback_url || media.embed_url;
if (!source) return reply.code(409).send({ error: 'This lesson does not have a playable video URL.' }); if (!source) return reply.code(409).send({ error: 'Esta aula não possui um endereço de vídeo reproduzível.' });
return { data: { provider: media.provider, kind: 'video', source } }; return { data: { provider: media.provider, kind: 'video', source } };
}); });
}; };

View File

@@ -14,7 +14,7 @@ const lessonParamsSchema = z.object({
const completionSchema = z.object({ const completionSchema = z.object({
completed: z.boolean().optional(), completed: z.boolean().optional(),
watchedSeconds: z.coerce.number().int().min(0).optional(), watchedSeconds: z.coerce.number().int().min(0).optional(),
}).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Provide progress or completion state' }); }).refine((input) => input.completed !== undefined || input.watchedSeconds !== undefined, { message: 'Informe o progresso ou o status de conclusão.' });
const commentSchema = z.object({ const commentSchema = z.object({
lessonId: z.string().uuid().nullable().optional(), lessonId: z.string().uuid().nullable().optional(),
@@ -51,7 +51,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => { app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params); const { courseId } = courseParamsSchema.parse(request.params);
if (!(await ensurePublishedCourse(courseId))) { if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
const result = await pool.query( const result = await pool.query(
@@ -78,7 +78,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
); );
if (lesson.rowCount !== 1) { if (lesson.rowCount !== 1) {
return reply.code(404).send({ error: 'Lesson not found' }); return reply.code(404).send({ error: 'Aula não encontrada.' });
} }
const result = await pool.query( const result = await pool.query(
@@ -99,7 +99,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => { app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
const { courseId } = courseParamsSchema.parse(request.params); const { courseId } = courseParamsSchema.parse(request.params);
if (!(await canViewCourseComments(courseId, request.user))) { if (!(await canViewCourseComments(courseId, request.user))) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
const result = await pool.query( const result = await pool.query(
@@ -120,13 +120,13 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
const { courseId } = courseParamsSchema.parse(request.params); const { courseId } = courseParamsSchema.parse(request.params);
const input = commentSchema.parse(request.body); const input = commentSchema.parse(request.body);
if (!(await ensurePublishedCourse(courseId))) { if (!(await ensurePublishedCourse(courseId))) {
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
if (input.lessonId) { if (input.lessonId) {
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]); const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
if (lesson.rowCount !== 1) { if (lesson.rowCount !== 1) {
return reply.code(400).send({ error: 'Lesson does not belong to this course' }); return reply.code(400).send({ error: 'Esta aula não pertence ao curso.' });
} }
} }
@@ -148,7 +148,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => { app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
const { commentId } = commentParamsSchema.parse(request.params); const { commentId } = commentParamsSchema.parse(request.params);
const input = replySchema.parse(request.body); const input = replySchema.parse(request.body);
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot reply to this comment' }); if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode responder a este comentário.' });
await pool.query( await pool.query(
`insert into comment_replies (comment_id, author_id, body) `insert into comment_replies (comment_id, author_id, body)
values ($1, $2, $3) values ($1, $2, $3)
@@ -161,7 +161,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => { app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
const { commentId } = commentParamsSchema.parse(request.params); const { commentId } = commentParamsSchema.parse(request.params);
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot moderate this comment' }); if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'Você não pode moderar este comentário.' });
await pool.query('delete from comments where id = $1', [commentId]); await pool.query('delete from comments where id = $1', [commentId]);
await recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip }); await recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
return reply.code(204).send(); return reply.code(204).send();

View File

@@ -5,6 +5,7 @@ import { z } from 'zod';
import { courseSelect } from './courses.js'; import { courseSelect } from './courses.js';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { recordAudit } from '../audit.js'; import { recordAudit } from '../audit.js';
import { config } from '../config.js';
const mediaSchema = z.object({ const mediaSchema = z.object({
provider: z.string().trim().min(1).max(80), provider: z.string().trim().min(1).max(80),
@@ -19,6 +20,7 @@ const assetSchema = z.object({
name: z.string().trim().min(1).max(255), name: z.string().trim().min(1).max(255),
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']), kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
url: z.string().url(), url: z.string().url(),
sizeBytes: z.number().int().nonnegative().nullable().optional(),
description: z.string().max(2000).default(''), description: z.string().max(2000).default(''),
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'), accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
}); });
@@ -33,12 +35,20 @@ const lessonSchema = z.object({
assets: z.array(assetSchema).default([]), assets: z.array(assetSchema).default([]),
}); });
const coverImageUrlSchema = z.string().url().refine((value) => {
const protocol = new URL(value).protocol;
return protocol === 'https:' || (config.APP_ENV !== 'production' && protocol === 'http:');
}, { message: 'A URL da imagem de capa deve usar HTTPS.' });
const courseSchema = z.object({ const courseSchema = z.object({
title: z.string().trim().min(1).max(255), title: z.string().trim().min(1).max(255),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(), slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(280).optional(),
description: z.string().max(10000).default(''), description: z.string().max(10000).default(''),
category: z.string().trim().min(1).max(120), category: z.string().trim().min(1).max(120),
coverImageUrl: z.string().url().nullable().optional(), coverImageUrl: coverImageUrlSchema.nullable().optional(),
coverImageZoom: z.number().min(1).max(2.5).default(1),
coverImagePositionX: 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'),
lessons: z.array(lessonSchema).min(1), lessons: z.array(lessonSchema).min(1),
assets: z.array(assetSchema).default([]), assets: z.array(assetSchema).default([]),
@@ -76,16 +86,16 @@ async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined,
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) { async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
for (const asset of assets) { for (const asset of assets) {
await client.query( await client.query(
`insert into assets (course_id, lesson_id, name, kind, url, description, access_level) `insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
values ($1, $2, $3, $4, $5, $6, $7)`, values ($1, $2, $3, $4, $5, $6, $7, $8)`,
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel], [parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.sizeBytes ?? null, asset.description, asset.accessLevel],
); );
} }
} }
async function assertActiveCategory(category: string) { async function assertActiveCategory(category: string) {
const result = await pool.query('select 1 from course_categories where name = $1 and is_active', [category]); const result = await pool.query('select 1 from course_categories where name = $1 and is_active', [category]);
if (!result.rowCount) throw new CourseContentConflict('Select an active category created by the superadmin.'); if (!result.rowCount) throw new CourseContentConflict('Selecione uma trilha ativa criada pelo superadmin.');
} }
function assertPublishable(input: CourseInput) { function assertPublishable(input: CourseInput) {
@@ -94,7 +104,7 @@ function assertPublishable(input: CourseInput) {
lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'), lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'),
); );
if (unplayableLesson) { if (unplayableLesson) {
throw new CoursePublicationBlocked(`The course cannot be published while "${unplayableLesson.title}" has no ready video.`); throw new CoursePublicationBlocked(`O curso não pode ser publicado enquanto “${unplayableLesson.title}” não tiver um vídeo pronto.`);
} }
} }
@@ -103,7 +113,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id)); const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : [])); const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId)); const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId));
if (unknownLessonId) throw new Error('A lesson being edited does not belong to this course.'); if (unknownLessonId) throw new Error('Uma aula em edição não pertence a este curso.');
const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId)); const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId));
if (removedLessonIds.length > 0) { if (removedLessonIds.length > 0) {
@@ -116,7 +126,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
[removedLessonIds], [removedLessonIds],
); );
if (usage.rowCount) { if (usage.rowCount) {
throw new CourseContentConflict('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.'); throw new CourseContentConflict('Uma aula com progresso ou comentários de alunos não pode ser removida. Mantenha-a ou arquive o curso.');
} }
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]); await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
} }
@@ -161,9 +171,9 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) { async function assertCanManageCourse(courseId: string, user: { id: string; role: string }) {
const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]); const result = await pool.query<{ instructor_id: string }>('select instructor_id from courses where id = $1', [courseId]);
const course = result.rows[0]; const course = result.rows[0];
if (!course) return { error: 'Course not found', statusCode: 404 as const }; if (!course) return { error: 'Curso não encontrado.', statusCode: 404 as const };
if (user.role !== 'admin' && course.instructor_id !== user.id) { if (user.role !== 'admin' && course.instructor_id !== user.id) {
return { error: 'You can only manage your own courses', statusCode: 403 as const }; return { error: 'Você só pode gerenciar seus próprios cursos.', statusCode: 403 as const };
} }
return null; return null;
} }
@@ -207,10 +217,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
await client.query('begin'); await client.query('begin');
const slug = await uniqueSlug(client, input.slug, input.title); const slug = await uniqueSlug(client, input.slug, input.title);
const course = await client.query<{ id: string }>( const course = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id, published_at) `insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, instructor_id, published_at)
values ($1, $2, $3, $4, $5, $6::course_status, $7, case when $6::course_status = 'published'::course_status then now() else null end) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::course_status, $10, case when $9::course_status = 'published'::course_status then now() else null end)
returning id`, returning id`,
[slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status, request.user.id], [slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status, request.user.id],
); );
await replaceCourseContents(client, course.rows[0].id, input); await replaceCourseContents(client, course.rows[0].id, input);
await client.query('commit'); await client.query('commit');
@@ -239,10 +249,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const slug = await uniqueSlug(client, input.slug, input.title, courseId); const slug = await uniqueSlug(client, input.slug, input.title, courseId);
await client.query( await client.query(
`update courses `update courses
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6, cover_image_zoom = $7, cover_image_position_x = $8, cover_image_position_y = $9,
status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end status = $10::course_status, published_at = case when $10::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
where id = $1`, where id = $1`,
[courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.status], [courseId, slug, input.title, input.description, input.category, input.coverImageUrl ?? null, input.coverImageZoom, input.coverImagePositionX, input.coverImagePositionY, input.status],
); );
await replaceCourseContents(client, courseId, input); await replaceCourseContents(client, courseId, input);
await client.query('commit'); await client.query('commit');
@@ -261,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();
}); });
@@ -274,20 +295,20 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('begin'); await client.query('begin');
const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; instructor_id: string }>( const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; cover_image_zoom: number; cover_image_position_x: number; cover_image_position_y: number; instructor_id: string }>(
`select title, description, category, cover_image_url, instructor_id from courses where id = $1`, [courseId], `select title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, instructor_id from courses where id = $1`, [courseId],
); );
if (!original.rows[0]) { if (!original.rows[0]) {
await client.query('rollback'); await client.query('rollback');
return reply.code(404).send({ error: 'Course not found' }); return reply.code(404).send({ error: 'Curso não encontrado.' });
} }
const source = original.rows[0]; const source = original.rows[0];
const title = `${source.title} (cópia)`; const title = `${source.title} (cópia)`;
const slug = await uniqueSlug(client, undefined, title); const slug = await uniqueSlug(client, undefined, title);
const copiedCourse = await client.query<{ id: string }>( const copiedCourse = await client.query<{ id: string }>(
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id) `insert into courses (slug, title, description, category, cover_image_url, cover_image_zoom, cover_image_position_x, cover_image_position_y, status, instructor_id)
values ($1, $2, $3, $4, $5, 'draft', $6) returning id`, values ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9) returning id`,
[slug, title, source.description, source.category, source.cover_image_url, source.instructor_id], [slug, title, source.description, source.category, source.cover_image_url, source.cover_image_zoom, source.cover_image_position_x, source.cover_image_position_y, source.instructor_id],
); );
const newCourseId = copiedCourse.rows[0].id; const newCourseId = copiedCourse.rows[0].id;
await client.query( await client.query(

View File

@@ -6,18 +6,36 @@ import {
BunnyConfigurationError, BunnyConfigurationError,
BunnyRequestError, BunnyRequestError,
createBunnyVideo, createBunnyVideo,
deleteBunnyVideo,
getBunnyVideo, getBunnyVideo,
isBunnyConfigured, isBunnyConfigured,
uploadBunnyVideo, uploadBunnyVideo,
} from '../providers/bunny.js'; } from '../providers/bunny.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { VideoUploadValidationError, validateVideoUpload } from '../uploads/video.js';
import {
BunnyStorageConfigurationError,
BunnyStorageRequestError,
isBunnyStorageConfigured,
uploadBunnyAsset,
uploadBunnyCover,
} 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() });
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) { function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message }); if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' }); if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'O Bunny Stream não conseguiu concluir esta solicitação. Tente novamente.' });
throw error;
}
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 BunnyStorageRequestError) return reply.code(422).send({ error: error.message });
throw error; throw error;
} }
@@ -28,10 +46,36 @@ async function persistBunnyStatus(video: { id: string; status: string; durationS
where provider = 'bunny' and external_id = $1`, where provider = 'bunny' and external_id = $1`,
[video.id, video.status, video.durationSeconds], [video.id, video.status, video.durationSeconds],
); );
if (video.durationSeconds !== null) {
await pool.query(
`update lessons
set duration_seconds = $2
where id = (select lesson_id from lesson_media where provider = 'bunny' and external_id = $1)`,
[video.id, video.durationSeconds],
);
}
}
async function assertVideoAccess(videoId: string, user: { id: string; role: string }) {
if (user.role === 'admin') return;
const result = await pool.query<{ owner_id: string }>('select owner_id from bunny_video_uploads where video_id = $1', [videoId]);
if (result.rows[0]?.owner_id === user.id) return;
const legacy = await pool.query<{ instructor_id: string }>(
`select c.instructor_id
from lesson_media lm
join lessons l on l.id = lm.lesson_id
join courses c on c.id = l.course_id
where lm.provider = 'bunny' and lm.external_id = $1
limit 1`,
[videoId],
);
if (legacy.rows[0]?.instructor_id === user.id) return;
throw new VideoUploadValidationError('Você não tem acesso a este vídeo.', 403);
} }
export const mediaRoutes: FastifyPluginAsync = async (app) => { export const mediaRoutes: FastifyPluginAsync = async (app) => {
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) }; const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
const coverAccess = { preHandler: app.authenticate };
app.get('/bunny/config', manageAccess, async () => ({ app.get('/bunny/config', manageAccess, async () => ({
data: { data: {
@@ -40,10 +84,56 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
}, },
})); }));
app.get('/covers/config', coverAccess, async () => ({
data: {
configured: isBunnyStorageConfigured(),
maxUploadBytes: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024,
},
}));
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) => {
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 imagem.' });
if (body.length === 0) return reply.code(400).send({ error: 'Escolha uma imagem para enviar.' });
if (body.length > config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024) {
return reply.code(413).send({ error: `Cover image is larger than the ${config.BUNNY_COVER_MAX_UPLOAD_MB} MB upload limit.` });
}
try {
const cover = await uploadBunnyCover({ body, contentType });
await recordAudit({ actorId: request.user.id, action: 'media.cover.uploaded', subjectType: 'cover', metadata: { key: cover.key }, ipAddress: request.ip });
return reply.code(201).send({ data: cover });
} catch (error) {
return storageError(reply, error);
}
});
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 {
const video = await createBunnyVideo(input.title); const video = await createBunnyVideo(input.title);
await pool.query('insert into bunny_video_uploads (video_id, owner_id) values ($1, $2)', [video.id, request.user.id]);
await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip }); await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip });
return reply.code(201).send({ data: video }); return reply.code(201).send({ data: video });
} catch (error) { } catch (error) {
@@ -53,6 +143,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => { app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params); const { videoId } = videoParamsSchema.parse(request.params);
try {
await assertVideoAccess(videoId, request.user);
} catch (error) {
return providerError(reply, error);
}
const contentLength = Number(request.headers['content-length'] || 0); const contentLength = Number(request.headers['content-length'] || 0);
const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024; const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
if (contentLength > maxBytes) { if (contentLength > maxBytes) {
@@ -60,7 +155,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
} }
try { try {
const video = await uploadBunnyVideo(videoId, request.body as ReadableStream, request.headers['content-type']); const body = validateVideoUpload(request.body as import('node:stream').Readable, request.headers['content-type'], maxBytes);
const video = await uploadBunnyVideo(videoId, body as unknown as ReadableStream, request.headers['content-type']);
await persistBunnyStatus(video); await persistBunnyStatus(video);
await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip }); await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip });
return { data: video }; return { data: video };
@@ -72,6 +168,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => { app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params); const { videoId } = videoParamsSchema.parse(request.params);
try { try {
await assertVideoAccess(videoId, request.user);
const video = await getBunnyVideo(videoId); const video = await getBunnyVideo(videoId);
await persistBunnyStatus(video); await persistBunnyStatus(video);
return { data: video }; return { data: video };
@@ -79,4 +176,17 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return providerError(reply, error); return providerError(reply, error);
} }
}); });
app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
const { videoId } = videoParamsSchema.parse(request.params);
try {
await assertVideoAccess(videoId, request.user);
await deleteBunnyVideo(videoId);
await pool.query('delete from bunny_video_uploads where video_id = $1', [videoId]);
await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip });
return reply.code(204).send();
} catch (error) {
return providerError(reply, error);
}
});
}; };

View File

@@ -0,0 +1,131 @@
import type { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { recordAudit } from '../audit.js';
import { pool } from '../db/pool.js';
import { courseSelect } from './courses.js';
const profileParamsSchema = z.object({ profileId: z.string().uuid() });
const instructorParamsSchema = z.object({ instructorId: z.string().uuid() });
const optionalUrl = z.union([z.string().url().max(500), z.literal(''), z.null()]).optional()
.transform((value) => value || null);
const profileUpdateSchema = z.object({
name: z.string().trim().min(2).max(120),
avatarImageUrl: optionalUrl,
headline: z.string().trim().max(180),
bio: z.string().trim().max(3_000),
websiteUrl: optionalUrl,
linkedinUrl: optionalUrl,
instagramUrl: optionalUrl,
youtubeUrl: optionalUrl,
isPublic: z.boolean().optional(),
});
type ProfileRow = {
id: string;
email?: string;
name: string;
role: 'student' | 'instructor' | 'admin';
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
isPublic: boolean;
};
const profileColumns = `
id,
display_name as name,
role,
avatar_image_url as "avatarImageUrl",
profile_headline as headline,
profile_bio as bio,
website_url as "websiteUrl",
linkedin_url as "linkedinUrl",
instagram_url as "instagramUrl",
youtube_url as "youtubeUrl",
profile_is_public as "isPublic"`;
const withoutProtectedMedia = (course: Record<string, unknown>) => ({
...course,
assets: [],
lessons: Array.isArray(course.lessons)
? course.lessons.map((lesson) => ({ ...lesson, media: [], assets: [] }))
: [],
});
export const profileRoutes: FastifyPluginAsync = async (app) => {
app.get('/me', { preHandler: app.authenticate }, async (request) => {
const result = await pool.query<ProfileRow>(
`select ${profileColumns}, email from users where id = $1`,
[request.user.id],
);
return { data: result.rows[0] };
});
app.patch('/me', { preHandler: app.authenticate }, async (request, reply) => {
const input = profileUpdateSchema.parse(request.body);
const result = await pool.query<ProfileRow>(
`update users set
display_name = $2,
avatar_image_url = $3,
profile_headline = $4,
profile_bio = $5,
website_url = $6,
linkedin_url = $7,
instagram_url = $8,
youtube_url = $9,
profile_is_public = coalesce($10, profile_is_public),
updated_at = now()
where id = $1
returning ${profileColumns}, email`,
[
request.user.id,
input.name,
input.avatarImageUrl,
input.headline,
input.bio,
input.websiteUrl,
input.linkedinUrl,
input.instagramUrl,
input.youtubeUrl,
input.isPublic,
],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: 'Perfil não encontrado.' });
await recordAudit({ actorId: request.user.id, action: 'profile.updated', subjectType: 'user', subjectId: request.user.id, metadata: { public: profile.isPublic }, ipAddress: request.ip });
return { data: profile };
});
const getPublicProfile = async (profileId: string, reply: { code: (status: number) => { send: (payload: object) => unknown } }, instructorOnly = false) => {
const result = await pool.query<ProfileRow>(
`select ${profileColumns}
from users
where id = $1 and profile_is_public${instructorOnly ? " and role in ('instructor', 'admin')" : ''}`,
[profileId],
);
const profile = result.rows[0];
if (!profile) return reply.code(404).send({ error: instructorOnly ? 'Perfil de instrutor não encontrado.' : 'Perfil não encontrado ou não está público.' });
const canPublishCourses = profile.role === 'instructor' || profile.role === 'admin';
const courses = canPublishCourses
? await pool.query(`${courseSelect()} where c.instructor_id = $1 and c.status = 'published' order by c.published_at desc`, [profileId])
: { rows: [] };
return { data: { ...profile, courses: courses.rows.map(withoutProtectedMedia) } };
};
app.get('/:profileId', async (request, reply) => {
const { profileId } = profileParamsSchema.parse(request.params);
return getPublicProfile(profileId, reply);
});
app.get('/instructors/:instructorId', async (request, reply) => {
const { instructorId } = instructorParamsSchema.parse(request.params);
return getPublicProfile(instructorId, reply, true);
});
};

View File

@@ -0,0 +1,23 @@
import { config } from '../config.js';
import nodemailer from 'nodemailer';
type WelcomeRecipient = { name: string; email: string };
export async function sendWelcomeEmail(recipient: WelcomeRecipient) {
if (!config.SMTP_HOST || !config.SMTP_PORT || !config.SMTP_USER || !config.SMTP_PASS || !config.MAIL_FROM) return false;
const escapedName = recipient.name.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[character] || character));
const transporter = nodemailer.createTransport({
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: config.SMTP_PORT === 465,
auth: { user: config.SMTP_USER, pass: config.SMTP_PASS },
});
await transporter.sendMail({
from: config.MAIL_FROM,
to: recipient.email,
subject: 'Bem-vindo(a) ao Compor HUB',
html: `<main style="font-family:Arial,sans-serif;color:#18181b;line-height:1.55"><h1>Bem-vindo(a), ${escapedName}!</h1><p>Sua conta no <strong>Compor HUB</strong> já está pronta.</p><p>Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.</p><p><a href="${config.FRONTEND_ORIGIN}" style="display:inline-block;background:#f97316;color:#fff;padding:12px 18px;border-radius:8px;text-decoration:none;font-weight:700">Acessar Compor HUB</a></p></main>`,
});
return true;
}

View 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 };
}

View File

@@ -0,0 +1,62 @@
import { Transform, type Readable } from 'node:stream';
const allowedContentTypes = new Set(['video/mp4', 'video/webm', 'video/quicktime']);
export class VideoUploadValidationError extends Error {
constructor(message: string, public readonly statusCode: 403 | 413 | 415) {
super(message);
}
}
const isRecognizedVideo = (header: Buffer) => {
// ISO Base Media (MP4/MOV): bytes 4–7 identify the file type box.
const isMp4Family = header.length >= 12 && header.subarray(4, 8).toString('ascii') === 'ftyp';
// WebM/Matroska EBML signature.
const isWebm = header.length >= 4 && header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
return isMp4Family || isWebm;
};
class ValidatedVideoStream extends Transform {
private totalBytes = 0;
private header = Buffer.alloc(0);
private validated = false;
constructor(private readonly maxBytes: number) {
super();
}
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) {
this.totalBytes += chunk.length;
if (this.totalBytes > this.maxBytes) {
callback(new VideoUploadValidationError('Video is larger than the configured upload limit.', 413));
return;
}
if (!this.validated) {
this.header = Buffer.concat([this.header, chunk]).subarray(0, 32);
if (this.header.length >= 12) {
if (!isRecognizedVideo(this.header)) {
callback(new VideoUploadValidationError('Envie apenas arquivos de vídeo MP4, WebM ou MOV válidos.', 415));
return;
}
this.validated = true;
}
}
callback(null, chunk);
}
override _flush(callback: (error?: Error | null) => void) {
if (!this.validated) {
callback(new VideoUploadValidationError('O arquivo enviado não é um vídeo válido.', 415));
return;
}
callback();
}
}
export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) {
const normalizedType = contentType?.split(';')[0]?.toLowerCase();
if (!normalizedType || !allowedContentTypes.has(normalizedType)) {
throw new VideoUploadValidationError('Envie apenas vídeos MP4, WebM ou MOV.', 415);
}
return input.pipe(new ValidatedVideoStream(maxBytes));
}

View File

@@ -10,6 +10,23 @@ export interface ApiUser {
role: ApiRole; role: ApiRole;
} }
export interface Profile {
id: string;
email?: string;
name: string;
role: ApiRole;
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
isPublic: boolean;
}
export type ProfileInput = Omit<Profile, 'id' | 'email' | 'role'>;
export interface ManagedUser { export interface ManagedUser {
id: string; id: string;
email: string; email: string;
@@ -27,6 +44,57 @@ export interface InstructorAnalytics { courses: number; lessons: number; learner
export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; } export interface AuditEntry { id: string; action: string; subjectType: string; subjectId: string | null; metadata: Record<string, unknown>; createdAt: string; actorName: string; }
export interface BunnyVideo { id: string; title: string; status: 'processing' | 'ready' | 'failed'; providerStatus: number; encodeProgress: number; durationSeconds: number | null; } export interface BunnyVideo { id: string; title: string; status: 'processing' | 'ready' | 'failed'; providerStatus: number; encodeProgress: number; durationSeconds: number | null; }
export interface LessonPlayback { provider: string; kind: 'embed' | 'video'; embedUrl?: string; source?: string; expiresAt?: string; } export interface LessonPlayback { provider: string; kind: 'embed' | 'video'; embedUrl?: string; source?: string; expiresAt?: string; }
export interface LearningPathCourse {
id: string;
title: string;
status: 'draft' | 'published' | 'archived';
coverImageUrl: string | null;
position: number;
}
export interface LearningPath {
id: string;
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published' | 'archived';
publishedAt: string | null;
createdAt: string;
courses: LearningPathCourse[];
}
export interface LearningPathInput {
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published';
courseIds: string[];
}
export interface TrailCourseOption {
id: string;
title: string;
status: 'draft' | 'published';
category: string;
coverImageUrl: string | null;
}
export interface HomeBannerInput {
kind: 'course' | 'custom';
courseId: string | null;
imageUrl: string | null;
title: string;
description: string;
}
export interface HomeBanner extends HomeBannerInput {
id: string;
position: number;
courseTitle: string | null;
courseDescription: string | null;
courseImageUrl: string | null;
}
interface Session { interface Session {
token: string; token: string;
@@ -58,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);
@@ -69,7 +157,7 @@ export async function apiRequest<T>(path: string, options: RequestInit = {}): Pr
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new ApiError(body.error || 'The request could not be completed', response.status); throw new ApiError(body.error || 'Não foi possível concluir a solicitação.', response.status);
} }
return body as T; return body as T;
} }
@@ -80,7 +168,17 @@ async function uploadRequest<T>(path: string, file: File): Promise<T> {
if (session) headers.set('Authorization', `Bearer ${session.token}`); if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file }); const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file });
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'The video upload could not be completed', response.status); if (!response.ok) throw new ApiError(body.error || 'Não foi possível concluir o envio do vídeo.', response.status);
return body as T;
}
async function uploadImageRequest<T>(path: string, file: File): Promise<T> {
const session = getSession();
const headers = new Headers({ 'Content-Type': file.type || 'application/octet-stream' });
if (session) headers.set('Authorization', `Bearer ${session.token}`);
const response = await fetch(`${API_URL}${path}`, { method: 'POST', headers, body: file });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new ApiError(body.error || 'Não foi possível enviar a imagem de capa.', response.status);
return body as T; return body as T;
} }
@@ -107,27 +205,42 @@ export const adminApi = {
async listUsers() { async listUsers() {
return apiRequest<{ data: ManagedUser[] }>('/admin/users'); return apiRequest<{ data: ManagedUser[] }>('/admin/users');
}, },
async updateUser(userId: string, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) { async updateUser(userId: string, update: Partial<Pick<ManagedUser, 'name' | 'email' | 'role' | 'isActive'>>) {
return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, { return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(update), body: JSON.stringify(update),
}); });
}, },
async invite(email: string, role: 'student' | 'instructor') { async invite(email: string, role: 'instructor' = 'instructor') {
return apiRequest<{ data: { inviteUrl: string } }>('/admin/invitations', { method: 'POST', body: JSON.stringify({ email, role }) }); return apiRequest<{ data: { inviteUrl: string } }>('/admin/invitations', { method: 'POST', body: JSON.stringify({ email, role }) });
}, },
async passwordReset(userId: string) { async passwordReset(userId: string) {
return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' }); return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' });
}, },
async deleteUser(userId: string) { return apiRequest<void>(`/admin/users/${userId}`, { method: 'DELETE' }); },
async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); }, async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); },
async auditLog() { return apiRequest<{ data: AuditEntry[] }>('/admin/audit-log'); }, async auditLog() { return apiRequest<{ data: AuditEntry[] }>('/admin/audit-log'); },
async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); }, async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); },
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 homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string }> } }>('/admin/home-configuration'); }, 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 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 homeBanners() { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners'); },
async updateHomeBanners(banners: HomeBannerInput[]) { return apiRequest<{ data: HomeBanner[] }>('/admin/home-banners', { method: 'PUT', body: JSON.stringify({ banners }) }); },
async homeConfiguration() { return apiRequest<{ data: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null; courses: Array<{ id: string; title: string; status: string; category: string; coverImageUrl: string | null }> } }>('/admin/home-configuration'); },
async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); }, async updateHomeConfiguration(input: { featuredCourseId: string | null; courseOrder: string[]; defaultCoverImageUrl: string | null }) { return apiRequest<{ data: typeof input }>('/admin/home-configuration', { method: 'PUT', body: JSON.stringify(input) }); },
}; };
export const profileApi = {
async me() { return apiRequest<{ data: Profile }>('/profiles/me'); },
async update(input: ProfileInput) { return apiRequest<{ data: Profile }>('/profiles/me', { method: 'PATCH', body: JSON.stringify(input) }); },
async public(profileId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/${profileId}`); },
async instructor(instructorId: string) { return apiRequest<{ data: Profile & { courses: unknown[] } }>(`/profiles/instructors/${instructorId}`); },
};
export const instructorApi = { export const instructorApi = {
async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); }, async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); },
async bunnyConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/bunny/config'); }, async bunnyConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/bunny/config'); },
@@ -140,6 +253,13 @@ export const instructorApi = {
async bunnyVideoStatus(videoId: string) { async bunnyVideoStatus(videoId: string) {
return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`); return apiRequest<{ data: BunnyVideo }>(`/manage/media/bunny/videos/${videoId}`);
}, },
async deleteBunnyVideo(videoId: string) {
return apiRequest<void>(`/manage/media/bunny/videos/${videoId}`, { method: 'DELETE' });
},
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 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 = {

View File

@@ -8,19 +8,24 @@ type ApiAsset = {
name: string; name: string;
kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link'; kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
url: string; url: string;
sizeBytes: number | null;
createdAt: string;
description: string; description: string;
}; };
type ApiCourse = { export type ApiCourse = {
id: string; id: string;
title: string; title: string;
description: string; description: string;
category: string; category: string;
coverImageUrl: string | null; coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
status: 'draft' | 'published' | 'archived'; status: 'draft' | 'published' | 'archived';
progress?: number; progress?: number;
lastActivity?: string | null; lastActivity?: string | null;
instructor: { name: string }; instructor: { id: string; name: string };
lessons: Array<{ lessons: Array<{
id: string; id: string;
title: string; title: string;
@@ -41,6 +46,39 @@ type ApiCourse = {
assets: ApiAsset[]; assets: ApiAsset[];
}; };
export type PublicLearningPath = {
id: string;
title: string;
description: string;
coverImageUrl: string | null;
coverImageZoom: number;
coverImagePositionX: number;
coverImagePositionY: number;
courses: Array<{
id: string;
title: string;
category: string;
coverImageUrl: string | null;
position: number;
}>;
};
export type PublicHomeBanner = {
id: string;
kind: 'course' | 'custom';
courseId: string | null;
imageUrl: string | null;
title: string;
description: string;
position: number;
courseTitle: string | null;
courseDescription: string | null;
courseImageUrl: string | null;
coverImageZoom: number | null;
coverImagePositionX: number | null;
coverImagePositionY: number | null;
};
const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => { const toAttachmentType = (kind: ApiAsset['kind']): AttachmentType => {
if (kind === 'document') return 'doc'; if (kind === 'document') return 'doc';
if (kind === 'archive') return 'zip'; if (kind === 'archive') return 'zip';
@@ -52,26 +90,51 @@ const toAttachment = (asset: ApiAsset): Attachment => ({
name: asset.name, name: asset.name,
type: toAttachmentType(asset.kind), type: toAttachmentType(asset.kind),
url: asset.url, url: asset.url,
size: asset.sizeBytes === null ? undefined : formatBytes(asset.sizeBytes),
sizeBytes: asset.sizeBytes ?? undefined,
createdAt: asset.createdAt,
description: asset.description, description: asset.description,
}); });
const toDuration = (seconds: number | null) => { const formatBytes = (bytes: number) => {
if (!seconds) return '—'; if (bytes < 1024) return `${bytes} B`;
const minutes = Math.round(seconds / 60); const units = ['KB', 'MB', 'GB'];
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`; const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)) - 1, units.length - 1);
return `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 1 }).format(bytes / 1024 ** (index + 1))} ${units[index]}`;
}; };
const toCourse = (course: ApiCourse): Course => ({ const toDuration = (seconds: number | null) => {
if (!seconds || seconds < 1) return '—';
const totalSeconds = Math.round(seconds);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const remainingSeconds = totalSeconds % 60;
if (hours) return `${hours}h ${String(minutes).padStart(2, '0')}min`;
if (minutes) return `${minutes}min`;
return `${remainingSeconds}s`;
};
const toCourseDuration = (lessons: ApiCourse['lessons']) => {
const seconds = lessons.reduce((total, lesson) => total + (lesson.media[0]?.durationSeconds ?? lesson.durationSeconds ?? 0), 0);
if (seconds) return toDuration(seconds);
return lessons.length ? 'Processando' : '—';
};
export const toCourse = (course: ApiCourse): Course => ({
id: course.id, id: course.id,
title: course.title, title: course.title,
description: course.description, description: course.description,
category: course.category, category: course.category,
thumbnail: course.coverImageUrl || '/course-placeholder.svg', thumbnail: course.coverImageUrl || '/course-placeholder.svg',
coverImageZoom: course.coverImageZoom || 1,
coverImagePositionX: course.coverImagePositionX ?? 50,
coverImagePositionY: course.coverImagePositionY ?? 50,
instructor: course.instructor.name, instructor: course.instructor.name,
instructorId: course.instructor.id,
status: course.status === 'draft' ? 'draft' : 'published', status: course.status === 'draft' ? 'draft' : 'published',
progress: course.progress, progress: course.progress,
lastActivity: course.lastActivity || undefined, lastActivity: course.lastActivity || undefined,
duration: `${course.lessons.length} Aulas`, duration: toCourseDuration(course.lessons),
lessons: course.lessons.map((lesson): Lesson => { lessons: course.lessons.map((lesson): Lesson => {
const media: LessonMedia[] = lesson.media.map((item) => ({ const media: LessonMedia[] = lesson.media.map((item) => ({
id: item.id, id: item.id,
@@ -86,7 +149,7 @@ const toCourse = (course: ApiCourse): Course => ({
id: lesson.id, id: lesson.id,
title: lesson.title, title: lesson.title,
description: lesson.description, description: lesson.description,
duration: toDuration(lesson.durationSeconds), duration: toDuration(media[0]?.durationSeconds ?? lesson.durationSeconds),
isFree: lesson.accessLevel === 'public', isFree: lesson.accessLevel === 'public',
videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '', videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
mediaProvider: media[0]?.provider, mediaProvider: media[0]?.provider,
@@ -115,22 +178,34 @@ const toAssetPayload = (attachment: Attachment) => ({
name: attachment.name, name: attachment.name,
kind: toAssetKind(attachment.type), kind: toAssetKind(attachment.type),
url: attachment.url, url: attachment.url,
sizeBytes: attachment.sizeBytes ?? parseSizeBytes(attachment.size),
description: attachment.description || '', description: attachment.description || '',
accessLevel: 'enrolled' as const, accessLevel: 'enrolled' as const,
}); });
const parseSizeBytes = (value?: string) => {
if (!value) return null;
const match = value.trim().replace(',', '.').match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i);
if (!match) return null;
const units: Record<string, number> = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
return Math.round(Number(match[1]) * units[match[2].toUpperCase()]);
};
const toCoursePayload = (course: Course) => ({ const toCoursePayload = (course: Course) => ({
title: course.title, title: course.title,
description: course.description, description: course.description,
category: course.category, category: course.category,
coverImageUrl: course.thumbnail && !course.thumbnail.startsWith('blob:') ? course.thumbnail : null, coverImageUrl: course.thumbnail && /^https?:\/\//.test(course.thumbnail) ? course.thumbnail : null,
coverImageZoom: course.coverImageZoom || 1,
coverImagePositionX: course.coverImagePositionX ?? 50,
coverImagePositionY: course.coverImagePositionY ?? 50,
status: course.status || 'published', status: course.status || 'published',
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload), assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
lessons: course.lessons.map((lesson) => ({ lessons: course.lessons.map((lesson) => ({
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined, id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
title: lesson.title, title: lesson.title,
description: lesson.description || '', description: lesson.description || '',
durationSeconds: toSeconds(lesson.duration), durationSeconds: lesson.media?.[0]?.durationSeconds ?? toSeconds(lesson.duration),
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const, accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
media: lesson.media?.length media: lesson.media?.length
? lesson.media.map((media) => ({ ? lesson.media.map((media) => ({
@@ -153,6 +228,40 @@ export const getCourses = async (): Promise<Course[]> => {
return response.data.map(toCourse); return response.data.map(toCourse);
}; };
export type InstructorProfileData = {
id: string;
name: string;
role: 'student' | 'instructor' | 'admin';
avatarImageUrl: string | null;
headline: string;
bio: string;
websiteUrl: string | null;
linkedinUrl: string | null;
instagramUrl: string | null;
youtubeUrl: string | null;
courses: Course[];
};
export const getInstructorProfile = async (instructorId: string): Promise<InstructorProfileData> => {
const response = await apiRequest<{ data: Omit<InstructorProfileData, 'courses'> & { courses: ApiCourse[] } }>(`/profiles/instructors/${instructorId}`);
return { ...response.data, courses: response.data.courses.map(toCourse) };
};
export const getPublicProfile = async (profileId: string): Promise<InstructorProfileData> => {
const response = await apiRequest<{ data: Omit<InstructorProfileData, 'courses'> & { courses: ApiCourse[] } }>(`/profiles/${profileId}`);
return { ...response.data, courses: response.data.courses.map(toCourse) };
};
export const getLearningPaths = async (): Promise<PublicLearningPath[]> => {
const response = await apiRequest<{ data: PublicLearningPath[] }>('/courses/paths');
return response.data;
};
export const getHomeBanners = async (): Promise<PublicHomeBanner[]> => {
const response = await apiRequest<{ data: PublicHomeBanner[] }>('/courses/home-banners');
return response.data;
};
export const getManagedCourses = async (): Promise<Course[]> => { export const getManagedCourses = async (): Promise<Course[]> => {
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses'); const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
return response.data.map(toCourse); return response.data.map(toCourse);

View File

@@ -8,6 +8,8 @@ export interface Attachment {
type: AttachmentType; type: AttachmentType;
url: string; url: string;
size?: string; // e.g. "4.2 MB" size?: string; // e.g. "4.2 MB"
sizeBytes?: number;
createdAt?: string;
description?: string; description?: string;
lessonId?: string; // Associated lesson or global course asset lessonId?: string; // Associated lesson or global course asset
downloadCount?: number; downloadCount?: number;
@@ -42,12 +44,16 @@ export interface Course {
title: string; title: string;
category: string; category: string;
thumbnail: string; thumbnail: string;
coverImageZoom?: number;
coverImagePositionX?: number;
coverImagePositionY?: number;
lessons: Lesson[]; lessons: Lesson[];
progress?: number; // 0 to 100 progress?: number; // 0 to 100
views?: number; views?: number;
duration: string; // Total duration or lesson count duration: string; // Total duration or lesson count
description: string; description: string;
instructor?: string; instructor?: string;
instructorId?: string;
instructorRole?: string; instructorRole?: string;
tips?: string[]; // Key takeaways / instructor tips tips?: string[]; // Key takeaways / instructor tips
attachments?: Attachment[]; // Downloadable materials across entire course attachments?: Attachment[]; // Downloadable materials across entire course