Compare commits
35 Commits
1d5eafbbd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95a4bd4ac7 | ||
|
|
a861644f6c | ||
|
|
6981092589 | ||
|
|
61b7bba5af | ||
|
|
81a67bf123 | ||
|
|
d9184372ca | ||
|
|
61359a22a5 | ||
|
|
bdaff3f5df | ||
|
|
f5aa826b32 | ||
|
|
aec9c001d3 | ||
|
|
10c3d03d9c | ||
|
|
54f598cd99 | ||
|
|
ad736758a7 | ||
|
|
987eb210eb | ||
|
|
8b94a25807 | ||
|
|
3ae9c03614 | ||
|
|
ee8d365321 | ||
|
|
03a6491989 | ||
|
|
7b2d25aabb | ||
|
|
07fbc7d009 | ||
|
|
37ce8e8afb | ||
|
|
815d3f93c1 | ||
|
|
a6f53ff0d3 | ||
|
|
ae39fefa7d | ||
|
|
c18e63f0e4 | ||
|
|
895b0dd00e | ||
|
|
5b86d6ef0d | ||
|
|
09d85a7123 | ||
|
|
79d1c70c44 | ||
|
|
e18bb76630 | ||
|
|
ed00f60281 | ||
|
|
66671f65c3 | ||
|
|
1a79fa447b | ||
|
|
7a01be2b59 | ||
|
|
ad860074fd |
@@ -20,6 +20,14 @@ JWT_SESSION_TTL=7d
|
||||
INVITATION_TTL_HOURS=168
|
||||
PASSWORD_RESET_TTL_HOURS=24
|
||||
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
|
||||
|
||||
# Bunny Stream. Set all three in Portainer to enable instructor uploads and
|
||||
@@ -40,6 +48,7 @@ BUNNY_STORAGE_PASSWORD=
|
||||
BUNNY_STORAGE_ENDPOINT=
|
||||
BUNNY_STORAGE_CDN_HOST=
|
||||
BUNNY_COVER_MAX_UPLOAD_MB=10
|
||||
BUNNY_ASSET_MAX_UPLOAD_MB=100
|
||||
|
||||
# Docker Swarm / Portainer image tags. The defaults are the Gitea Container Registry images.
|
||||
API_IMAGE=gitea.blyzer.com.br/blyzer/compor-academy-api
|
||||
|
||||
9
App.tsx
9
App.tsx
@@ -12,6 +12,10 @@ import { Course, UserRole } from './types';
|
||||
import { SuperAdmin } from './pages/SuperAdmin';
|
||||
import { AccessTokenPage } from './pages/AccessTokenPage';
|
||||
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
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
|
||||
@@ -92,7 +96,12 @@ function AppContent() {
|
||||
</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="/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="/reset-password" element={<AccessTokenPage mode="reset" />} />
|
||||
|
||||
|
||||
@@ -38,9 +38,10 @@ BUNNY_STORAGE_PASSWORD=the-storage-zone-password
|
||||
BUNNY_STORAGE_ENDPOINT=https://the-storage-endpoint-shown-in-bunny
|
||||
BUNNY_STORAGE_CDN_HOST=https://your-pull-zone.b-cdn.net
|
||||
BUNNY_COVER_MAX_UPLOAD_MB=10
|
||||
BUNNY_ASSET_MAX_UPLOAD_MB=100
|
||||
```
|
||||
|
||||
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload only JPG, PNG, or WebP cover images; the API verifies the image bytes and uploads them under a generated `covers/` filename. The CDN URL is then saved on the course.
|
||||
The Storage Password is available in Bunny's **Storage Zone → FTP & API Access** section. Never expose it as a `VITE_` variable. In Academy, instructors can upload JPG, PNG, or WebP cover images and PDF, DOCX, XLSX, CSV, or ZIP support materials; the API validates the file bytes and determines the material type and size automatically. Covers are stored under `covers/` and course materials under `materials/`.
|
||||
|
||||
### Bunny processing webhooks
|
||||
|
||||
@@ -69,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.
|
||||
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
|
||||
- `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).
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react';
|
||||
import { Course, SectionVariant } from '../types';
|
||||
import { CourseCoverImage } from './CourseCoverImage';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface CourseCardProps {
|
||||
course: Course;
|
||||
@@ -15,6 +17,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
|
||||
onClick,
|
||||
onOpenMaterials
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const widthClass = variant === 'landscape'
|
||||
? 'w-[320px] md:w-[420px]'
|
||||
: 'w-[200px] md:w-[260px]';
|
||||
@@ -37,13 +40,17 @@ export const CourseCard: React.FC<CourseCardProps> = ({
|
||||
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`}
|
||||
>
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
<CourseCoverImage
|
||||
source={course.thumbnail}
|
||||
alt={course.title}
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
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"
|
||||
style={{
|
||||
transform: `scale(${course.coverImageZoom || 1})`,
|
||||
objectPosition: `${course.coverImagePositionX ?? 50}% ${course.coverImagePositionY ?? 50}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Badges on Thumbnail */}
|
||||
@@ -94,7 +101,7 @@ export const CourseCard: React.FC<CourseCardProps> = ({
|
||||
{course.instructor && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
39
components/CourseCoverImage.tsx
Normal file
39
components/CourseCoverImage.tsx
Normal 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?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { CourseRow } from './CourseRow';
|
||||
import { Section, Course } from '../types';
|
||||
import { getCourses } from '../services/db';
|
||||
import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { LearningPathRow } from './LearningPathRow';
|
||||
|
||||
interface CourseGridProps {
|
||||
onCourseSelect?: (course: Course) => void;
|
||||
@@ -15,6 +16,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
selectedCategoryFilter
|
||||
}) => {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [paths, setPaths] = useState<PublicLearningPath[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all');
|
||||
const { user } = useAuth();
|
||||
@@ -39,7 +41,9 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setCourses(await getCourses());
|
||||
const [loadedCourses, loadedPaths] = await Promise.all([getCourses(), getLearningPaths()]);
|
||||
setCourses(loadedCourses);
|
||||
setPaths(loadedPaths);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -138,11 +142,6 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
variant: 'landscape',
|
||||
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 (
|
||||
@@ -238,6 +237,7 @@ export const CourseGrid: React.FC<CourseGridProps> = ({
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{activeCategory === 'all' && paths.length > 0 && <LearningPathRow paths={paths} courses={courses} onCourseSelect={onCourseSelect} />}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,81 +1,24 @@
|
||||
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 { getCourses } from '../services/db';
|
||||
import { getCourses, getHomeBanners, PublicHomeBanner } from '../services/db';
|
||||
import { CourseCoverImage } from './CourseCoverImage';
|
||||
|
||||
interface HeroProps {
|
||||
onPlay?: (course: Course) => void;
|
||||
}
|
||||
interface HeroProps { onPlay?: (course: Course) => void; }
|
||||
|
||||
export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
|
||||
const [featuredCourse, setFeaturedCourse] = useState<Course | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCourses().then((courses) => setFeaturedCourse(courses[0] || null)).catch(() => setFeaturedCourse(null));
|
||||
}, []);
|
||||
|
||||
if (!featuredCourse) return null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Background Image with Cinematic Gradient Overlay */}
|
||||
<div className="absolute inset-0 transition-transform duration-[20s] ease-in-out group-hover:scale-105">
|
||||
<img
|
||||
src={featuredCourse.thumbnail}
|
||||
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>
|
||||
);
|
||||
const [banners, setBanners] = useState<PublicHomeBanner[]>([]);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
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];
|
||||
const banner = banners[index];
|
||||
const course = banner?.courseId ? courses.find((item) => item.id === banner.courseId) : fallback;
|
||||
if (!banner && !fallback) return null;
|
||||
const title = banner ? (banner.kind === 'course' ? banner.courseTitle || banner.title : banner.title) : fallback.title;
|
||||
const description = banner ? (banner.kind === 'course' ? banner.courseDescription || banner.description : banner.description) : fallback.description;
|
||||
const image = banner ? (banner.kind === 'course' ? banner.courseImageUrl : banner.imageUrl) : fallback.thumbnail;
|
||||
const canPlay = Boolean(course && (banner?.kind !== 'custom' || banner.courseId));
|
||||
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>;
|
||||
};
|
||||
|
||||
38
components/LearningPathRow.tsx
Normal file
38
components/LearningPathRow.tsx
Normal 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>}
|
||||
</>;
|
||||
};
|
||||
@@ -25,6 +25,18 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
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);
|
||||
|
||||
if (isRegistering && role === 'professor') {
|
||||
@@ -40,7 +52,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
||||
|
||||
if (!signedInUser) {
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
@@ -83,11 +95,11 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<form noValidate onSubmit={handleSubmit} className="space-y-5">
|
||||
<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 />}
|
||||
<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="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 />
|
||||
{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" autoFocus={!isRegistering} />
|
||||
<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>
|
||||
|
||||
{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>}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Attachment, AttachmentType } from '../types';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { downloadCourseAsset } from '../services/api';
|
||||
|
||||
interface MaterialCardProps {
|
||||
attachment: Attachment;
|
||||
@@ -67,32 +68,29 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
||||
const { showDownloadToast, showToast } = useToast();
|
||||
const badge = getAttachmentBadge(attachment.type);
|
||||
|
||||
const handleAction = (e: React.MouseEvent) => {
|
||||
setDownloaded(true);
|
||||
const handleAction = async () => {
|
||||
if (attachment.type === 'link') {
|
||||
showToast(`Acessando link "${attachment.name}"`, 'info');
|
||||
} else {
|
||||
showDownloadToast(attachment.name, attachment.size);
|
||||
window.open(attachment.url, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onDownload) {
|
||||
onDownload(attachment);
|
||||
}
|
||||
// If it has a real URL that is not '#', let the default anchor do its work
|
||||
if (!attachment.url || attachment.url === '#') {
|
||||
e.preventDefault();
|
||||
// Trigger a synthetic download notification reset
|
||||
setTimeout(() => setDownloaded(false), 3000);
|
||||
try {
|
||||
await downloadCourseAsset(attachment.id, attachment.name);
|
||||
setDownloaded(true);
|
||||
showDownloadToast(attachment.name, attachment.size);
|
||||
onDownload?.(attachment);
|
||||
window.setTimeout(() => setDownloaded(false), 3000);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Não foi possível baixar este material.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<a
|
||||
href={attachment.url || '#'}
|
||||
target={attachment.type === 'link' ? '_blank' : '_self'}
|
||||
rel="noreferrer"
|
||||
onClick={handleAction}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAction()}
|
||||
className="flex items-center justify-between p-3.5 rounded-xl bg-zinc-900/90 hover:bg-zinc-800/90 transition-all duration-200 group shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3.5 min-w-0 pr-3">
|
||||
@@ -119,8 +117,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
||||
Baixado
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
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' ? (
|
||||
@@ -134,10 +131,10 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
||||
Baixar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,11 +166,9 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
||||
{attachment.size || 'Disponível'}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={attachment.url || '#'}
|
||||
target={attachment.type === 'link' ? '_blank' : '_self'}
|
||||
rel="noreferrer"
|
||||
onClick={handleAction}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAction()}
|
||||
className="inline-flex items-center gap-2 text-xs font-bold text-black bg-white hover:bg-orange-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 active:scale-95 shadow-md"
|
||||
>
|
||||
{attachment.type === 'link' ? (
|
||||
@@ -187,7 +182,7 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({
|
||||
Baixar Arquivo
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { LoginModal } from './LoginModal';
|
||||
import { SearchModal } from './SearchModal';
|
||||
@@ -15,6 +15,7 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [showSearchModal, setShowSearchModal] = useState(false);
|
||||
const [showAccountMenu, setShowAccountMenu] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -34,6 +35,10 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
|
||||
|
||||
const isMaterialsPage = location.pathname === '/materiais';
|
||||
const isHomePage = location.pathname === '/';
|
||||
const goTo = (path: string) => {
|
||||
setShowAccountMenu(false);
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -100,37 +105,22 @@ export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
|
||||
</button>
|
||||
) : (
|
||||
<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>}
|
||||
{user.role === 'superadmin' ? (
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
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
|
||||
<div className="relative">
|
||||
<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">
|
||||
<span className="hidden md:block">Olá, <strong className="font-semibold text-white">{user.name.split(' ')[0]}</strong></span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${showAccountMenu ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
) : user.role === 'professor' && (
|
||||
<button
|
||||
onClick={() => navigate('/gerenciar')}
|
||||
className={`text-sm font-semibold px-5 py-2.5 rounded-[980px] transition-colors ${
|
||||
location.pathname === '/gerenciar'
|
||||
? '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 do Instrutor
|
||||
</button>
|
||||
)}
|
||||
{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 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>
|
||||
{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>}
|
||||
{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>}
|
||||
{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></>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-[1px] bg-white/20 hidden sm:block"></div>
|
||||
|
||||
<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
|
||||
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"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Search, X, Loader2, PlayCircle, Download } from 'lucide-react';
|
||||
import { Course } from '../types';
|
||||
import { getCourses } from '../services/db';
|
||||
import { CourseCoverImage } from './CourseCoverImage';
|
||||
|
||||
interface SearchModalProps {
|
||||
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"
|
||||
>
|
||||
<div className="aspect-video bg-gray-900 rounded-[8px] overflow-hidden mb-3 relative">
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
<CourseCoverImage
|
||||
source={course.thumbnail}
|
||||
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]"
|
||||
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">
|
||||
<PlayCircle className="w-10 h-10 text-orange-500" />
|
||||
|
||||
51
components/UserManagementPanel.tsx
Normal file
51
components/UserManagementPanel.tsx
Normal 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>}
|
||||
</>;
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { User, UserRole } from '../types';
|
||||
import { authApi, clearSession, getSession, saveSession } from '../services/api';
|
||||
import { ApiUser, authApi, clearSession, getSession, saveSession } from '../services/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (email: string, password: string) => Promise<User | null>;
|
||||
register: (name: string, email: string, password: string) => Promise<User | null>;
|
||||
logout: () => void;
|
||||
updateCurrentUser: (user: ApiUser) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
@@ -67,8 +68,14 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
clearSession();
|
||||
};
|
||||
|
||||
const updateCurrentUser = (apiUser: ApiUser) => {
|
||||
const session = getSession();
|
||||
if (session) saveSession({ ...session, user: apiUser });
|
||||
setUser(toFrontendUser(apiUser));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, register, logout, isLoading }}>
|
||||
<AuthContext.Provider value={{ user, login, register, logout, updateCurrentUser, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,11 @@ services:
|
||||
INVITATION_TTL_HOURS: ${INVITATION_TTL_HOURS:-168}
|
||||
PASSWORD_RESET_TTL_HOURS: ${PASSWORD_RESET_TTL_HOURS:-24}
|
||||
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_API_KEY: ${BUNNY_STREAM_API_KEY:-}
|
||||
BUNNY_EMBED_TOKEN_KEY: ${BUNNY_EMBED_TOKEN_KEY:-}
|
||||
@@ -45,6 +50,7 @@ services:
|
||||
BUNNY_STORAGE_ENDPOINT: ${BUNNY_STORAGE_ENDPOINT:-}
|
||||
BUNNY_STORAGE_CDN_HOST: ${BUNNY_STORAGE_CDN_HOST:-}
|
||||
BUNNY_COVER_MAX_UPLOAD_MB: ${BUNNY_COVER_MAX_UPLOAD_MB:-10}
|
||||
BUNNY_ASSET_MAX_UPLOAD_MB: ${BUNNY_ASSET_MAX_UPLOAD_MB:-100}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3001/api/v1/health || exit 1"]
|
||||
interval: 15s
|
||||
|
||||
15
index.css
15
index.css
@@ -68,6 +68,21 @@ h1, h2, h3, h4, h5, h6, .font-display, .font-heading {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
17
index.tsx
17
index.tsx
@@ -2,6 +2,23 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
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');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"fastify": "^5.3.2",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"lucide-react": "0.344.0",
|
||||
"nodemailer": "^10.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -22,6 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
@@ -1475,6 +1477,16 @@
|
||||
"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": {
|
||||
"version": "8.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
||||
@@ -2231,6 +2243,15 @@
|
||||
"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": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"fastify": "^5.3.2",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"lucide-react": "0.344.0",
|
||||
"nodemailer": "^10.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -31,6 +32,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "^8.15.4",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tsx": "^4.19.4",
|
||||
|
||||
@@ -11,7 +11,10 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 {
|
||||
if (mode === 'invite') {
|
||||
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.'); }
|
||||
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>;
|
||||
};
|
||||
|
||||
34
pages/InstructorProfile.tsx
Normal file
34
pages/InstructorProfile.tsx
Normal 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>;
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
Image as ImageIcon,
|
||||
Minus,
|
||||
PlusCircle,
|
||||
Video,
|
||||
Loader2,
|
||||
FileText,
|
||||
@@ -29,12 +31,32 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Move,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Course, Comment, Attachment, Lesson, AttachmentType, LessonMedia } from '../types';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { getManagedCourses, saveCourse, deleteCourse, duplicateCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||
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
|
||||
const CourseModal: React.FC<{
|
||||
@@ -48,11 +70,24 @@ const CourseModal: React.FC<{
|
||||
const [category, setCategory] = useState('');
|
||||
const [status, setStatus] = useState<Course['status']>('draft');
|
||||
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 [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
|
||||
@@ -60,24 +95,20 @@ const CourseModal: React.FC<{
|
||||
|
||||
// New Lesson State
|
||||
const [newLessonTitle, setNewLessonTitle] = useState('');
|
||||
const [newLessonDuration, setNewLessonDuration] = useState('');
|
||||
const [newLessonMedia, setNewLessonMedia] = useState<LessonMedia | null>(null);
|
||||
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
||||
const [bunnyConfigured, setBunnyConfigured] = useState<boolean | null>(null);
|
||||
const [bunnyUploadState, setBunnyUploadState] = useState<'idle' | 'uploading' | 'uploaded' | 'error'>('idle');
|
||||
const [bunnyUploadMessage, setBunnyUploadMessage] = useState('');
|
||||
const [deletingVideoId, setDeletingVideoId] = useState<string | null>(null);
|
||||
|
||||
// Tips & Global Attachments
|
||||
const [tips, setTips] = useState<string[]>([]);
|
||||
const [newTip, setNewTip] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
|
||||
// New Attachment State
|
||||
const [newAttName, setNewAttName] = useState('');
|
||||
const [newAttType, setNewAttType] = useState<AttachmentType>('pdf');
|
||||
const [newAttUrl, setNewAttUrl] = useState('');
|
||||
const [newAttSize, setNewAttSize] = useState('');
|
||||
const [newAttDesc, setNewAttDesc] = useState('');
|
||||
const [newLinkName, setNewLinkName] = useState('');
|
||||
const [newLinkUrl, setNewLinkUrl] = useState('');
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -90,6 +121,10 @@ const CourseModal: React.FC<{
|
||||
setCategory(initialData.category);
|
||||
setStatus(initialData.status || 'published');
|
||||
setThumbnailPreview(initialData.thumbnail);
|
||||
setCoverImageUrl(initialData.thumbnail);
|
||||
setCoverImageZoom(initialData.coverImageZoom || 1);
|
||||
setCoverImagePositionX(initialData.coverImagePositionX ?? 50);
|
||||
setCoverImagePositionY(initialData.coverImagePositionY ?? 50);
|
||||
setTips(initialData.tips || []);
|
||||
setAttachments(initialData.attachments || []);
|
||||
setLessons(initialData.lessons || []);
|
||||
@@ -99,20 +134,36 @@ const CourseModal: React.FC<{
|
||||
setCategory('');
|
||||
setStatus('draft');
|
||||
setThumbnailPreview('');
|
||||
setCoverImageUrl('');
|
||||
setCoverImageZoom(1);
|
||||
setCoverImagePositionX(50);
|
||||
setCoverImagePositionY(50);
|
||||
setTips([]);
|
||||
setAttachments([]);
|
||||
setLessons([]);
|
||||
}
|
||||
if (localCoverPreviewRef.current) {
|
||||
URL.revokeObjectURL(localCoverPreviewRef.current);
|
||||
localCoverPreviewRef.current = null;
|
||||
}
|
||||
setCoverPreviewError(false);
|
||||
setIsAdjustingCoverPosition(false);
|
||||
setNewLessonMedia(null);
|
||||
setBunnyUploadState('idle');
|
||||
setBunnyUploadMessage('');
|
||||
setCoverUploadMessage('');
|
||||
setAssetUploadMessage('');
|
||||
setNewLinkName('');
|
||||
setNewLinkUrl('');
|
||||
instructorApi.bunnyConfiguration()
|
||||
.then((response) => setBunnyConfigured(response.data.configured))
|
||||
.catch(() => setBunnyConfigured(false));
|
||||
instructorApi.coverConfiguration()
|
||||
.then((response) => setCoverConfigured(response.data.configured))
|
||||
.catch(() => setCoverConfigured(false));
|
||||
instructorApi.assetConfiguration()
|
||||
.then((response) => setAssetConfigured(response.data.configured))
|
||||
.catch(() => setAssetConfigured(false));
|
||||
courseApi.categories()
|
||||
.then((response) => {
|
||||
setCategories(response.data);
|
||||
@@ -122,6 +173,10 @@ const CourseModal: React.FC<{
|
||||
}
|
||||
}, [isOpen, initialData]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (localCoverPreviewRef.current) URL.revokeObjectURL(localCoverPreviewRef.current);
|
||||
}, []);
|
||||
|
||||
const toBunnyMedia = (video: BunnyVideo): LessonMedia => ({
|
||||
provider: 'bunny',
|
||||
externalId: video.id,
|
||||
@@ -149,10 +204,6 @@ const CourseModal: React.FC<{
|
||||
const uploaded = await instructorApi.uploadBunnyVideo(created.data.id, file);
|
||||
const media = toBunnyMedia(uploaded.data);
|
||||
setNewLessonMedia(media);
|
||||
if (!newLessonDuration.trim() && uploaded.data.durationSeconds) {
|
||||
const minutes = Math.max(1, Math.round(uploaded.data.durationSeconds / 60));
|
||||
setNewLessonDuration(`${minutes}m`);
|
||||
}
|
||||
setBunnyUploadState('uploaded');
|
||||
setBunnyUploadMessage(uploaded.data.status === 'ready'
|
||||
? 'Vídeo pronto para ser adicionado à aula.'
|
||||
@@ -175,7 +226,15 @@ const CourseModal: React.FC<{
|
||||
setCoverUploadMessage(`Enviando ${file.name}...`);
|
||||
try {
|
||||
const response = await instructorApi.uploadCover(file);
|
||||
setThumbnailPreview(response.data.coverImageUrl);
|
||||
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.');
|
||||
@@ -184,6 +243,32 @@ const CourseModal: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
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 lesson = lessons.find((item) => item.id === lessonId);
|
||||
const media = lesson?.media?.[0];
|
||||
@@ -193,7 +278,7 @@ const CourseModal: React.FC<{
|
||||
const refreshed = toBunnyMedia(response.data);
|
||||
setLessons((current) => current.map((item) => item.id === lessonId ? {
|
||||
...item,
|
||||
duration: refreshed.durationSeconds ? `${Math.max(1, Math.round(refreshed.durationSeconds / 60))}m` : item.duration,
|
||||
duration: formatVideoDuration(refreshed.durationSeconds),
|
||||
media: [refreshed],
|
||||
} : item));
|
||||
} catch (error) {
|
||||
@@ -226,7 +311,7 @@ const CourseModal: React.FC<{
|
||||
if (!update) return lesson;
|
||||
return {
|
||||
...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],
|
||||
};
|
||||
}));
|
||||
@@ -248,7 +333,7 @@ const CourseModal: React.FC<{
|
||||
const newLesson: Lesson = {
|
||||
id: `lesson-${Date.now()}`,
|
||||
title: newLessonTitle,
|
||||
duration: newLessonDuration || '15m',
|
||||
duration: formatVideoDuration(newLessonMedia.durationSeconds),
|
||||
videoUrl: newLessonMedia.playbackUrl || '',
|
||||
mediaProvider: newLessonMedia.provider,
|
||||
media: [newLessonMedia],
|
||||
@@ -259,15 +344,32 @@ const CourseModal: React.FC<{
|
||||
setLessons([...lessons, newLesson]);
|
||||
|
||||
setNewLessonTitle('');
|
||||
setNewLessonDuration('');
|
||||
setNewLessonMedia(null);
|
||||
setNewLessonIsFree(false);
|
||||
setBunnyUploadState('idle');
|
||||
setBunnyUploadMessage('');
|
||||
};
|
||||
|
||||
const removeLesson = (id: string) => {
|
||||
setLessons(lessons.filter(l => l.id !== id));
|
||||
const removeLesson = async (lesson: Lesson) => {
|
||||
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) => {
|
||||
@@ -296,26 +398,75 @@ const CourseModal: React.FC<{
|
||||
setTips(tips.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleAddAttachment = () => {
|
||||
if (!newAttName.trim()) {
|
||||
alert("Informe o nome do arquivo para download.");
|
||||
const handleAttachmentUploads = async (files: FileList | File[]) => {
|
||||
const selectedFiles = Array.from(files);
|
||||
if (!selectedFiles.length) return;
|
||||
if (!assetConfigured) {
|
||||
setAssetUploadMessage('O armazenamento de materiais ainda não foi configurado pelo administrador.');
|
||||
return;
|
||||
}
|
||||
|
||||
const att: Attachment = {
|
||||
id: `att-${Date.now()}`,
|
||||
name: newAttName.trim(),
|
||||
type: newAttType,
|
||||
url: newAttUrl.trim() || '#',
|
||||
size: newAttSize.trim() || '1.5 MB',
|
||||
description: newAttDesc.trim()
|
||||
};
|
||||
setIsAssetUploading(true);
|
||||
setAssetUploadMessage(selectedFiles.length === 1 ? `Enviando ${selectedFiles[0].name}...` : `Enviando ${selectedFiles.length} arquivos...`);
|
||||
const uploadedAttachments: Attachment[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
for (const file of selectedFiles) {
|
||||
try {
|
||||
const response = await instructorApi.uploadAsset(file);
|
||||
const type: AttachmentType = response.data.kind === 'spreadsheet'
|
||||
? 'spreadsheet'
|
||||
: response.data.kind === 'archive'
|
||||
? 'zip'
|
||||
: file.name.toLowerCase().endsWith('.pdf')
|
||||
? 'pdf'
|
||||
: 'doc';
|
||||
uploadedAttachments.push({
|
||||
id: `asset-${response.data.key}`,
|
||||
name: file.name,
|
||||
type,
|
||||
url: response.data.assetUrl,
|
||||
sizeBytes: response.data.sizeBytes,
|
||||
description: '',
|
||||
});
|
||||
} catch {
|
||||
failedFiles.push(file.name);
|
||||
}
|
||||
}
|
||||
if (uploadedAttachments.length) setAttachments((current) => [...current, ...uploadedAttachments]);
|
||||
setAssetUploadMessage(failedFiles.length
|
||||
? `${uploadedAttachments.length} enviado(s). Não foi possível enviar: ${failedFiles.join(', ')}.`
|
||||
: `${uploadedAttachments.length} material(is) enviado(s) com sucesso.`);
|
||||
setIsAssetUploading(false);
|
||||
};
|
||||
|
||||
setAttachments([...attachments, att]);
|
||||
setNewAttName('');
|
||||
setNewAttSize('');
|
||||
setNewAttUrl('');
|
||||
setNewAttDesc('');
|
||||
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
event.target.value = '';
|
||||
if (!files) return;
|
||||
await handleAttachmentUploads(files);
|
||||
};
|
||||
|
||||
const handleAddLink = () => {
|
||||
const url = newLinkUrl.trim();
|
||||
if (!url) {
|
||||
alert('Informe o link do material.');
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
@@ -342,7 +493,7 @@ const CourseModal: React.FC<{
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const durationStr = `${lessons.length} Aulas`;
|
||||
const durationStr = totalVideoDuration(lessons);
|
||||
|
||||
try {
|
||||
await onSave({
|
||||
@@ -351,7 +502,10 @@ const CourseModal: React.FC<{
|
||||
description,
|
||||
category: category || categories[0],
|
||||
status,
|
||||
thumbnail: thumbnailPreview || '/course-placeholder.svg',
|
||||
thumbnail: coverImageUrl || '/course-placeholder.svg',
|
||||
coverImageZoom,
|
||||
coverImagePositionX,
|
||||
coverImagePositionY,
|
||||
duration: durationStr,
|
||||
instructor: initialData?.instructor || '',
|
||||
views: initialData?.views || 0,
|
||||
@@ -378,7 +532,7 @@ const CourseModal: React.FC<{
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-orange-400">{initialData ? 'Editar curso' : 'Novo curso'}</p>
|
||||
<h2 className="mt-1 text-2xl font-extrabold text-white">{editorStep === 1 ? 'Informações do curso' : editorStep === 2 ? 'Estruture as aulas' : 'Materiais complementares'}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Inclua os arquivos e links de apoio.'}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{editorStep === 1 ? 'Defina como o curso será apresentado.' : editorStep === 2 ? 'Adicione e organize a trilha de aprendizado.' : 'Envie os arquivos de apoio para seus alunos.'}</p>
|
||||
</div>
|
||||
<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>
|
||||
@@ -411,9 +565,71 @@ const CourseModal: React.FC<{
|
||||
>
|
||||
{thumbnailPreview ? (
|
||||
<>
|
||||
<img src={thumbnailPreview} alt="Preview" className="w-full h-full object-cover opacity-70 group-hover:opacity-40 transition-opacity" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-orange-500 px-4 py-2 rounded-xl text-xs font-bold shadow-lg">Alterar Capa</span>
|
||||
{coverPreviewError ? (
|
||||
<div className="p-6 text-center">
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
@@ -464,7 +680,7 @@ const CourseModal: React.FC<{
|
||||
<div>
|
||||
<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">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
@@ -497,52 +713,54 @@ const CourseModal: React.FC<{
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Add attachment inputs */}
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttName}
|
||||
onChange={(e) => setNewAttName(e.target.value)}
|
||||
placeholder="Ex.: Planilha de acompanhamento de campanhas"
|
||||
className="col-span-2 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<select
|
||||
value={newAttType}
|
||||
onChange={(e) => setNewAttType(e.target.value as AttachmentType)}
|
||||
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none"
|
||||
>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="spreadsheet">Planilha</option>
|
||||
<option value="zip">Pacote ZIP</option>
|
||||
<option value="doc">Documento</option>
|
||||
<option value="link">Link</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttSize}
|
||||
onChange={(e) => setNewAttSize(e.target.value)}
|
||||
placeholder="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="Explique como este material apoia o aprendizado."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
ref={attachmentFileRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.zip,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,application/zip"
|
||||
className="hidden"
|
||||
onChange={(event) => void handleAttachmentUpload(event)}
|
||||
/>
|
||||
<div
|
||||
onDragEnter={(event) => { event.preventDefault(); setIsDraggingAsset(true); }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; setIsDraggingAsset(true); }}
|
||||
onDragLeave={(event) => { event.preventDefault(); setIsDraggingAsset(false); }}
|
||||
onDrop={(event) => { event.preventDefault(); setIsDraggingAsset(false); void handleAttachmentUploads(event.dataTransfer.files); }}
|
||||
className={`rounded-xl border border-dashed p-5 transition ${isDraggingAsset ? 'border-orange-400 bg-orange-500/10' : 'border-white/15 bg-white/[0.02]'}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Envie um arquivo de apoio</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAttachment}
|
||||
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"
|
||||
disabled={!assetConfigured || isAssetUploading}
|
||||
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>
|
||||
</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>
|
||||
|
||||
{/* List of Attachments */}
|
||||
@@ -552,7 +770,7 @@ const CourseModal: React.FC<{
|
||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||
{getAttachmentIcon(att.type)}
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -566,7 +784,7 @@ const CourseModal: React.FC<{
|
||||
</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 rascunho' : 'Salvar e publicar'}</button>
|
||||
<button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="inline-flex items-center justify-center gap-2 rounded-xl bg-orange-500 px-6 py-3 text-sm font-extrabold text-white shadow-lg shadow-orange-500/20 transition-colors hover:bg-orange-600 disabled:opacity-60">{isSubmitting ? 'Salvando...' : status === 'draft' ? 'Salvar como não publicado' : 'Salvar e publicar'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -639,10 +857,12 @@ const CourseModal: React.FC<{
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLesson(lesson.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
disabled={Boolean(lesson.media?.[0]?.provider === 'bunny' && deletingVideoId === lesson.media[0].externalId)}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -656,20 +876,13 @@ const CourseModal: React.FC<{
|
||||
Adicionar aula
|
||||
</h4>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonTitle}
|
||||
onChange={(e) => setNewLessonTitle(e.target.value)}
|
||||
placeholder="Ex.: Aula 01 — Configuração inicial do Pixel"
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonDuration}
|
||||
onChange={(e) => setNewLessonDuration(e.target.value)}
|
||||
placeholder="Ex.: 25 min"
|
||||
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"
|
||||
placeholder="Título da aula"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -731,6 +944,7 @@ const CourseModal: React.FC<{
|
||||
};
|
||||
|
||||
export const ManageCourses: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
||||
@@ -763,6 +977,17 @@ export const ManageCourses: React.FC = () => {
|
||||
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 = () => {
|
||||
setEditingCourse(undefined);
|
||||
setIsModalOpen(true);
|
||||
@@ -784,7 +1009,7 @@ export const ManageCourses: React.FC = () => {
|
||||
const handleDuplicate = async (id: string) => {
|
||||
try {
|
||||
await duplicateCourse(id);
|
||||
showToast('Cópia criada como rascunho.', 'success');
|
||||
showToast('Cópia criada como não publicada.', 'success');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Não foi possível duplicar este curso.', 'error');
|
||||
@@ -925,15 +1150,19 @@ export const ManageCourses: React.FC = () => {
|
||||
<div>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]">
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
<CourseCoverImage
|
||||
source={course.thumbnail}
|
||||
alt={course.title}
|
||||
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">
|
||||
{course.category}
|
||||
</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>
|
||||
|
||||
{/* Body */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { BookOpenCheck, Loader2, PlayCircle } from 'lucide-react';
|
||||
import { Course } from '../types';
|
||||
import { getMyLearningCourses } from '../services/db';
|
||||
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||
|
||||
export const MyLearning: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
|
||||
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">
|
||||
{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">
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
|
||||
135
pages/ProfilePage.tsx
Normal file
135
pages/ProfilePage.tsx
Normal 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
112
pages/TrailDetail.tsx
Normal file
File diff suppressed because one or more lines are too long
34
pages/TrailPage.tsx
Normal file
34
pages/TrailPage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, BookOpen, Loader2 } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Course } from '../types';
|
||||
import { getCourses, getLearningPaths, PublicLearningPath } from '../services/db';
|
||||
import { CourseCoverImage } from '../components/CourseCoverImage';
|
||||
import { CourseCard } from '../components/CourseCard';
|
||||
|
||||
export const TrailPage: React.FC<{ onPlay: (course: Course) => void }> = ({ onPlay }) => {
|
||||
const { trailId } = useParams<{ trailId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [trail, setTrail] = useState<PublicLearningPath | null>(null);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trailId) return;
|
||||
Promise.all([getLearningPaths(), getCourses()])
|
||||
.then(([paths, availableCourses]) => {
|
||||
const found = paths.find((item) => item.id === trailId) || null;
|
||||
setTrail(found);
|
||||
if (found) {
|
||||
const byId = new Map(availableCourses.map((course) => [course.id, course]));
|
||||
setCourses(found.courses.map((course) => byId.get(course.id)).filter((course): course is Course => Boolean(course)));
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [trailId]);
|
||||
|
||||
if (isLoading) return <main className="flex min-h-[65vh] items-center justify-center pt-24"><Loader2 className="h-8 w-8 animate-spin text-orange-400" /></main>;
|
||||
if (!trail) return <main className="mx-auto flex min-h-[65vh] max-w-6xl items-center px-6 pt-24"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha indisponível</p><h1 className="mt-3 text-3xl font-bold text-white">Esta trilha não está publicada.</h1></div></main>;
|
||||
|
||||
return <main className="min-h-screen pb-20 pt-20"><section className="relative overflow-hidden border-b border-white/10 bg-zinc-950"><div className="absolute inset-0 opacity-30">{trail.coverImageUrl && <CourseCoverImage source={trail.coverImageUrl} alt="" className="h-full w-full object-cover" style={{ transform: `scale(${trail.coverImageZoom})`, objectPosition: `${trail.coverImagePositionX}% ${trail.coverImagePositionY}%` }} />}</div><div className="absolute inset-0 bg-gradient-to-r from-black via-black/90 to-black/45" /><div className="relative mx-auto max-w-6xl px-6 py-14 sm:px-8 sm:py-20"><button onClick={() => navigate('/')} className="mb-8 inline-flex items-center gap-2 text-sm font-semibold text-gray-300 transition hover:text-orange-300"><ArrowLeft className="h-4 w-4" /> Voltar para cursos</button><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Trilha de cursos</p><h1 className="mt-3 max-w-3xl text-4xl font-bold tracking-tight text-white sm:text-5xl">{trail.title}</h1>{trail.description && <p className="mt-5 max-w-2xl text-base leading-7 text-gray-300">{trail.description}</p>}<div className="mt-7 inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/30 px-4 py-2 text-sm font-semibold text-white"><BookOpen className="h-4 w-4 text-orange-400" />{courses.length} {courses.length === 1 ? 'curso na trilha' : 'cursos na trilha'}</div></div></section><section className="mx-auto max-w-6xl px-6 py-12 sm:px-8"><div className="mb-7"><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Sua sequência</p><h2 className="mt-2 text-2xl font-bold text-white">Cursos da trilha</h2></div>{courses.length ? <div className="grid gap-7 sm:grid-cols-2 lg:grid-cols-3">{courses.map((course, index) => <div key={course.id}><p className="mb-3 text-xs font-bold text-orange-300">{String(index + 1).padStart(2, '0')} · Curso</p><CourseCard course={course} variant="landscape" onClick={onPlay} /></div>)}</div> : <div className="rounded-2xl border border-dashed border-white/15 p-10 text-center text-sm text-gray-500">Nenhum curso publicado nesta trilha.</div>}</section></main>;
|
||||
};
|
||||
3
server/migrations/007_course_cover_zoom.sql
Normal file
3
server/migrations/007_course_cover_zoom.sql
Normal 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);
|
||||
5
server/migrations/008_course_cover_position.sql
Normal file
5
server/migrations/008_course_cover_position.sql
Normal 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);
|
||||
32
server/migrations/009_learning_paths.sql
Normal file
32
server/migrations/009_learning_paths.sql
Normal 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();
|
||||
8
server/migrations/010_learning_path_kind.sql
Normal file
8
server/migrations/010_learning_path_kind.sql
Normal 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);
|
||||
21
server/migrations/011_home_banners.sql
Normal file
21
server/migrations/011_home_banners.sql
Normal 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();
|
||||
5
server/migrations/012_consolidate_learning_paths.sql
Normal file
5
server/migrations/012_consolidate_learning_paths.sql
Normal 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;
|
||||
13
server/migrations/013_instructor_profiles.sql
Normal file
13
server/migrations/013_instructor_profiles.sql
Normal 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');
|
||||
16
server/migrations/014_bunny_video_ownership.sql
Normal file
16
server/migrations/014_bunny_video_ownership.sql
Normal 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;
|
||||
20
server/migrations/015_seed_trails_from_legacy_categories.sql
Normal file
20
server/migrations/015_seed_trails_from_legacy_categories.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Course categories were previously presented as trilhas but only stored a
|
||||
-- label. Turn every existing label into a real ordered trilha without moving
|
||||
-- or deleting any course.
|
||||
insert into learning_paths (title, description, status, published_at)
|
||||
select category.name, '', 'draft', null
|
||||
from course_categories category
|
||||
where not exists (
|
||||
select 1 from learning_paths path where lower(path.title) = lower(category.name)
|
||||
);
|
||||
|
||||
insert into learning_path_courses (learning_path_id, course_id, position)
|
||||
select path.id, course.id, ranked.position
|
||||
from (
|
||||
select id, category, row_number() over (partition by category order by published_at nulls last, title, id)::integer as position
|
||||
from courses
|
||||
where status <> 'archived'
|
||||
) ranked
|
||||
join courses course on course.id = ranked.id
|
||||
join learning_paths path on lower(path.title) = lower(ranked.category)
|
||||
on conflict (learning_path_id, course_id) do nothing;
|
||||
@@ -11,6 +11,7 @@ import { learningRoutes } from './routes/learning.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { mediaRoutes } from './routes/media.js';
|
||||
import { bunnyWebhookRoutes } from './routes/bunny-webhooks.js';
|
||||
import { profileRoutes } from './routes/profiles.js';
|
||||
|
||||
export function buildApp() {
|
||||
const app = Fastify({ logger: true });
|
||||
@@ -34,6 +35,18 @@ export function buildApp() {
|
||||
app.addContentTypeParser('application/octet-stream', 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, {
|
||||
origin: config.FRONTEND_ORIGIN,
|
||||
@@ -42,11 +55,14 @@ export function buildApp() {
|
||||
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
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);
|
||||
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 } }) => {
|
||||
@@ -62,6 +78,7 @@ export function buildApp() {
|
||||
|
||||
app.register(authPlugin);
|
||||
app.register(authRoutes, { prefix: '/api/v1/auth' });
|
||||
app.register(profileRoutes, { prefix: '/api/v1/profiles' });
|
||||
app.register(courseRoutes, { prefix: '/api/v1/courses' });
|
||||
app.register(manageCourseRoutes, { prefix: '/api/v1/manage/courses' });
|
||||
app.register(mediaRoutes, { prefix: '/api/v1/manage/media' });
|
||||
|
||||
@@ -34,7 +34,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
reply.code(401).send({ error: 'Authentication required' });
|
||||
reply.code(401).send({ error: 'Autenticação necessária.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
const account = result.rows[0];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const registerAuth: FastifyPluginAsync = async (app) => {
|
||||
if (!(await verifyActiveUser(request, reply))) return;
|
||||
|
||||
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.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const environmentSchema = z.object({
|
||||
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
|
||||
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'),
|
||||
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),
|
||||
@@ -19,6 +19,11 @@ const environmentSchema = z.object({
|
||||
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),
|
||||
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_API_KEY: optionalEnvironmentValue(z.string().min(20)),
|
||||
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
|
||||
@@ -30,12 +35,13 @@ const environmentSchema = z.object({
|
||||
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);
|
||||
|
||||
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 = [
|
||||
@@ -45,7 +51,7 @@ const bunnyConfigurationValues = [
|
||||
];
|
||||
|
||||
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 = [
|
||||
@@ -56,5 +62,10 @@ const bunnyStorageConfigurationValues = [
|
||||
];
|
||||
|
||||
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
|
||||
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.');
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ 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('Bunny Storage is not configured. Ask an administrator to add the Storage Zone variables.');
|
||||
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,
|
||||
@@ -30,7 +31,7 @@ function imageExtension(image: CoverImage) {
|
||||
if (contentType === 'image/png' && isPng) return 'png';
|
||||
if (contentType === 'image/jpeg' && isJpeg) return 'jpg';
|
||||
if (contentType === 'image/webp' && isWebp) return 'webp';
|
||||
throw new BunnyStorageRequestError('Only valid JPG, PNG, and WebP images are accepted.');
|
||||
throw new BunnyStorageRequestError('Envie apenas imagens JPG, PNG ou WebP válidas.');
|
||||
}
|
||||
|
||||
export async function uploadBunnyCover(image: CoverImage) {
|
||||
@@ -52,3 +53,60 @@ export async function uploadBunnyCover(image: CoverImage) {
|
||||
}
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class BunnyRequestError extends Error {}
|
||||
|
||||
function getBunnyConfiguration() {
|
||||
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 {
|
||||
@@ -66,7 +66,7 @@ function toVideo(video: BunnyApiVideo): BunnyVideo {
|
||||
status: bunnyMediaStatus(video.status),
|
||||
providerStatus: video.status,
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
const { libraryId, embedTokenKey } = getBunnyConfiguration();
|
||||
const expires = Math.floor(Date.now() / 1000) + validitySeconds;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { createRawToken, hashToken } from '../auth/account-tokens.js';
|
||||
@@ -10,12 +11,14 @@ const userParamsSchema = 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(),
|
||||
isActive: z.boolean().optional(),
|
||||
}).refine((input) => input.role !== undefined || input.isActive !== undefined, {
|
||||
message: 'Provide at least one field to update',
|
||||
}).refine((input) => input.name !== undefined || input.email !== undefined || input.role !== undefined || input.isActive !== undefined, {
|
||||
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 categoryParamsSchema = z.object({ categoryId: z.string().uuid() });
|
||||
const homeConfigurationSchema = z.object({
|
||||
@@ -23,6 +26,95 @@ const homeConfigurationSchema = z.object({
|
||||
courseOrder: z.array(z.string().uuid()).max(500),
|
||||
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) => {
|
||||
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
||||
@@ -57,6 +149,114 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
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) => {
|
||||
const input = categorySchema.parse(request.body);
|
||||
const result = await pool.query(
|
||||
@@ -79,7 +279,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
returning id, name, position, is_active as "isActive"`,
|
||||
[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 });
|
||||
return { data: result.rows[0] };
|
||||
});
|
||||
@@ -87,7 +287,11 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/home-configuration', adminAccess, async () => {
|
||||
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<{ 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]));
|
||||
return { data: {
|
||||
@@ -103,7 +307,7 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
const ids = [...new Set([...(input.featuredCourseId ? [input.featuredCourseId] : []), ...input.courseOrder])];
|
||||
if (ids.length) {
|
||||
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();
|
||||
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"
|
||||
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] };
|
||||
});
|
||||
|
||||
app.post('/invitations', adminAccess, async (request, reply) => {
|
||||
const input = invitationSchema.parse(request.body);
|
||||
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();
|
||||
await pool.query(
|
||||
`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) => {
|
||||
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]);
|
||||
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();
|
||||
await pool.query(
|
||||
`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);
|
||||
|
||||
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(
|
||||
`update users
|
||||
set role = coalesce($2::user_role, role),
|
||||
is_active = coalesce($3, is_active)
|
||||
set display_name = coalesce($2, display_name),
|
||||
email = coalesce($3, email),
|
||||
role = coalesce($4::user_role, role),
|
||||
is_active = coalesce($5, is_active)
|
||||
where id = $1
|
||||
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];
|
||||
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 });
|
||||
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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,16 +5,17 @@ import { hashToken } from '../auth/account-tokens.js';
|
||||
import type { AuthUser } from '../auth/plugin.js';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { config } from '../config.js';
|
||||
import { sendWelcomeEmail } from '../services/email.js';
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
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({
|
||||
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 = {
|
||||
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 } }) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -73,7 +74,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
if (!token.rows[0]) {
|
||||
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>(
|
||||
`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 });
|
||||
} catch {
|
||||
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 {
|
||||
client.release();
|
||||
}
|
||||
@@ -104,7 +105,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
if (!token.rows[0]) {
|
||||
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('commit');
|
||||
@@ -130,10 +131,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
const user = serializeUser(result.rows[0]);
|
||||
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 });
|
||||
} catch (error: unknown) {
|
||||
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;
|
||||
}
|
||||
@@ -149,7 +156,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
const account = result.rows[0];
|
||||
|
||||
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);
|
||||
|
||||
@@ -14,16 +14,16 @@ const webhookSchema = z.object({
|
||||
|
||||
export const bunnyWebhookRoutes: FastifyPluginAsync = async (app) => {
|
||||
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 signatureHeader = request.headers.signature || request.headers['x-bunny-signature'];
|
||||
const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader;
|
||||
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);
|
||||
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);
|
||||
await pool.query(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { signedBunnyEmbedUrl, BunnyConfigurationError } from '../providers/bunny.js';
|
||||
import { BunnyStorageConfigurationError, BunnyStorageRequestError, bunnyAssetKeyFromUrl, downloadBunnyAsset, downloadBunnyCover } from '../providers/bunny-storage.js';
|
||||
|
||||
const paramsSchema = z.object({
|
||||
courseId: z.string().uuid(),
|
||||
@@ -10,6 +11,10 @@ const playbackParamsSchema = z.object({
|
||||
courseId: 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) => `
|
||||
select
|
||||
@@ -21,6 +26,9 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
|
||||
coalesce(c.cover_image_url, (
|
||||
select value #>> '{}' from platform_settings where key = 'media.defaultCoverImageUrl'
|
||||
)) 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.published_at as "publishedAt",
|
||||
jsonb_build_object(
|
||||
@@ -42,6 +50,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
|
||||
'name', a.name,
|
||||
'kind', a.kind,
|
||||
'url', a.url,
|
||||
'sizeBytes', a.size_bytes,
|
||||
'createdAt', a.created_at,
|
||||
'description', a.description,
|
||||
'accessLevel', a.access_level
|
||||
) order by a.created_at)
|
||||
@@ -74,6 +84,8 @@ export const courseSelect = (publicOnly = false, includeUnreadyMedia = false) =>
|
||||
'name', a.name,
|
||||
'kind', a.kind,
|
||||
'url', a.url,
|
||||
'sizeBytes', a.size_bytes,
|
||||
'createdAt', a.created_at,
|
||||
'description', a.description,
|
||||
'accessLevel', a.access_level
|
||||
) order by a.created_at)
|
||||
@@ -107,6 +119,51 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
||||
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) => {
|
||||
const authenticated = await hasSession(request);
|
||||
const result = await pool.query(
|
||||
@@ -126,6 +183,47 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
||||
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) => {
|
||||
const result = await pool.query(
|
||||
`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];
|
||||
|
||||
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) };
|
||||
@@ -181,22 +279,22 @@ export const courseRoutes: FastifyPluginAsync = async (app) => {
|
||||
[courseId, lessonId],
|
||||
);
|
||||
const media = result.rows[0];
|
||||
if (!media) return reply.code(404).send({ error: 'Lesson media was not found' });
|
||||
if (media.status === 'processing') return reply.code(409).send({ error: 'This video is still being processed by Bunny Stream.' });
|
||||
if (media.status === 'failed') return reply.code(409).send({ error: 'This video could not be processed. Please contact the instructor.' });
|
||||
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: 'Este vídeo ainda está sendo processado pelo Bunny Stream.' });
|
||||
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') {
|
||||
try {
|
||||
const playback = signedBunnyEmbedUrl(media.external_id);
|
||||
return { data: { provider: 'bunny', kind: 'embed', ...playback } };
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 } };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ const lessonParamsSchema = z.object({
|
||||
const completionSchema = z.object({
|
||||
completed: z.boolean().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({
|
||||
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) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
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(
|
||||
@@ -78,7 +78,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -99,7 +99,7 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/courses/:courseId/comments', { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
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(
|
||||
@@ -120,13 +120,13 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { courseId } = courseParamsSchema.parse(request.params);
|
||||
const input = commentSchema.parse(request.body);
|
||||
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) {
|
||||
const lesson = await pool.query('select id from lessons where id = $1 and course_id = $2', [input.lessonId, courseId]);
|
||||
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) => {
|
||||
const { commentId } = commentParamsSchema.parse(request.params);
|
||||
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(
|
||||
`insert into comment_replies (comment_id, author_id, body)
|
||||
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) => {
|
||||
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 recordAudit({ actorId: request.user.id, action: 'comment.deleted', subjectType: 'comment', subjectId: commentId, ipAddress: request.ip });
|
||||
return reply.code(204).send();
|
||||
|
||||
@@ -20,6 +20,7 @@ const assetSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
kind: z.enum(['document', 'spreadsheet', 'archive', 'image', 'link']),
|
||||
url: z.string().url(),
|
||||
sizeBytes: z.number().int().nonnegative().nullable().optional(),
|
||||
description: z.string().max(2000).default(''),
|
||||
accessLevel: z.enum(['public', 'enrolled']).default('enrolled'),
|
||||
});
|
||||
@@ -37,7 +38,7 @@ const lessonSchema = z.object({
|
||||
const coverImageUrlSchema = z.string().url().refine((value) => {
|
||||
const protocol = new URL(value).protocol;
|
||||
return protocol === 'https:' || (config.APP_ENV !== 'production' && protocol === 'http:');
|
||||
}, { message: 'Cover image URL must use HTTPS.' });
|
||||
}, { message: 'A URL da imagem de capa deve usar HTTPS.' });
|
||||
|
||||
const courseSchema = z.object({
|
||||
title: z.string().trim().min(1).max(255),
|
||||
@@ -45,6 +46,9 @@ const courseSchema = z.object({
|
||||
description: z.string().max(10000).default(''),
|
||||
category: z.string().trim().min(1).max(120),
|
||||
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'),
|
||||
lessons: z.array(lessonSchema).min(1),
|
||||
assets: z.array(assetSchema).default([]),
|
||||
@@ -82,16 +86,16 @@ async function uniqueSlug(client: PoolClient, requestedSlug: string | undefined,
|
||||
async function insertAssets(client: PoolClient, parent: { courseId?: string; lessonId?: string }, assets: CourseInput['assets']) {
|
||||
for (const asset of assets) {
|
||||
await client.query(
|
||||
`insert into assets (course_id, lesson_id, name, kind, url, description, access_level)
|
||||
values ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.description, asset.accessLevel],
|
||||
`insert into assets (course_id, lesson_id, name, kind, url, size_bytes, description, access_level)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[parent.courseId ?? null, parent.lessonId ?? null, asset.name, asset.kind, asset.url, asset.sizeBytes ?? null, asset.description, asset.accessLevel],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertActiveCategory(category: string) {
|
||||
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) {
|
||||
@@ -100,7 +104,7 @@ function assertPublishable(input: CourseInput) {
|
||||
lesson.media.length === 0 || lesson.media.some((media) => media.status !== 'ready'),
|
||||
);
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +113,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
|
||||
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
|
||||
const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
|
||||
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));
|
||||
if (removedLessonIds.length > 0) {
|
||||
@@ -122,7 +126,7 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
|
||||
[removedLessonIds],
|
||||
);
|
||||
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]);
|
||||
}
|
||||
@@ -167,9 +171,9 @@ async function replaceCourseContents(client: PoolClient, courseId: string, input
|
||||
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 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) {
|
||||
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;
|
||||
}
|
||||
@@ -213,10 +217,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
await client.query('begin');
|
||||
const slug = await uniqueSlug(client, input.slug, input.title);
|
||||
const course = await client.query<{ id: string }>(
|
||||
`insert into courses (slug, title, description, category, cover_image_url, 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)
|
||||
`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, $7, $8, $9::course_status, $10, case when $9::course_status = 'published'::course_status then now() else null end)
|
||||
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 client.query('commit');
|
||||
@@ -245,10 +249,10 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
const slug = await uniqueSlug(client, input.slug, input.title, courseId);
|
||||
await client.query(
|
||||
`update courses
|
||||
set slug = $2, title = $3, description = $4, category = $5, cover_image_url = $6,
|
||||
status = $7::course_status, published_at = case when $7::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
|
||||
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 = $10::course_status, published_at = case when $10::course_status = 'published'::course_status then coalesce(published_at, now()) else null end
|
||||
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 client.query('commit');
|
||||
@@ -267,7 +271,18 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
const { courseId } = paramsSchema.parse(request.params);
|
||||
const accessError = await assertCanManageCourse(courseId, request.user);
|
||||
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 });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
@@ -280,20 +295,20 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const original = await client.query<{ title: string; description: string; category: string; cover_image_url: string | null; instructor_id: string }>(
|
||||
`select title, description, category, cover_image_url, instructor_id from courses where id = $1`, [courseId],
|
||||
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, cover_image_zoom, cover_image_position_x, cover_image_position_y, instructor_id from courses where id = $1`, [courseId],
|
||||
);
|
||||
if (!original.rows[0]) {
|
||||
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 title = `${source.title} (cópia)`;
|
||||
const slug = await uniqueSlug(client, undefined, title);
|
||||
const copiedCourse = await client.query<{ id: string }>(
|
||||
`insert into courses (slug, title, description, category, cover_image_url, status, instructor_id)
|
||||
values ($1, $2, $3, $4, $5, 'draft', $6) returning id`,
|
||||
[slug, title, source.description, source.category, source.cover_image_url, source.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, $6, $7, $8, 'draft', $9) returning 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;
|
||||
await client.query(
|
||||
|
||||
@@ -6,28 +6,34 @@ import {
|
||||
BunnyConfigurationError,
|
||||
BunnyRequestError,
|
||||
createBunnyVideo,
|
||||
deleteBunnyVideo,
|
||||
getBunnyVideo,
|
||||
isBunnyConfigured,
|
||||
uploadBunnyVideo,
|
||||
} from '../providers/bunny.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 videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
||||
|
||||
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 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;
|
||||
@@ -40,10 +46,36 @@ async function persistBunnyStatus(video: { id: string; status: string; durationS
|
||||
where provider = 'bunny' and external_id = $1`,
|
||||
[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) => {
|
||||
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
|
||||
const coverAccess = { preHandler: app.authenticate };
|
||||
|
||||
app.get('/bunny/config', manageAccess, async () => ({
|
||||
data: {
|
||||
@@ -52,18 +84,25 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
}));
|
||||
|
||||
app.get('/covers/config', manageAccess, async () => ({
|
||||
app.get('/covers/config', coverAccess, async () => ({
|
||||
data: {
|
||||
configured: isBunnyStorageConfigured(),
|
||||
maxUploadBytes: config.BUNNY_COVER_MAX_UPLOAD_MB * 1024 * 1024,
|
||||
},
|
||||
}));
|
||||
|
||||
app.post('/covers', manageAccess, async (request, reply) => {
|
||||
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: 'Send an image file as the request body.' });
|
||||
if (body.length === 0) return reply.code(400).send({ error: 'Choose an image to upload.' });
|
||||
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.` });
|
||||
}
|
||||
@@ -76,10 +115,25 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/assets', manageAccess, async (request, reply) => {
|
||||
const contentType = request.headers['content-type']?.split(';')[0]?.toLowerCase();
|
||||
const body = request.body;
|
||||
if (!contentType || !Buffer.isBuffer(body)) return reply.code(400).send({ error: 'Envie um arquivo de material.' });
|
||||
try {
|
||||
const asset = validateAssetUpload(body, contentType, config.BUNNY_ASSET_MAX_UPLOAD_MB * 1024 * 1024);
|
||||
const stored = await uploadBunnyAsset({ body, contentType: asset.contentType, extension: asset.extension });
|
||||
await recordAudit({ actorId: request.user.id, action: 'media.asset.uploaded', subjectType: 'asset', metadata: { key: stored.key, kind: asset.kind, sizeBytes: body.length }, ipAddress: request.ip });
|
||||
return reply.code(201).send({ data: { ...stored, kind: asset.kind, sizeBytes: body.length } });
|
||||
} catch (error) {
|
||||
return storageError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/bunny/videos', manageAccess, async (request, reply) => {
|
||||
const input = createVideoSchema.parse(request.body);
|
||||
try {
|
||||
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 });
|
||||
return reply.code(201).send({ data: video });
|
||||
} catch (error) {
|
||||
@@ -89,6 +143,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
|
||||
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 maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
|
||||
if (contentLength > maxBytes) {
|
||||
@@ -96,7 +155,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
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 recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip });
|
||||
return { data: video };
|
||||
@@ -108,6 +168,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
||||
const { videoId } = videoParamsSchema.parse(request.params);
|
||||
try {
|
||||
await assertVideoAccess(videoId, request.user);
|
||||
const video = await getBunnyVideo(videoId);
|
||||
await persistBunnyStatus(video);
|
||||
return { data: video };
|
||||
@@ -115,4 +176,17 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
131
server/src/routes/profiles.ts
Normal file
131
server/src/routes/profiles.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
23
server/src/services/email.ts
Normal file
23
server/src/services/email.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
}
|
||||
52
server/src/uploads/asset.ts
Normal file
52
server/src/uploads/asset.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type AssetKind = 'document' | 'spreadsheet' | 'archive';
|
||||
|
||||
type AssetDefinition = {
|
||||
kind: AssetKind;
|
||||
extension: string;
|
||||
needsZipSignature?: boolean;
|
||||
needsOleSignature?: boolean;
|
||||
needsPdfSignature?: boolean;
|
||||
needsTextContent?: boolean;
|
||||
};
|
||||
|
||||
const allowedAssetTypes = new Map<string, AssetDefinition>([
|
||||
['application/pdf', { kind: 'document', extension: 'pdf', needsPdfSignature: true }],
|
||||
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', { kind: 'document', extension: 'docx', needsZipSignature: true }],
|
||||
['application/msword', { kind: 'document', extension: 'doc', needsOleSignature: true }],
|
||||
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', { kind: 'spreadsheet', extension: 'xlsx', needsZipSignature: true }],
|
||||
['application/vnd.ms-excel', { kind: 'spreadsheet', extension: 'xls', needsOleSignature: true }],
|
||||
['text/csv', { kind: 'spreadsheet', extension: 'csv', needsTextContent: true }],
|
||||
['application/zip', { kind: 'archive', extension: 'zip', needsZipSignature: true }],
|
||||
]);
|
||||
|
||||
export class AssetUploadValidationError extends Error {
|
||||
constructor(message: string, public readonly statusCode: 400 | 413 | 415) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const zipSignature = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
const oleSignature = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||
|
||||
export function validateAssetUpload(body: Buffer, contentType: string | undefined, maxBytes: number) {
|
||||
const normalizedType = contentType?.split(';')[0]?.trim().toLowerCase();
|
||||
const definition = normalizedType ? allowedAssetTypes.get(normalizedType) : undefined;
|
||||
if (!definition) {
|
||||
throw new AssetUploadValidationError('Envie apenas arquivos PDF, DOCX, XLSX, CSV ou ZIP.', 415);
|
||||
}
|
||||
if (body.length === 0) throw new AssetUploadValidationError('Escolha um arquivo para enviar.', 400);
|
||||
if (body.length > maxBytes) throw new AssetUploadValidationError(`O arquivo excede o limite de ${Math.floor(maxBytes / 1024 / 1024)} MB.`, 413);
|
||||
if (definition.needsPdfSignature && !body.subarray(0, 5).equals(Buffer.from('%PDF-'))) {
|
||||
throw new AssetUploadValidationError('O arquivo enviado não é um PDF válido.', 415);
|
||||
}
|
||||
if (definition.needsZipSignature && !body.subarray(0, 4).equals(zipSignature)) {
|
||||
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
|
||||
}
|
||||
if (definition.needsOleSignature && !body.subarray(0, 8).equals(oleSignature)) {
|
||||
throw new AssetUploadValidationError('O arquivo enviado não possui o formato esperado.', 415);
|
||||
}
|
||||
if (definition.needsTextContent && body.subarray(0, Math.min(body.length, 4096)).includes(0)) {
|
||||
throw new AssetUploadValidationError('O arquivo CSV deve conter texto.', 415);
|
||||
}
|
||||
return { kind: definition.kind, extension: definition.extension, contentType: normalizedType as string };
|
||||
}
|
||||
62
server/src/uploads/video.ts
Normal file
62
server/src/uploads/video.ts
Normal 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));
|
||||
}
|
||||
120
services/api.ts
120
services/api.ts
@@ -10,6 +10,23 @@ export interface ApiUser {
|
||||
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 {
|
||||
id: 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 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 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 {
|
||||
token: string;
|
||||
@@ -58,6 +126,26 @@ export function clearSession() {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
export async function downloadCourseAsset(assetId: string, filename: string) {
|
||||
const session = getSession();
|
||||
const headers = new Headers();
|
||||
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
||||
const response = await fetch(`${API_URL}/courses/assets/${assetId}/download`, { headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new ApiError(body.error || 'Não foi possível baixar este material.', response.status);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const session = getSession();
|
||||
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(() => ({}));
|
||||
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;
|
||||
}
|
||||
@@ -80,7 +168,7 @@ async function uploadRequest<T>(path: string, file: File): Promise<T> {
|
||||
if (session) headers.set('Authorization', `Bearer ${session.token}`);
|
||||
const response = await fetch(`${API_URL}${path}`, { method: 'PUT', headers, body: file });
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -90,7 +178,7 @@ async function uploadImageRequest<T>(path: string, file: File): Promise<T> {
|
||||
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 || 'The cover image could not be uploaded', response.status);
|
||||
if (!response.ok) throw new ApiError(body.error || 'Não foi possível enviar a imagem de capa.', response.status);
|
||||
return body as T;
|
||||
}
|
||||
|
||||
@@ -117,27 +205,42 @@ export const adminApi = {
|
||||
async listUsers() {
|
||||
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}`, {
|
||||
method: 'PATCH',
|
||||
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 }) });
|
||||
},
|
||||
async passwordReset(userId: string) {
|
||||
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 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 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 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) }); },
|
||||
};
|
||||
|
||||
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 = {
|
||||
async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); },
|
||||
async bunnyConfiguration() { return apiRequest<{ data: { configured: boolean; maxUploadBytes: number } }>('/manage/media/bunny/config'); },
|
||||
@@ -150,8 +253,13 @@ export const instructorApi = {
|
||||
async bunnyVideoStatus(videoId: string) {
|
||||
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 = {
|
||||
|
||||
129
services/db.ts
129
services/db.ts
@@ -8,19 +8,24 @@ type ApiAsset = {
|
||||
name: string;
|
||||
kind: 'document' | 'spreadsheet' | 'archive' | 'image' | 'link';
|
||||
url: string;
|
||||
sizeBytes: number | null;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type ApiCourse = {
|
||||
export type ApiCourse = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
coverImageUrl: string | null;
|
||||
coverImageZoom: number;
|
||||
coverImagePositionX: number;
|
||||
coverImagePositionY: number;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
progress?: number;
|
||||
lastActivity?: string | null;
|
||||
instructor: { name: string };
|
||||
instructor: { id: string; name: string };
|
||||
lessons: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -41,6 +46,39 @@ type ApiCourse = {
|
||||
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 => {
|
||||
if (kind === 'document') return 'doc';
|
||||
if (kind === 'archive') return 'zip';
|
||||
@@ -52,26 +90,51 @@ const toAttachment = (asset: ApiAsset): Attachment => ({
|
||||
name: asset.name,
|
||||
type: toAttachmentType(asset.kind),
|
||||
url: asset.url,
|
||||
size: asset.sizeBytes === null ? undefined : formatBytes(asset.sizeBytes),
|
||||
sizeBytes: asset.sizeBytes ?? undefined,
|
||||
createdAt: asset.createdAt,
|
||||
description: asset.description,
|
||||
});
|
||||
|
||||
const toDuration = (seconds: number | null) => {
|
||||
if (!seconds) return '—';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
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,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
category: course.category,
|
||||
thumbnail: course.coverImageUrl || '/course-placeholder.svg',
|
||||
coverImageZoom: course.coverImageZoom || 1,
|
||||
coverImagePositionX: course.coverImagePositionX ?? 50,
|
||||
coverImagePositionY: course.coverImagePositionY ?? 50,
|
||||
instructor: course.instructor.name,
|
||||
instructorId: course.instructor.id,
|
||||
status: course.status === 'draft' ? 'draft' : 'published',
|
||||
progress: course.progress,
|
||||
lastActivity: course.lastActivity || undefined,
|
||||
duration: `${course.lessons.length} Aulas`,
|
||||
duration: toCourseDuration(course.lessons),
|
||||
lessons: course.lessons.map((lesson): Lesson => {
|
||||
const media: LessonMedia[] = lesson.media.map((item) => ({
|
||||
id: item.id,
|
||||
@@ -86,7 +149,7 @@ const toCourse = (course: ApiCourse): Course => ({
|
||||
id: lesson.id,
|
||||
title: lesson.title,
|
||||
description: lesson.description,
|
||||
duration: toDuration(lesson.durationSeconds),
|
||||
duration: toDuration(media[0]?.durationSeconds ?? lesson.durationSeconds),
|
||||
isFree: lesson.accessLevel === 'public',
|
||||
videoUrl: media[0]?.playbackUrl || media[0]?.embedUrl || '',
|
||||
mediaProvider: media[0]?.provider,
|
||||
@@ -115,22 +178,34 @@ const toAssetPayload = (attachment: Attachment) => ({
|
||||
name: attachment.name,
|
||||
kind: toAssetKind(attachment.type),
|
||||
url: attachment.url,
|
||||
sizeBytes: attachment.sizeBytes ?? parseSizeBytes(attachment.size),
|
||||
description: attachment.description || '',
|
||||
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) => ({
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
category: course.category,
|
||||
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',
|
||||
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
||||
lessons: course.lessons.map((lesson) => ({
|
||||
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
|
||||
title: lesson.title,
|
||||
description: lesson.description || '',
|
||||
durationSeconds: toSeconds(lesson.duration),
|
||||
durationSeconds: lesson.media?.[0]?.durationSeconds ?? toSeconds(lesson.duration),
|
||||
accessLevel: lesson.isFree ? 'public' as const : 'enrolled' as const,
|
||||
media: lesson.media?.length
|
||||
? lesson.media.map((media) => ({
|
||||
@@ -153,6 +228,40 @@ export const getCourses = async (): Promise<Course[]> => {
|
||||
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[]> => {
|
||||
const response = await apiRequest<{ data: ApiCourse[] }>('/manage/courses');
|
||||
return response.data.map(toCourse);
|
||||
|
||||
6
types.ts
6
types.ts
@@ -8,6 +8,8 @@ export interface Attachment {
|
||||
type: AttachmentType;
|
||||
url: string;
|
||||
size?: string; // e.g. "4.2 MB"
|
||||
sizeBytes?: number;
|
||||
createdAt?: string;
|
||||
description?: string;
|
||||
lessonId?: string; // Associated lesson or global course asset
|
||||
downloadCount?: number;
|
||||
@@ -42,12 +44,16 @@ export interface Course {
|
||||
title: string;
|
||||
category: string;
|
||||
thumbnail: string;
|
||||
coverImageZoom?: number;
|
||||
coverImagePositionX?: number;
|
||||
coverImagePositionY?: number;
|
||||
lessons: Lesson[];
|
||||
progress?: number; // 0 to 100
|
||||
views?: number;
|
||||
duration: string; // Total duration or lesson count
|
||||
description: string;
|
||||
instructor?: string;
|
||||
instructorId?: string;
|
||||
instructorRole?: string;
|
||||
tips?: string[]; // Key takeaways / instructor tips
|
||||
attachments?: Attachment[]; // Downloadable materials across entire course
|
||||
|
||||
Reference in New Issue
Block a user