feat: implement core course platform UI and logic

- Add project configuration and git ignore rules
- Implement App router with protected routes
- Develop course player, grid, and card components
- Add state management for course progress and navigation
- Initialize database services for course and comment data
This commit is contained in:
MMrp89
2026-08-27 12:34:12 -03:00
parent a1de78971d
commit 684f929f28
34 changed files with 5450 additions and 11 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

123
App.tsx Normal file
View File

@@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Navbar } from './components/Navbar';
import { Hero } from './components/Hero';
import { CourseGrid } from './components/CourseGrid';
import { ManageCourses } from './pages/ManageCourses';
import { MaterialsPage } from './pages/MaterialsPage';
import { CoursePlayerModal } from './components/CoursePlayerModal';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ToastProvider } from './context/ToastContext';
import { Course } from './types';
// Protected Route Component
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRole?: 'professor' | 'student' }> = ({ children, allowedRole }) => {
const { user, isLoading } = useAuth();
if (isLoading) return null;
if (!user) {
return <Navigate to="/" replace />;
}
if (allowedRole && user.role !== allowedRole) {
return <Navigate to={user.role === 'professor' ? '/gerenciar' : '/'} replace />;
}
return <>{children}</>;
};
// Home component
interface HomeProps {
onPlay: (course: Course) => void;
}
const Home: React.FC<HomeProps> = ({ onPlay }) => {
return (
<main>
<Hero onPlay={onPlay} />
<CourseGrid onCourseSelect={onPlay} />
</main>
);
};
function AppContent() {
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
return (
<div className="min-h-screen bg-black text-white font-sans selection:bg-orange-500/30 selection:text-white">
{/* Navbar with Direct Materials Hub Trigger */}
<Navbar
onPlay={setSelectedCourse}
/>
<Routes>
<Route
path="/"
element={
<Home
onPlay={setSelectedCourse}
/>
}
/>
{/* Full Dedicated Materials & Downloads Page */}
<Route
path="/materiais"
element={
<MaterialsPage
onPlayCourse={setSelectedCourse}
/>
}
/>
{/* Professor Route - Protected */}
<Route
path="/gerenciar"
element={
<ProtectedRoute allowedRole="professor">
<ManageCourses />
</ProtectedRoute>
}
/>
{/* Catch all redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{/* Global Player Modal */}
<CoursePlayerModal
course={selectedCourse}
onClose={() => setSelectedCourse(null)}
/>
{/* Footer */}
<footer className="py-12 px-6 border-t border-white/10 bg-[#070709]">
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6 text-gray-400 text-sm">
<div className="flex items-center gap-3">
<span className="font-semibold text-white">Compor HUB</span>
<span className="text-gray-600">|</span>
<span>Portal de Cursos & Central de Downloads</span>
</div>
<div className="flex flex-wrap justify-center md:justify-start gap-6 text-xs text-gray-500">
<span>&copy; {new Date().getFullYear()} Compor HUB. Todos os direitos reservados.</span>
</div>
</div>
</footer>
</div>
);
}
function App() {
return (
<Router>
<AuthProvider>
<ToastProvider>
<AppContent />
</ToastProvider>
</AuthProvider>
</Router>
);
}
export default App;

View File

@@ -1,11 +0,0 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div>

104
components/CourseCard.tsx Normal file
View File

@@ -0,0 +1,104 @@
import React from 'react';
import { PlayCircle, Download, FileText, CheckCircle2 } from 'lucide-react';
import { Course, SectionVariant } from '../types';
interface CourseCardProps {
course: Course;
variant: SectionVariant;
onClick?: (course: Course) => void;
onOpenMaterials?: (course: Course) => void;
}
export const CourseCard: React.FC<CourseCardProps> = ({
course,
variant,
onClick,
onOpenMaterials
}) => {
const widthClass = variant === 'landscape'
? 'w-[320px] md:w-[420px]'
: 'w-[200px] md:w-[260px]';
const aspectRatioClass = variant === 'landscape'
? 'aspect-video'
: 'aspect-[3/4]';
// Count total materials (global + lesson specific)
const globalAttachmentsCount = course.attachments?.length || 0;
const lessonAttachmentsCount = course.lessons?.reduce((acc, l) => acc + (l.attachments?.length || 0), 0) || 0;
const totalMaterials = globalAttachmentsCount + lessonAttachmentsCount;
return (
<div
className={`group relative flex-none ${widthClass} cursor-pointer snap-start`}
>
{/* Thumbnail Container */}
<div
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}
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"
/>
{/* Badges on Thumbnail */}
<div className="absolute top-2.5 left-2.5 right-2.5 flex items-center justify-between gap-2 z-10 pointer-events-none">
<span className="text-caption font-semibold tracking-caption uppercase bg-black/75 backdrop-blur-md text-white px-2.5 py-0.5 rounded-[980px] border border-white/10 shadow-sm">
{course.category.split('&')[0].trim()}
</span>
{totalMaterials > 0 && (
<span className="flex items-center gap-1 text-caption font-semibold tracking-caption bg-orange-500/90 text-white px-2.5 py-0.5 rounded-[980px] backdrop-blur-md shadow-sm">
<Download className="w-3 h-3" />
{totalMaterials} {totalMaterials === 1 ? 'Material' : 'Materiais'}
</span>
)}
</div>
{/* Hover Play Button Overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-t from-black/80 via-black/30 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<div className="rounded-[980px] bg-orange-500 p-3 shadow-xl shadow-orange-500/30 scale-90 group-hover:scale-100 transition-transform duration-300">
<PlayCircle className="h-6 w-6 text-white fill-white/20" />
</div>
</div>
{/* Progress Bar */}
{course.progress !== undefined && course.progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/60">
<div
className="h-full bg-orange-500 transition-all duration-300"
style={{ width: `${course.progress}%` }}
/>
</div>
)}
</div>
{/* Metadata */}
<div className={`flex flex-col px-0.5 mt-3 transition-opacity duration-300 opacity-90 group-hover:opacity-100 ${variant === 'portrait' ? 'text-center' : ''}`}>
<div className="flex items-start justify-between gap-2">
<h3
onClick={() => onClick?.(course)}
className="text-body font-semibold text-white/95 line-clamp-1 group-hover:text-orange-400 transition-colors leading-body tracking-body"
>
{course.title}
</h3>
</div>
<div className={`flex items-center gap-2 text-caption font-normal tracking-caption text-gray-400 mt-1 ${variant === 'portrait' ? 'justify-center' : ''}`}>
<span>{course.duration}</span>
{course.instructor && (
<>
<span className="w-1 h-1 rounded-[980px] bg-gray-600"></span>
<span className="truncate">{course.instructor}</span>
</>
)}
</div>
</div>
</div>
);
};

228
components/CourseGrid.tsx Normal file
View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { CourseRow } from './CourseRow';
import { Section, Course } from '../types';
import { initDB, getCourses } from '../services/db';
import { Loader2, ChevronLeft, ChevronRight } from 'lucide-react';
import { CATEGORIES } from '../constants';
interface CourseGridProps {
onCourseSelect?: (course: Course) => void;
selectedCategoryFilter?: string;
}
export const CourseGrid: React.FC<CourseGridProps> = ({
onCourseSelect,
selectedCategoryFilter
}) => {
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const [activeCategory, setActiveCategory] = useState<string>('all');
// Slider State & Refs for Categories
const categoryScrollRef = useRef<HTMLDivElement>(null);
const isMouseDownRef = useRef(false);
const startXRef = useRef(0);
const scrollLeftRef = useRef(0);
const draggedDistanceRef = useRef(0);
const [isDraggingCategory, setIsDraggingCategory] = useState(false);
const [canScrollCatLeft, setCanScrollCatLeft] = useState(false);
const [canScrollCatRight, setCanScrollCatRight] = useState(false);
const checkCategoryBoundaries = useCallback(() => {
if (!categoryScrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = categoryScrollRef.current;
setCanScrollCatLeft(scrollLeft > 10);
setCanScrollCatRight(scrollLeft + clientWidth < scrollWidth - 10);
}, []);
useEffect(() => {
const loadData = async () => {
await initDB();
const allCourses = await getCourses();
setCourses(allCourses);
setLoading(false);
};
loadData();
}, []);
useEffect(() => {
checkCategoryBoundaries();
window.addEventListener('resize', checkCategoryBoundaries);
return () => window.removeEventListener('resize', checkCategoryBoundaries);
}, [checkCategoryBoundaries, courses]);
const handleCategoryScroll = (direction: 'left' | 'right') => {
if (!categoryScrollRef.current) return;
const scrollAmount = 300;
categoryScrollRef.current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
};
const handleCatMouseDown = (e: React.MouseEvent) => {
if (!categoryScrollRef.current) return;
isMouseDownRef.current = true;
startXRef.current = e.pageX - categoryScrollRef.current.offsetLeft;
scrollLeftRef.current = categoryScrollRef.current.scrollLeft;
draggedDistanceRef.current = 0;
setIsDraggingCategory(false);
};
const handleCatMouseMove = (e: React.MouseEvent) => {
if (!isMouseDownRef.current || !categoryScrollRef.current) return;
e.preventDefault();
const x = e.pageX - categoryScrollRef.current.offsetLeft;
const walk = (x - startXRef.current) * 1.4;
draggedDistanceRef.current = Math.abs(walk);
if (draggedDistanceRef.current > 5) {
setIsDraggingCategory(true);
categoryScrollRef.current.scrollLeft = scrollLeftRef.current - walk;
}
};
const handleCatMouseUp = () => {
isMouseDownRef.current = false;
setTimeout(() => {
setIsDraggingCategory(false);
draggedDistanceRef.current = 0;
}, 50);
};
const handleCategoryClick = (cat: string) => {
if (draggedDistanceRef.current < 8) {
setActiveCategory(cat);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64 w-full">
<Loader2 className="w-10 h-10 text-orange-500 animate-spin" />
</div>
);
}
// Filter courses if a specific category is active
const filteredCourses = activeCategory === 'all'
? courses
: courses.filter(c => c.category === activeCategory);
// Group into curated sections for Netflix/Apple TV style layout
const sections: Section[] = [
{
title: activeCategory === 'all' ? 'Cursos em Destaque' : activeCategory,
variant: 'landscape',
courses: filteredCourses
},
{
title: 'Com Materiais & Planilhas para Download',
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 (
<section className="relative z-20 pb-32 space-y-8">
{/* Category Pills Slider Container */}
<div className="px-6 md:px-12 max-w-[1440px] mx-auto select-none">
<div className="relative group/catslider flex items-center">
{/* Scroll Button Left */}
{canScrollCatLeft && (
<button
onClick={() => handleCategoryScroll('left')}
className="absolute -left-3 sm:-left-4 z-20 w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-zinc-900/90 hover:bg-orange-500 text-white border border-white/20 backdrop-blur-md flex items-center justify-center shadow-xl transition-all hover:scale-110 active:scale-95 shrink-0"
title="Categorias anteriores"
>
<ChevronLeft className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
)}
{/* Left Gradient Fade */}
{canScrollCatLeft && (
<div className="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-black via-black/80 to-transparent pointer-events-none z-10" />
)}
{/* Scrollable Category Track */}
<div
ref={categoryScrollRef}
onMouseDown={handleCatMouseDown}
onMouseMove={handleCatMouseMove}
onMouseUp={handleCatMouseUp}
onMouseLeave={handleCatMouseUp}
onScroll={checkCategoryBoundaries}
className={`flex items-center gap-2.5 overflow-x-auto py-2 hide-scrollbar flex-1 min-w-0 overscroll-x-contain scroll-smooth ${
isDraggingCategory ? 'cursor-grabbing' : 'cursor-grab'
}`}
>
<button
onClick={() => handleCategoryClick('all')}
className={`px-5 py-2.5 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all whitespace-nowrap shrink-0 shadow-sm ${
activeCategory === 'all'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25 scale-[1.02]'
: 'bg-zinc-900/90 text-gray-300 hover:bg-zinc-800 hover:text-white border border-white/10'
}`}
>
Todos os Cursos ({courses.length})
</button>
{CATEGORIES.map(cat => {
const count = courses.filter(c => c.category === cat).length;
return (
<button
key={cat}
onClick={() => handleCategoryClick(cat)}
className={`px-5 py-2.5 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all whitespace-nowrap shrink-0 shadow-sm ${
activeCategory === cat
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25 scale-[1.02]'
: 'bg-zinc-900/90 text-gray-300 hover:bg-zinc-800 hover:text-white border border-white/10'
}`}
>
{cat} {count > 0 && `(${count})`}
</button>
);
})}
</div>
{/* Right Gradient Fade */}
{canScrollCatRight && (
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-black via-black/80 to-transparent pointer-events-none z-10" />
)}
{/* Scroll Button Right */}
{canScrollCatRight && (
<button
onClick={() => handleCategoryScroll('right')}
className="absolute -right-3 sm:-right-4 z-20 w-8 h-8 sm:w-9 sm:h-9 rounded-full bg-zinc-900/90 hover:bg-orange-500 text-white border border-white/20 backdrop-blur-md flex items-center justify-center shadow-xl transition-all hover:scale-110 active:scale-95 shrink-0"
title="Mais categorias"
>
<ChevronRight className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
)}
</div>
</div>
{/* Sections Rows */}
{sections.map((section, index) => (
section.courses.length > 0 && (
<CourseRow
key={`${section.title}-${index}`}
section={section}
onCourseClick={onCourseSelect}
/>
)
))}
</section>
);
};

View File

@@ -0,0 +1,571 @@
import React, { useState, useEffect, useRef } from 'react';
import {
X,
Send,
User as UserIcon,
Lightbulb,
Paperclip,
Lock,
Unlock,
ListVideo,
Play,
Download,
Sparkles,
MessageSquare,
CheckCircle2,
Check,
Share2,
FileText
} from 'lucide-react';
import { Course, Comment, Lesson, Attachment } from '../types';
import { useAuth } from '../context/AuthContext';
import { getComments, saveComment, saveProgress, incrementViews } from '../services/db';
import { LoginModal } from './LoginModal';
import { MaterialCard } from './MaterialCard';
interface CoursePlayerModalProps {
course: Course | null;
onClose: () => void;
}
export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, onClose }) => {
const { user } = useAuth();
const [comments, setComments] = useState<Comment[]>([]);
const [newComment, setNewComment] = useState('');
const [currentProgress, setCurrentProgress] = useState(0);
const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null);
const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]);
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
const [copiedLink, setCopiedLink] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// Load course data & completed lessons from storage
useEffect(() => {
if (course) {
incrementViews(course.id);
getComments(course.id).then(setComments);
setCurrentProgress(course.progress || 0);
// Load completed lessons from localStorage
const storageKey = `course_${course.id}_completed_lessons`;
const savedCompleted = localStorage.getItem(storageKey);
if (savedCompleted) {
try {
setCompletedLessonIds(JSON.parse(savedCompleted));
} catch (e) {
setCompletedLessonIds([]);
}
} else if (course.progress && course.progress > 0 && course.lessons && course.lessons.length > 0) {
// Initial demo completion based on progress
const count = Math.ceil((course.progress / 100) * course.lessons.length);
const initialCompleted = course.lessons.slice(0, count).map(l => l.id);
setCompletedLessonIds(initialCompleted);
localStorage.setItem(storageKey, JSON.stringify(initialCompleted));
}
// Select first lesson by default
if (course.lessons && course.lessons.length > 0) {
setCurrentLesson(course.lessons[0]);
}
}
}, [course]);
// Persist completed lessons and recalculate progress
const updateCompletedLessons = (newCompleted: string[]) => {
setCompletedLessonIds(newCompleted);
if (course) {
localStorage.setItem(`course_${course.id}_completed_lessons`, JSON.stringify(newCompleted));
if (course.lessons && course.lessons.length > 0) {
const percent = Math.round((newCompleted.length / course.lessons.length) * 100);
setCurrentProgress(percent);
saveProgress(course.id, percent);
}
}
};
const toggleLessonCompleted = (lessonId: string) => {
if (completedLessonIds.includes(lessonId)) {
const updated = completedLessonIds.filter(id => id !== lessonId);
updateCompletedLessons(updated);
} else {
const updated = [...completedLessonIds, lessonId];
updateCompletedLessons(updated);
}
};
// Video finished automatically -> mark lesson as completed
const handleVideoEnded = () => {
if (currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id];
updateCompletedLessons(updated);
}
// Auto-advance to next lesson if available
if (course?.lessons && currentLesson) {
const currentIndex = course.lessons.findIndex(l => l.id === currentLesson.id);
if (currentIndex >= 0 && currentIndex < course.lessons.length - 1) {
const nextLesson = course.lessons[currentIndex + 1];
if (nextLesson.isFree || user) {
setTimeout(() => {
setCurrentLesson(nextLesson);
}, 1000);
}
}
}
};
// Track video progress
const handleTimeUpdate = () => {
if (videoRef.current) {
const { currentTime, duration } = videoRef.current;
if (duration > 0) {
const percent = Math.round((currentTime / duration) * 100);
// Automatically mark as complete when reaching 95%+
if (percent >= 95 && currentLesson && !completedLessonIds.includes(currentLesson.id)) {
const updated = [...completedLessonIds, currentLesson.id];
updateCompletedLessons(updated);
}
}
}
};
const handleAddComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !course) return;
const comment: Comment = {
id: Date.now().toString(),
courseId: course.id,
lessonId: currentLesson?.id,
user: user?.name || 'Aluno Convidado',
text: newComment,
date: new Date().toLocaleDateString('pt-BR')
};
await saveComment(comment);
setComments([comment, ...comments]);
setNewComment('');
};
const changeLesson = (lesson: Lesson) => {
setCurrentLesson(lesson);
};
const handleShare = () => {
navigator.clipboard.writeText(window.location.href);
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2500);
};
// Collect all downloadable materials for this course (Global + All Lessons)
const allCourseAttachments: Array<{ attachment: Attachment; source: string }> = [];
if (course?.attachments) {
course.attachments.forEach(att => {
allCourseAttachments.push({ attachment: att, source: 'Material Geral do Curso' });
});
}
if (course?.lessons) {
course.lessons.forEach(l => {
if (l.attachments) {
l.attachments.forEach(att => {
allCourseAttachments.push({ attachment: att, source: l.title });
});
}
});
}
const currentLessonAttachments = currentLesson?.attachments || [];
// Access Control Logic
const canWatch = currentLesson && (currentLesson.isFree || user);
if (!course) return null;
return (
<div className="fixed inset-0 z-[100] bg-[#09090b] text-white overflow-y-auto animate-fade-in-up">
{/* Top Bar Floating Controls */}
<div className="fixed top-0 left-0 right-0 p-4 md:p-6 z-30 flex items-center justify-between bg-gradient-to-b from-black/90 via-black/40 to-transparent pointer-events-none">
<div className="flex items-center gap-3 pointer-events-auto bg-black/60 backdrop-blur-md px-4 py-2 rounded-[980px] border border-white/10">
<span className="w-2 h-2 rounded-[980px] bg-orange-500 animate-pulse"></span>
<span className="text-caption font-semibold tracking-caption text-gray-200 truncate max-w-[200px] md:max-w-md">
{course.title}
</span>
</div>
<div className="flex items-center gap-3 pointer-events-auto">
<button
onClick={handleShare}
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 backdrop-blur-md text-white text-body-sm font-semibold tracking-body-sm px-4 py-2.5 rounded-[980px] transition-all active:scale-95 shadow-xl"
title="Copiar Link"
>
<Share2 className="w-4 h-4 text-orange-400" />
<span className="hidden sm:inline">{copiedLink ? 'Link Copiado!' : 'Compartilhar'}</span>
</button>
<button
onClick={onClose}
className="bg-white/10 hover:bg-white/20 backdrop-blur-md p-2.5 rounded-[980px] transition-all active:scale-95 text-white shadow-xl"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="max-w-[1440px] mx-auto min-h-screen pt-16 md:pt-20 pb-20">
<div className="flex flex-col lg:flex-row gap-8 px-4 md:px-8">
{/* Main Left Column: Video & Lesson Details */}
<div className="flex-1 lg:max-w-[65%] xl:max-w-[68%]">
{/* Video Player Section */}
<div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group">
{canWatch ? (
<video
key={currentLesson?.id}
ref={videoRef}
src={currentLesson?.videoUrl}
className="w-full h-full object-contain rounded-[8px]"
controls
autoPlay
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
>
Seu navegador não suporta a tag de vídeo.
</video>
) : (
// Locked State
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-10 text-center p-6 rounded-[8px]">
<div className="bg-orange-500/10 border border-orange-500/20 p-4 rounded-[8px] mb-4 text-orange-400">
<Lock className="w-8 h-8" />
</div>
<h2 className="font-display text-subheading md:text-heading-sm font-bold tracking-heading-sm leading-heading-sm mb-2">Aula Exclusiva (Pro)</h2>
<p className="text-body-sm font-normal text-gray-400 mb-6 max-w-md leading-body-sm tracking-body-sm">
Esta aula e seus materiais de download complementares estão disponíveis para membros. Faça login para continuar assistindo.
</p>
<button
onClick={() => setShowLoginPrompt(true)}
className="bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm px-8 py-3 rounded-[980px] transition-all shadow-xl shadow-orange-500/25 active:scale-95"
>
Fazer Login para Acessar
</button>
</div>
)}
</div>
{/* Current Lesson Header Info */}
<div className="mt-6 mb-8">
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-2.5 text-caption font-semibold uppercase tracking-caption text-orange-400">
<span>{course.category}</span>
<span className="w-1 h-1 rounded-[980px] bg-zinc-700"></span>
<span className="text-gray-400">{currentLesson?.duration || '15m'}</span>
</div>
<div className="flex items-center gap-2">
{/* Mark as completed toggle button */}
{currentLesson && (
<button
onClick={() => toggleLessonCompleted(currentLesson.id)}
className={`inline-flex items-center gap-1.5 text-xs font-semibold px-3.5 py-1.5 rounded-[980px] transition-all active:scale-95 ${
completedLessonIds.includes(currentLesson.id)
? 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 border border-emerald-500/30 font-bold'
: 'bg-white/10 hover:bg-emerald-500/20 hover:text-emerald-300 text-gray-300'
}`}
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{completedLessonIds.includes(currentLesson.id) ? 'Aula Concluída' : 'Concluir Aula'}</span>
</button>
)}
{/* Direct Button to Materials Tab if Available */}
{allCourseAttachments.length > 0 && (
<button
onClick={() => setActiveTab('materials')}
className="inline-flex items-center gap-2 text-caption font-semibold tracking-caption bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20 px-3.5 py-1.5 rounded-[980px] transition-all"
>
<Download className="w-3.5 h-3.5" />
<span>{allCourseAttachments.length} {allCourseAttachments.length === 1 ? 'Material' : 'Materiais'}</span>
</button>
)}
</div>
</div>
<h1 className="font-display text-subheading sm:text-heading-sm md:text-heading font-bold tracking-heading leading-heading text-white mb-3">
{currentLesson?.title || course.title}
</h1>
<p className="text-body-sm sm:text-body text-gray-300 leading-body tracking-body font-normal mb-6">
{currentLesson?.description || course.description}
</p>
{/* Lesson Specific Quick Downloads (if present) */}
{currentLessonAttachments.length > 0 && (
<div className="p-6 rounded-[8px] bg-zinc-900/80 border border-orange-500/20 mb-8 space-y-4 shadow-xl">
<div className="flex items-center justify-between">
<h4 className="text-caption font-semibold uppercase tracking-caption text-orange-400 flex items-center gap-2">
<Download className="w-4 h-4" />
Downloads Desta Aula
</h4>
<span className="text-caption font-mono text-gray-400">
{currentLessonAttachments.length} {currentLessonAttachments.length === 1 ? 'arquivo' : 'arquivos'}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{currentLessonAttachments.map(att => (
<MaterialCard key={att.id} attachment={att} compact />
))}
</div>
</div>
)}
{/* Instructor Key Takeaways / Tips */}
{course.tips && course.tips.length > 0 && (
<div className="bg-gradient-to-br from-zinc-900 to-black border border-white/10 rounded-[8px] p-6 mb-8 shadow-xl">
<h3 className="font-display text-subheading font-bold text-white mb-4 flex items-center gap-2.5 tracking-subheading leading-subheading">
<Lightbulb className="w-5 h-5 text-amber-400" />
Recomendações Práticas do Instrutor
</h3>
<ul className="space-y-3">
{course.tips.map((tip, i) => (
<li key={i} className="flex gap-3 text-gray-300 text-body-sm font-normal leading-body-sm tracking-body-sm">
<span className="block w-2 h-2 rounded-[980px] bg-orange-500 mt-2 shrink-0"></span>
<span>{tip}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Right Sidebar: Tabs for Playlist, Materials, and Discussion */}
<div className="lg:w-[35%] xl:w-[32%] shrink-0 flex flex-col space-y-6">
{/* Tab Navigation */}
<div className="flex items-center justify-between bg-zinc-950/90 p-1.5 rounded-[980px] border border-white/10 shadow-xl gap-1">
<button
onClick={() => setActiveTab('playlist')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 sm:px-3.5 rounded-[980px] text-xs font-semibold whitespace-nowrap transition-all ${
activeTab === 'playlist'
? 'bg-orange-500 text-white shadow-md shadow-orange-500/25'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
<ListVideo className="w-3.5 h-3.5 shrink-0" />
<span>Aulas ({course.lessons.length})</span>
</button>
<button
onClick={() => setActiveTab('materials')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 sm:px-3.5 rounded-[980px] text-xs font-semibold whitespace-nowrap transition-all ${
activeTab === 'materials'
? 'bg-orange-500 text-white shadow-md shadow-orange-500/25'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
<Download className="w-3.5 h-3.5 shrink-0" />
<span>Downloads ({allCourseAttachments.length})</span>
</button>
<button
onClick={() => setActiveTab('discussion')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 sm:px-3.5 rounded-[980px] text-xs font-semibold whitespace-nowrap transition-all ${
activeTab === 'discussion'
? 'bg-orange-500 text-white shadow-md shadow-orange-500/25'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
<span>Dúvidas ({comments.length})</span>
</button>
</div>
{/* TAB CONTENT: Playlist */}
{activeTab === 'playlist' && (
<div className="bg-zinc-950/80 rounded-2xl p-5 space-y-3 shadow-xl">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">Trilha de Aulas</span>
<span className="text-xs text-orange-400 font-mono font-bold">{course.duration}</span>
</div>
<div className="space-y-2 max-h-[600px] overflow-y-auto pr-1">
{course.lessons.map((lesson, index) => {
const isActive = currentLesson?.id === lesson.id;
const isCompleted = completedLessonIds.includes(lesson.id);
const isLocked = !lesson.isFree && !user;
const hasAttachments = lesson.attachments && lesson.attachments.length > 0;
return (
<div
key={lesson.id}
onClick={() => changeLesson(lesson)}
className={`
group flex items-start gap-3.5 p-3.5 rounded-xl cursor-pointer transition-all border
${isActive
? 'bg-orange-500/15 border-orange-500/40 text-orange-400 shadow-md shadow-orange-500/10'
: isCompleted
? 'bg-emerald-950/40 hover:bg-emerald-900/50 border-emerald-500/30 text-emerald-300 shadow-sm'
: 'bg-zinc-900/80 hover:bg-zinc-800/90 border-transparent text-gray-200'
}
${isLocked ? 'opacity-70' : 'opacity-100'}
`}
>
{/* Status Icon */}
<div className="mt-0.5 shrink-0">
{isActive ? (
<div className="p-2 rounded-lg bg-orange-500 text-white shadow-md">
<Play className="w-3.5 h-3.5 fill-white" />
</div>
) : isCompleted ? (
<div className="p-2 rounded-lg bg-emerald-500 text-white shadow-md">
<Check className="w-3.5 h-3.5 stroke-[3]" />
</div>
) : isLocked ? (
<div className="p-2 rounded-lg bg-white/5 text-gray-400">
<Lock className="w-3.5 h-3.5" />
</div>
) : (
<div className="p-2 rounded-lg bg-white/5 text-gray-400 font-mono text-xs font-bold w-8 h-8 flex items-center justify-center">
{index + 1}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className={`text-xs sm:text-sm font-semibold truncate ${
isActive
? 'text-orange-400 font-bold'
: isCompleted
? 'text-emerald-300 group-hover:text-emerald-200 font-semibold'
: 'text-gray-200 group-hover:text-white'
}`}>
{lesson.title}
</h4>
<div className="flex items-center gap-2 text-xs text-gray-400 mt-1 font-normal">
<span>{lesson.duration}</span>
{isCompleted && (
<span className="bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5" /> Concluída
</span>
)}
{lesson.isFree ? (
<span className="bg-emerald-500/15 text-emerald-400 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase">Grátis</span>
) : (
<span className="bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase">Exclusiva</span>
)}
{hasAttachments && (
<span className="flex items-center gap-1 text-[10px] text-orange-400 bg-orange-500/15 px-2 py-0.5 rounded-md font-bold">
<Paperclip className="w-2.5 h-2.5" />
{lesson.attachments!.length} anexo(s)
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* TAB CONTENT: Materials & Downloads */}
{activeTab === 'materials' && (
<div className="bg-zinc-950/80 rounded-2xl p-5 space-y-4 shadow-xl">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<div>
<h3 className="text-sm font-bold text-white">Materiais para Download</h3>
<p className="text-xs text-gray-400 mt-0.5">Arquivos anexados a este curso</p>
</div>
<span className="text-xs bg-orange-500/15 text-orange-400 font-bold px-2.5 py-1 rounded-full">
{allCourseAttachments.length} Total
</span>
</div>
{allCourseAttachments.length === 0 ? (
<div className="py-12 text-center text-gray-500">
<Download className="w-8 h-8 mx-auto mb-2 opacity-30 text-orange-400" />
<p className="text-xs text-gray-400">Nenhum material cadastrado para este curso ainda.</p>
</div>
) : (
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-1">
{allCourseAttachments.map((item, idx) => (
<div key={`${item.attachment.id}-${idx}`} className="space-y-1.5">
<span className="text-xs text-gray-400 font-semibold px-1 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-orange-500"></span>
{item.source}
</span>
<MaterialCard attachment={item.attachment} compact />
</div>
))}
</div>
)}
</div>
)}
{/* TAB CONTENT: Discussion */}
{activeTab === 'discussion' && (
<div className="bg-zinc-950/80 rounded-2xl p-5 flex flex-col space-y-4 shadow-xl">
<h3 className="text-sm font-bold text-white pb-2 border-b border-white/5">Dúvidas & Comentários</h3>
{/* Comment Form */}
<form onSubmit={handleAddComment} className="flex gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder={user ? "Faça uma pergunta sobre a aula..." : "Faça login para comentar"}
disabled={!user}
className="flex-1 bg-zinc-900/90 rounded-xl px-3.5 py-2.5 text-xs text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
/>
<button
type="submit"
disabled={!newComment.trim() || !user}
className="p-2.5 rounded-xl bg-orange-500 hover:bg-orange-600 disabled:opacity-30 text-white transition-colors"
>
<Send className="w-4 h-4" />
</button>
</form>
{/* Comments List */}
<div className="space-y-3 max-h-[450px] overflow-y-auto pr-1">
{comments.length === 0 ? (
<p className="text-xs text-gray-500 text-center py-8">Nenhum comentário ainda. Seja o primeiro a perguntar!</p>
) : (
comments.map(c => (
<div key={c.id} className="p-3 rounded-xl bg-zinc-900/90 space-y-1">
<div className="flex items-center justify-between text-xs font-semibold">
<span className="text-gray-200">{c.user}</span>
<span className="text-gray-500 font-mono text-[11px]">{c.date}</span>
</div>
<p className="text-xs text-gray-300 font-normal leading-relaxed">{c.text}</p>
{c.reply && (
<div className="mt-2 pl-3 border-l-2 border-orange-500 text-xs bg-white/[0.02] p-2 rounded-r-lg">
<span className="font-bold text-orange-400 uppercase text-[10px]">Instrutor:</span>
<p className="text-gray-300 text-xs mt-0.5">{c.reply}</p>
</div>
)}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
<LoginModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
</div>
);
};

176
components/CourseRow.tsx Normal file
View File

@@ -0,0 +1,176 @@
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { CourseCard } from './CourseCard';
import { Section, Course } from '../types';
import { ChevronRight, ChevronLeft } from 'lucide-react';
interface CourseRowProps {
section: Section;
onCourseClick?: (course: Course) => void;
}
export const CourseRow: React.FC<CourseRowProps> = ({ section, onCourseClick }) => {
const containerRef = useRef<HTMLDivElement>(null);
const isMouseDownRef = useRef(false);
const startXRef = useRef(0);
const scrollLeftRef = useRef(0);
const draggedDistanceRef = useRef(0);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const [isDragging, setIsDragging] = useState(false);
const checkScrollBoundaries = useCallback(() => {
if (!containerRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = containerRef.current;
setCanScrollLeft(scrollLeft > 20);
setCanScrollRight(scrollLeft + clientWidth < scrollWidth - 20);
}, []);
useEffect(() => {
checkScrollBoundaries();
window.addEventListener('resize', checkScrollBoundaries);
return () => window.removeEventListener('resize', checkScrollBoundaries);
}, [checkScrollBoundaries, section.courses]);
// Arrow button handlers
const handleScroll = (direction: 'left' | 'right') => {
if (!containerRef.current) return;
const scrollAmount = containerRef.current.clientWidth * 0.75;
containerRef.current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
};
// Mouse Drag-to-Scroll Handlers
const handleMouseDown = (e: React.MouseEvent) => {
if (!containerRef.current) return;
// Don't drag if clicking buttons or specific interactive items
isMouseDownRef.current = true;
startXRef.current = e.pageX - containerRef.current.offsetLeft;
scrollLeftRef.current = containerRef.current.scrollLeft;
draggedDistanceRef.current = 0;
setIsDragging(false);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isMouseDownRef.current || !containerRef.current) return;
e.preventDefault();
const x = e.pageX - containerRef.current.offsetLeft;
const walk = (x - startXRef.current) * 1.5; // Drag sensitivity multiplier
draggedDistanceRef.current = Math.abs(walk);
if (draggedDistanceRef.current > 6) {
setIsDragging(true);
containerRef.current.scrollLeft = scrollLeftRef.current - walk;
}
};
const handleMouseUp = () => {
isMouseDownRef.current = false;
setTimeout(() => {
setIsDragging(false);
draggedDistanceRef.current = 0;
}, 50);
};
const handleCardClick = (course: Course) => {
// Only trigger play/open if user wasn't dragging
if (draggedDistanceRef.current < 10) {
onCourseClick?.(course);
}
};
return (
<div className="flex flex-col py-6 group/row animate-fade-in-up relative select-none">
{/* Title Header */}
<div className="px-6 md:px-12 max-w-[1440px] w-full mx-auto mb-4 flex items-center justify-between">
<div className="flex items-baseline gap-4 group-hover/row:text-white transition-colors duration-300">
<h2 className="font-display text-xl sm:text-2xl md:text-2xl font-bold text-gray-100 tracking-tight">
{section.title}
</h2>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider hidden sm:inline">
{section.courses.length} {section.courses.length === 1 ? 'curso' : 'cursos'}
</span>
</div>
{/* Inline Navigation Arrows for Desktop */}
<div className="flex items-center gap-2">
<button
onClick={() => handleScroll('left')}
disabled={!canScrollLeft}
className="w-9 h-9 rounded-full bg-zinc-900/80 hover:bg-orange-500 disabled:opacity-20 disabled:hover:bg-zinc-900/80 text-white border border-white/10 flex items-center justify-center transition-all active:scale-95 shadow-lg"
title="Rolar para a esquerda"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={() => handleScroll('right')}
disabled={!canScrollRight}
className="w-9 h-9 rounded-full bg-zinc-900/80 hover:bg-orange-500 disabled:opacity-20 disabled:hover:bg-zinc-900/80 text-white border border-white/10 flex items-center justify-center transition-all active:scale-95 shadow-lg"
title="Rolar para a direita"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{/* Horizontal Carousel Container */}
<div className="relative w-full max-w-[1440px] mx-auto group/carousel">
{/* Floating Side Arrow Left (Hover Overlay) */}
{canScrollLeft && (
<button
onClick={() => handleScroll('left')}
className="absolute left-2 top-1/2 -translate-y-1/2 z-20 w-11 h-11 rounded-full bg-black/80 hover:bg-orange-500 text-white border border-white/20 backdrop-blur-md hidden md:flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-all duration-200 shadow-2xl hover:scale-110 active:scale-95"
title="Anterior"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
{/* Scroll Container with drag-to-scroll */}
<div
ref={containerRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onScroll={checkScrollBoundaries}
className={`flex overflow-x-auto gap-6 px-6 md:px-12 pb-8 pt-1 hide-scrollbar overscroll-x-contain scroll-smooth ${
isDragging ? 'cursor-grabbing select-none' : 'cursor-grab'
}`}
style={{ scrollSnapType: isDragging ? 'none' : 'x mandatory' }}
>
{section.courses.map((course) => (
<div key={course.id} className="snap-start shrink-0">
<CourseCard
course={course}
variant={section.variant}
onClick={handleCardClick}
/>
</div>
))}
{/* Padding at the end */}
<div className="w-8 md:w-12 flex-none" />
</div>
{/* Floating Side Arrow Right (Hover Overlay) */}
{canScrollRight && (
<button
onClick={() => handleScroll('right')}
className="absolute right-2 top-1/2 -translate-y-1/2 z-20 w-11 h-11 rounded-full bg-black/80 hover:bg-orange-500 text-white border border-white/20 backdrop-blur-md hidden md:flex items-center justify-center opacity-0 group-hover/carousel:opacity-100 transition-all duration-200 shadow-2xl hover:scale-110 active:scale-95"
title="Próximo"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
{/* Subtle Vignette on the right */}
<div className="absolute top-0 bottom-0 right-0 w-16 bg-gradient-to-l from-black to-transparent pointer-events-none z-10 hidden md:block" />
</div>
</div>
);
};

75
components/Hero.tsx Normal file
View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Play, Plus, Sparkles } from 'lucide-react';
import { FEATURED_COURSE } from '../constants';
import { Course } from '../types';
interface HeroProps {
onPlay?: (course: Course) => void;
}
export const Hero: React.FC<HeroProps> = ({ onPlay }) => {
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={FEATURED_COURSE.thumbnail}
alt="Featured Course"
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">{FEATURED_COURSE.category}</span>
<span className="text-gray-500">•</span>
<span className="text-gray-300">{FEATURED_COURSE.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">
{FEATURED_COURSE.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">
{FEATURED_COURSE.description}
</p>
{/* Action Buttons with comfortable width, borders, and spacing */}
<div className="flex flex-wrap items-center gap-4">
{/* Primary CTA */}
<button
onClick={() => onPlay?.(FEATURED_COURSE)}
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>
);
};

166
components/LoginModal.tsx Normal file
View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { X, Loader2, GraduationCap, School, KeyRound } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { UserRole } from '../types';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
}
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<UserRole>('student'); // Default role
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsSubmitting(true);
// Simulate network delay for "premium" feel
setTimeout(() => {
// In a real app, the backend verifies credentials AND role matches.
// Here we simulate checking if the user exists for that specific context.
const success = login(email);
setIsSubmitting(false);
if (success) {
onClose();
if (email === 'admin@prof.com') {
// Force check: if they selected student but logged in as admin credentials, redirect correctly or warn.
// For UX simplicity here, we trust the credential mapping in AuthContext
navigate('/gerenciar');
} else {
navigate('/');
}
} else {
setError('Credenciais inválidas. Verifique o email ou a função selecionada.');
}
}, 800);
};
const handleDemoLogin = (roleType: UserRole) => {
const demoEmail = roleType === 'professor' ? 'admin@prof.com' : 'aluno@test.com';
setEmail(demoEmail);
setPassword('demo123');
setRole(roleType);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-xl transition-opacity"
onClick={onClose}
/>
{/* Modal Content */}
<div className="relative w-full max-w-md bg-[#1c1c1e] rounded-[8px] shadow-xl border border-white/10 p-6 md:p-8 animate-fade-in-up">
<button
onClick={onClose}
className="absolute top-5 right-5 text-white/40 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="text-center mb-6">
<h2 className="font-display text-heading-sm font-bold text-white mb-1.5 tracking-heading-sm leading-heading-sm">Compor HUB ID</h2>
<p className="text-body-sm font-normal text-white/60 tracking-body-sm leading-body-sm">Acesse seu espaço de aprendizado e materiais</p>
</div>
{/* Role Toggle */}
<div className="flex bg-black/40 p-1 rounded-[8px] mb-6 border border-white/5 relative">
{/* Sliding Background */}
<div
className={`absolute top-1 bottom-1 w-[calc(50%-4px)] bg-orange-500/20 rounded-[8px] transition-all duration-300 ease-out border border-orange-500/30 shadow-md ${role === 'student' ? 'left-1' : 'left-[calc(50%+4px)]'}`}
/>
<button
type="button"
onClick={() => setRole('student')}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'student' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}
>
<GraduationCap className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Aluno</span>
</button>
<button
type="button"
onClick={() => setRole('professor')}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-[8px] relative z-10 transition-colors duration-300 ${role === 'professor' ? 'text-white font-semibold' : 'text-gray-400 hover:text-gray-300'}`}
>
<School className="w-4 h-4" />
<span className="text-body-sm font-semibold tracking-body-sm">Instrutor</span>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-3">
<div>
<input
type="email"
placeholder="Seu e-mail"
value={email}
onChange={(e) => setEmail(e.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
/>
</div>
<div>
<input
type="password"
placeholder="Sua senha"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body"
/>
</div>
</div>
{/* Quick Demo Buttons */}
<div className="flex gap-2 justify-center">
<button
type="button"
onClick={() => handleDemoLogin('student')}
className="text-caption font-semibold tracking-caption text-orange-400 hover:text-orange-300 px-3 py-1 rounded-[980px] bg-orange-500/10 border border-orange-500/20 flex items-center gap-1 transition-colors"
>
<KeyRound className="w-3 h-3" /> Acesso Aluno (Demo)
</button>
<button
type="button"
onClick={() => handleDemoLogin('professor')}
className="text-caption font-semibold tracking-caption text-emerald-400 hover:text-emerald-300 px-3 py-1 rounded-[980px] bg-emerald-500/10 border border-emerald-500/20 flex items-center gap-1 transition-colors"
>
<KeyRound className="w-3 h-3" /> Acesso Instrutor (Demo)
</button>
</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>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-white text-black text-body-sm font-semibold tracking-body-sm py-3.5 rounded-[980px] hover:bg-gray-200 active:scale-95 transition-all duration-200 flex items-center justify-center gap-2 shadow-xl"
>
{isSubmitting ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
'Entrar'
)}
</button>
</form>
</div>
</div>
);
};

88
components/Logo.tsx Normal file
View File

@@ -0,0 +1,88 @@
import React from 'react';
interface LogoProps {
variant?: 'dark' | 'light'; // 'dark' = fundo escuro (compor branco + HUB laranja) | 'light' = fundo claro (compor escuro + HUB laranja)
className?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
showSymbolOnly?: boolean;
}
export const Logo: React.FC<LogoProps> = ({
variant = 'dark',
className = '',
size = 'md',
showSymbolOnly = false,
}) => {
const isDark = variant === 'dark';
// Sizing styles
const textSizes = {
sm: 'text-xl',
md: 'text-2xl',
lg: 'text-3xl',
xl: 'text-4xl',
};
const dotSizes = {
sm: { r: 2.5, gap: 'gap-1', w: 'w-4' },
md: { r: 3.5, gap: 'gap-1', w: 'w-5' },
lg: { r: 4.5, gap: 'gap-1.5', w: 'w-6' },
xl: { r: 5.5, gap: 'gap-2', w: 'w-8' },
};
if (showSymbolOnly) {
return (
<svg
viewBox="0 0 36 30"
className={`${size === 'sm' ? 'h-5' : size === 'md' ? 'h-6' : size === 'lg' ? 'h-8' : 'h-10'} w-auto inline-block ${className}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Compor HUB"
>
<circle cx="10" cy="8" r="6" fill="#EA721D" />
<circle cx="26" cy="8" r="6" fill="#EA721D" />
<circle cx="18" cy="22" r="6" fill="#EA721D" />
</svg>
);
}
return (
<div
className={`inline-flex items-center select-none font-sans font-black tracking-tighter leading-none ${className}`}
>
{/* "compor" */}
<span
className={`${textSizes[size]} font-black ${
isDark ? 'text-white' : 'text-[#222222]'
} tracking-[-0.04em]`}
style={{ fontFamily: "system-ui, -apple-system, 'Montserrat', 'Segoe UI', Roboto, sans-serif" }}
>
compor
</span>
{/* 3 Orange Dots Icon - Floating elevated between compor & HUB */}
<div className="inline-flex flex-col items-center justify-center mx-1.5 self-start pt-0.5">
<svg
viewBox="0 0 34 28"
className={`${size === 'sm' ? 'h-3' : size === 'md' ? 'h-4' : size === 'lg' ? 'h-5' : 'h-6'} w-auto`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="9" cy="7" r="5.5" fill="#EA721D" />
<circle cx="25" cy="7" r="5.5" fill="#EA721D" />
<circle cx="17" cy="20.5" r="5.5" fill="#EA721D" />
</svg>
</div>
{/* "HUB" */}
<span
className={`${textSizes[size]} font-black text-[#EA721D] tracking-normal`}
style={{ fontFamily: "system-ui, -apple-system, 'Montserrat', 'Segoe UI', Roboto, sans-serif" }}
>
HUB
</span>
</div>
);
};
export default Logo;

194
components/MaterialCard.tsx Normal file
View File

@@ -0,0 +1,194 @@
import React from 'react';
import {
FileText,
FileSpreadsheet,
FileArchive,
FileCode,
Image as ImageIcon,
Link as LinkIcon,
Download,
ExternalLink,
CheckCircle2,
Paperclip
} from 'lucide-react';
import { Attachment, AttachmentType } from '../types';
import { useToast } from '../context/ToastContext';
interface MaterialCardProps {
attachment: Attachment;
onDownload?: (attachment: Attachment) => void;
compact?: boolean;
}
export const getAttachmentIcon = (type: AttachmentType) => {
switch (type) {
case 'pdf':
return <FileText className="w-6 h-6 text-rose-500 shrink-0" />;
case 'spreadsheet':
return <FileSpreadsheet className="w-6 h-6 text-emerald-400 shrink-0" />;
case 'zip':
return <FileArchive className="w-6 h-6 text-amber-400 shrink-0" />;
case 'doc':
return <FileText className="w-6 h-6 text-blue-400 shrink-0" />;
case 'image':
return <ImageIcon className="w-6 h-6 text-purple-400 shrink-0" />;
case 'link':
return <LinkIcon className="w-6 h-6 text-sky-400 shrink-0" />;
default:
return <Paperclip className="w-6 h-6 text-gray-400 shrink-0" />;
}
};
export const getAttachmentBadge = (type: AttachmentType) => {
switch (type) {
case 'pdf':
return { label: 'PDF', bg: 'bg-rose-500/15 text-rose-400 font-bold' };
case 'spreadsheet':
return { label: 'PLANILHA', bg: 'bg-emerald-500/15 text-emerald-400 font-bold' };
case 'zip':
return { label: 'PACOTE ZIP', bg: 'bg-amber-500/15 text-amber-400 font-bold' };
case 'doc':
return { label: 'DOCUMENTO', bg: 'bg-blue-500/15 text-blue-400 font-bold' };
case 'image':
return { label: 'IMAGEM / ASSET', bg: 'bg-purple-500/15 text-purple-400 font-bold' };
case 'link':
return { label: 'LINK EXTERNO', bg: 'bg-sky-500/15 text-sky-400 font-bold' };
default:
return { label: 'ARQUIVO', bg: 'bg-gray-500/15 text-gray-400 font-bold' };
}
};
export const MaterialCard: React.FC<MaterialCardProps> = ({
attachment,
onDownload,
compact = false,
}) => {
const [downloaded, setDownloaded] = React.useState(false);
const { showDownloadToast, showToast } = useToast();
const badge = getAttachmentBadge(attachment.type);
const handleAction = (e: React.MouseEvent) => {
setDownloaded(true);
if (attachment.type === 'link') {
showToast(`Acessando link "${attachment.name}"`, 'info');
} else {
showDownloadToast(attachment.name, attachment.size);
}
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);
}
};
if (compact) {
return (
<a
href={attachment.url || '#'}
target={attachment.type === 'link' ? '_blank' : '_self'}
rel="noreferrer"
onClick={handleAction}
className="flex items-center justify-between p-3.5 rounded-xl bg-zinc-900/90 hover:bg-zinc-800/90 transition-all duration-200 group shadow-sm"
>
<div className="flex items-center gap-3.5 min-w-0 pr-3">
<div className="shrink-0 group-hover:scale-110 transition-transform">
{getAttachmentIcon(attachment.type)}
</div>
<div className="min-w-0">
<p className="text-xs sm:text-sm font-semibold text-gray-200 group-hover:text-white truncate">
{attachment.name}
</p>
<div className="flex items-center gap-2 text-[11px] font-normal text-gray-400 mt-0.5">
<span className={`px-2 py-0.5 rounded-md text-[10px] uppercase tracking-wider ${badge.bg}`}>
{badge.label}
</span>
{attachment.size && <span className="font-mono">• {attachment.size}</span>}
</div>
</div>
</div>
<div className="shrink-0 pl-2">
{downloaded ? (
<span className="flex items-center gap-1.5 text-xs text-emerald-400 font-bold bg-emerald-500/15 px-3 py-1.5 rounded-lg">
<CheckCircle2 className="w-3.5 h-3.5" />
Baixado
</span>
) : (
<button
type="button"
className="flex items-center gap-1.5 text-xs font-semibold bg-white/10 hover:bg-orange-500 hover:text-white text-gray-200 px-3.5 py-1.5 rounded-lg transition-all active:scale-95"
>
{attachment.type === 'link' ? (
<>
<ExternalLink className="w-3.5 h-3.5" />
Acessar
</>
) : (
<>
<Download className="w-3.5 h-3.5" />
Baixar
</>
)}
</button>
)}
</div>
</a>
);
}
return (
<div className="flex flex-col justify-between p-6 rounded-2xl bg-zinc-900/90 hover:bg-zinc-850 hover:shadow-xl transition-all duration-300 group">
<div>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="shrink-0 group-hover:scale-110 transition-transform">
{getAttachmentIcon(attachment.type)}
</div>
<span className={`px-2.5 py-0.5 rounded-md text-[10px] uppercase tracking-wider ${badge.bg}`}>
{badge.label}
</span>
</div>
<h4 className="text-sm font-semibold text-white mb-1.5 line-clamp-1 group-hover:text-orange-400 transition-colors">
{attachment.name}
</h4>
{attachment.description && (
<p className="text-xs font-normal text-gray-400 line-clamp-2 mb-4">
{attachment.description}
</p>
)}
</div>
<div className="pt-4 border-t border-white/5 flex items-center justify-between mt-auto">
<span className="text-xs font-mono text-gray-400 font-medium">
{attachment.size || 'Disponível'}
</span>
<a
href={attachment.url || '#'}
target={attachment.type === 'link' ? '_blank' : '_self'}
rel="noreferrer"
onClick={handleAction}
className="inline-flex items-center gap-2 text-xs font-bold text-black bg-white hover:bg-orange-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 active:scale-95 shadow-md"
>
{attachment.type === 'link' ? (
<>
<ExternalLink className="w-3.5 h-3.5" />
Abrir Link
</>
) : (
<>
<Download className="w-3.5 h-3.5" />
Baixar Arquivo
</>
)}
</a>
</div>
</div>
);
};

View File

@@ -0,0 +1,218 @@
import React, { useState } from 'react';
import {
Download,
Search,
Filter,
FileText,
FileSpreadsheet,
FileArchive,
FileCode,
Sparkles,
ExternalLink,
BookOpen,
Layers
} from 'lucide-react';
import { Course, Attachment, AttachmentType } from '../types';
import { MaterialCard } from './MaterialCard';
interface MaterialsHubModalProps {
isOpen: boolean;
onClose: () => void;
courses: Course[];
onSelectCourse?: (course: Course) => void;
}
export const MaterialsHubModal: React.FC<MaterialsHubModalProps> = ({
isOpen,
onClose,
courses,
onSelectCourse,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
if (!isOpen) return null;
// Aggregate all materials across all courses and lessons
const allMaterials: Array<{ attachment: Attachment; course: Course; lessonTitle?: string }> = [];
courses.forEach(course => {
// Global course attachments
if (course.attachments && course.attachments.length > 0) {
course.attachments.forEach(att => {
allMaterials.push({ attachment: att, course });
});
}
// Lesson specific attachments
if (course.lessons) {
course.lessons.forEach(lesson => {
if (lesson.attachments && lesson.attachments.length > 0) {
lesson.attachments.forEach(att => {
allMaterials.push({ attachment: att, course, lessonTitle: lesson.title });
});
}
});
}
});
// Filtered materials
const filtered = allMaterials.filter(item => {
const matchesSearch =
item.attachment.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.course.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(item.attachment.description && item.attachment.description.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesType = selectedType === 'all' || item.attachment.type === selectedType;
const matchesCategory = selectedCategory === 'all' || item.course.category === selectedCategory;
return matchesSearch && matchesType && matchesCategory;
});
const categories = Array.from(new Set(courses.map(c => c.category)));
return (
<div className="fixed inset-0 z-[120] bg-black/80 backdrop-blur-xl flex items-center justify-center p-4 md:p-8 animate-fade-in-up">
<div className="bg-[#101012] border border-white/10 rounded-[8px] w-full max-w-6xl max-h-[90vh] flex flex-col shadow-xl overflow-hidden">
{/* Header */}
<div className="p-6 md:p-8 border-b border-white/10 flex flex-col md:flex-row md:items-center justify-between gap-6 bg-gradient-to-r from-orange-950/20 via-black to-black">
<div>
<div className="flex items-center gap-2 text-caption font-semibold uppercase tracking-caption text-orange-400 mb-2">
<Sparkles className="w-4 h-4" />
<span>Central de Recursos & Downloads</span>
</div>
<h2 className="font-display text-subheading md:text-heading-sm font-bold text-white tracking-heading-sm leading-heading-sm">
Biblioteca de Materiais Complementares
</h2>
<p className="text-body-sm font-normal text-gray-400 mt-1 leading-body-sm tracking-body-sm">
Baixe planilhas, modelos, e-books, checklists e PDFs de todos os cursos do Compor HUB.
</p>
</div>
<button
onClick={onClose}
className="self-end md:self-center px-5 py-2.5 rounded-[980px] bg-white/10 hover:bg-white/20 text-white text-body-sm font-semibold tracking-body-sm transition-all shadow-xl"
>
Fechar
</button>
</div>
{/* Filters and Search Bar */}
<div className="p-6 border-b border-white/5 bg-[#141417] space-y-4">
<div className="flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between">
{/* Search Input */}
<div className="relative flex-1">
<Search className="w-5 h-5 text-gray-400 absolute left-4 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Pesquisar material por nome, curso ou palavra-chave..."
className="w-full bg-black/60 border border-white/10 rounded-[8px] pl-12 pr-4 py-3 text-body-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-all"
/>
</div>
{/* Type Filters Pills */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 hide-scrollbar">
{[
{ id: 'all', label: 'Todos' },
{ id: 'pdf', label: 'PDFs' },
{ id: 'spreadsheet', label: 'Planilhas' },
{ id: 'zip', label: 'Pacotes ZIP' },
{ id: 'doc', label: 'Documentos' },
{ id: 'link', label: 'Links' },
].map(t => (
<button
key={t.id}
onClick={() => setSelectedType(t.id)}
className={`px-4 py-2 rounded-[980px] text-body-sm font-semibold tracking-body-sm whitespace-nowrap transition-all ${
selectedType === t.id
? 'bg-orange-500 text-white shadow-xl shadow-orange-500/20'
: 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white'
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2 flex-wrap text-caption font-semibold tracking-caption text-gray-400 pt-1">
<span className="text-gray-300 flex items-center gap-1.5 mr-1">
<Filter className="w-3.5 h-3.5 text-orange-400" /> Categoria:
</span>
<button
onClick={() => setSelectedCategory('all')}
className={`px-3 py-1 rounded-[980px] transition-colors ${
selectedCategory === 'all' ? 'bg-white/20 text-white' : 'hover:text-white'
}`}
>
Todas ({allMaterials.length})
</button>
{categories.map(cat => {
const count = allMaterials.filter(m => m.course.category === cat).length;
return (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1 rounded-[980px] transition-colors ${
selectedCategory === cat ? 'bg-white/20 text-white' : 'hover:text-white'
}`}
>
{cat} ({count})
</button>
);
})}
</div>
</div>
{/* Material Cards Grid */}
<div className="flex-1 overflow-y-auto p-6 md:p-8 space-y-6">
{filtered.length === 0 ? (
<div className="py-20 text-center text-gray-500">
<Download className="w-12 h-12 mx-auto mb-3 opacity-30 text-orange-400" />
<p className="text-body font-semibold text-gray-400">Nenhum material encontrado</p>
<p className="text-caption text-gray-600 mt-1">Tente ajustar seus termos de pesquisa ou filtros.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filtered.map((item, index) => (
<div key={`${item.attachment.id}-${index}`} className="flex flex-col">
{/* Context Badge: Which Course / Lesson */}
<div className="flex items-center justify-between text-caption font-semibold tracking-caption text-gray-400 mb-2 px-1">
<span
onClick={() => {
onClose();
onSelectCourse?.(item.course);
}}
className="hover:text-orange-400 cursor-pointer truncate flex items-center gap-1.5"
>
<BookOpen className="w-3.5 h-3.5 text-orange-500/80" />
{item.course.title}
</span>
{item.lessonTitle && (
<span className="text-gray-500 truncate max-w-[120px] font-normal" title={item.lessonTitle}>
{item.lessonTitle}
</span>
)}
</div>
<MaterialCard attachment={item.attachment} />
</div>
))}
</div>
)}
</div>
{/* Footer info */}
<div className="p-4 px-8 border-t border-white/5 bg-[#0a0a0c] flex items-center justify-between text-caption font-semibold tracking-caption text-gray-500">
<span>{filtered.length} materiais disponíveis para download</span>
<span>Compor HUB • Materiais Exclusivos</span>
</div>
</div>
</div>
);
};

143
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,143 @@
import React, { useState, useEffect } from 'react';
import { User as UserIcon, LogOut, Search, Folder } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
import { LoginModal } from './LoginModal';
import { SearchModal } from './SearchModal';
import { useNavigate, useLocation } from 'react-router-dom';
import { Course } from '../types';
import { Logo } from './Logo';
interface NavbarProps {
onPlay: (course: Course) => void;
}
export const Navbar: React.FC<NavbarProps> = ({ onPlay }) => {
const [scrolled, setScrolled] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [showSearchModal, setShowSearchModal] = useState(false);
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleLogout = () => {
logout();
navigate('/');
};
const isMaterialsPage = location.pathname === '/materiais';
const isHomePage = location.pathname === '/';
return (
<>
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ease-in-out px-6 md:px-12 py-4 ${
scrolled || location.pathname !== '/'
? 'bg-[#09090b]/95 backdrop-blur-xl shadow-2xl shadow-black/80'
: 'bg-transparent bg-gradient-to-b from-black/90 via-black/40 to-transparent'
}`}
>
<div className="max-w-[1440px] mx-auto flex items-center justify-between gap-4">
<div className="flex items-center gap-6 md:gap-8">
<div
className="flex items-center gap-2 group cursor-pointer"
onClick={() => navigate('/')}
>
<Logo variant="dark" size="lg" />
</div>
{/* Quick Navigation Links */}
<div className="hidden md:flex items-center gap-2 text-sm font-medium">
<button
onClick={() => navigate('/')}
className={`px-4 py-2 rounded-[980px] transition-colors ${
isHomePage
? 'text-white bg-white/10 font-semibold'
: 'text-gray-400 hover:text-white hover:bg-white/5'
}`}
>
Cursos
</button>
<button
onClick={() => navigate('/materiais')}
className={`px-4 py-2 rounded-[980px] transition-colors flex items-center gap-2 ${
isMaterialsPage
? 'text-orange-400 bg-orange-500/15 font-semibold border border-orange-500/30'
: 'text-gray-400 hover:text-orange-400 hover:bg-white/5'
}`}
>
<Folder className="w-4 h-4 text-orange-400" />
<span>Biblioteca de Materiais</span>
</button>
</div>
</div>
<div className="flex items-center gap-3 sm:gap-4">
{/* Search */}
<button
onClick={() => setShowSearchModal(true)}
className="text-gray-300 hover:text-white text-sm font-medium transition-colors flex items-center gap-2 px-3.5 py-2.5 rounded-[980px] hover:bg-white/10 border border-transparent hover:border-white/10"
>
<Search className="w-4 h-4" />
<span className="hidden lg:inline">Buscar</span>
</button>
{!user ? (
<button
onClick={() => setShowLoginModal(true)}
className="flex items-center gap-2.5 bg-orange-500 hover:bg-orange-600 active:scale-95 transition-all duration-300 px-6 py-2.5 rounded-[980px] text-sm font-bold text-white shadow-xl shadow-orange-500/25 shrink-0"
>
<UserIcon className="w-4 h-4 text-white" />
<span>Entrar</span>
</button>
) : (
<div className="flex items-center gap-3">
{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>
)}
<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"
title="Encerrar Sessão"
>
<LogOut className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Sair</span>
</button>
</div>
</div>
)}
</div>
</div>
</nav>
<LoginModal isOpen={showLoginModal} onClose={() => setShowLoginModal(false)} />
<SearchModal isOpen={showSearchModal} onClose={() => setShowSearchModal(false)} onPlay={onPlay} />
</>
);
};

170
components/SearchModal.tsx Normal file
View File

@@ -0,0 +1,170 @@
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';
interface SearchModalProps {
isOpen: boolean;
onClose: () => void;
onPlay: (course: Course) => void;
}
export const SearchModal: React.FC<SearchModalProps> = ({ isOpen, onClose, onPlay }) => {
const [query, setQuery] = useState('');
const [allCourses, setAllCourses] = useState<Course[]>([]);
const [results, setResults] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
// Load courses on mount
useEffect(() => {
const loadData = async () => {
const courses = await getCourses();
setAllCourses(courses);
setLoading(false);
};
loadData();
}, []);
// Focus input when opened
useEffect(() => {
if (isOpen) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => { document.body.style.overflow = 'unset'; };
}, [isOpen]);
// Filter logic across Title, Category, Instructor and Materials
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const lowerQuery = query.toLowerCase();
const filtered = allCourses.filter(c => {
const matchesTitle = c.title.toLowerCase().includes(lowerQuery);
const matchesCat = c.category.toLowerCase().includes(lowerQuery);
const matchesInstructor = c.instructor?.toLowerCase().includes(lowerQuery);
const matchesLessons = c.lessons.some(l => l.title.toLowerCase().includes(lowerQuery));
const matchesAttachments = c.attachments?.some(a => a.name.toLowerCase().includes(lowerQuery));
return matchesTitle || matchesCat || matchesInstructor || matchesLessons || matchesAttachments;
});
setResults(filtered);
}, [query, allCourses]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] bg-[#0a0a0c]/95 backdrop-blur-xl animate-fade-in-up flex flex-col">
{/* Header / Search Input */}
<div className="pt-20 px-6 md:px-12 pb-6 border-b border-white/10 relative">
<button
onClick={onClose}
className="absolute top-6 right-6 md:right-12 p-2.5 bg-white/10 hover:bg-white/20 rounded-[980px] text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="max-w-[1440px] mx-auto w-full">
<div className="flex items-center gap-4">
<Search className="w-7 h-7 text-orange-400 shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Buscar por cursos, aulas, instrutor ou materiais..."
className="w-full bg-transparent font-display text-2xl md:text-heading font-bold text-white placeholder:text-gray-600 focus:outline-none tracking-heading leading-heading"
/>
</div>
</div>
</div>
{/* Results Area */}
<div className="flex-1 overflow-y-auto px-6 md:px-12 py-8">
<div className="max-w-[1440px] mx-auto">
{loading ? (
<div className="flex justify-center mt-20">
<Loader2 className="w-10 h-10 text-orange-500 animate-spin" />
</div>
) : (
<>
{!query && (
<div className="text-center mt-20 opacity-40 space-y-2">
<p className="font-display text-subheading font-normal text-gray-300 tracking-subheading leading-subheading">Digite um termo para pesquisar cursos ou arquivos</p>
<p className="text-caption font-normal text-gray-500 tracking-caption">Ex: Meta Ads, ROAS, Planilha, Criativos, Funil</p>
</div>
)}
{query && results.length === 0 && (
<div className="text-center mt-20">
<p className="text-body font-normal text-gray-400 tracking-body">Nenhum curso ou material encontrado para "{query}"</p>
</div>
)}
{results.length > 0 && (
<div>
<h3 className="text-gray-400 font-semibold mb-6 uppercase tracking-caption text-caption flex items-center gap-2">
<span className="w-2 h-2 rounded-[980px] bg-orange-500"></span>
{results.length} Curso{results.length !== 1 ? 's' : ''} Encontrado{results.length !== 1 ? 's' : ''}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
{results.map(course => {
const matCount = (course.attachments?.length || 0) + course.lessons.reduce((acc, l) => acc + (l.attachments?.length || 0), 0);
return (
<div
key={course.id}
onClick={() => { onPlay(course); onClose(); }}
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}
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]"
/>
<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" />
</div>
{matCount > 0 && (
<div className="absolute bottom-2 right-2 bg-black/70 backdrop-blur-md px-2 py-0.5 rounded-[980px] text-caption font-semibold text-orange-400 border border-orange-500/30 flex items-center gap-1">
<Download className="w-2.5 h-2.5" />
{matCount}
</div>
)}
</div>
<h4 className="text-white font-semibold text-body-sm truncate group-hover:text-orange-400 transition-colors leading-body-sm tracking-body-sm">
{course.title}
</h4>
<div className="flex items-center justify-between text-caption font-normal tracking-caption text-gray-500 mt-1">
<span className="truncate">{course.category}</span>
<span>{course.duration}</span>
</div>
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
};

561
constants.ts Normal file
View File

@@ -0,0 +1,561 @@
import { Course, Section, SectionVariant, Lesson, Attachment } from './types';
export const CATEGORIES = [
'Tráfego Pago & Meta Ads',
'Google Ads & YouTube',
'Funis de Vendas & ROI',
'Copywriting & Criativos',
'Gestão & Processos de Agência',
'Automação & CRM',
];
const SAMPLE_VIDEO = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
export const FEATURED_COURSE: Course = {
id: 'featured-masterclass-roas',
title: 'Masterclass: Escala de Tráfego Pago & ROAS 5x+',
category: 'Tráfego Pago & Meta Ads',
thumbnail: 'https://images.unsplash.com/photo-1551836022-d5d88e9218df?auto=format&fit=crop&w=1920&q=80',
duration: '6 Aulas • 3h 45m',
description: 'O método completo e validado para escalar campanhas de Meta Ads e Google Ads com previsibilidade, gestão de criativos de alta retenção e controle estrito de CAC e Blended ROAS.',
instructor: 'Mário Morgado',
instructorRole: 'Head de Performance & Growth',
lessons: [
{
id: 'l-feat-1',
title: '01. Arquitetura de Conta & Estratégia de Escala CBO/ABO',
duration: '22m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Como montar uma estrutura robusta à prova de bloqueios e pronta para receber orçamentos 5x maiores sem estourar o CPA.',
attachments: [
{
id: 'att-feat-1',
name: 'Planilha_Calculadora_ROAS_e_CAC.xlsx',
type: 'spreadsheet',
url: '#',
size: '1.4 MB',
description: 'Calculadora automática para encontrar o ponto de equilíbrio (breakeven) e margem de lucro por campanha.'
},
{
id: 'att-feat-2',
name: 'Guia_Estrutura_de_Contas_Meta_2026.pdf',
type: 'pdf',
url: '#',
size: '3.8 MB',
description: 'Manual visual com esquemas de públicos abertos, lookalikes e segmentações vantajosas.'
}
]
},
{
id: 'l-feat-2',
title: '02. Laboratório de Criativos: Análise de Hook Rate & Hold Rate',
duration: '35m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'A anatomia dos criativos vencedores. Métricas de retenção nos primeiros 3 segundos e copies que convertem em escala.',
attachments: [
{
id: 'att-feat-3',
name: 'Templates_Criativos_Photoshop_Figma.zip',
type: 'zip',
url: '#',
size: '48.2 MB',
description: 'Pack com 15 layouts de anúncios estáticos e scripts de vídeos curtos prontos para edição.'
},
{
id: 'att-feat-4',
name: 'Checklist_Auditoria_de_Criativos.pdf',
type: 'pdf',
url: '#',
size: '890 KB',
description: 'Checklist de 12 pontos para aprovação de novos criativos antes de subir na conta.'
}
]
},
{
id: 'l-feat-3',
title: '03. Google Ads para E-commerce: Performance Max & Search Alpha',
duration: '40m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Dominando feeds do Google Merchant Center, segmentações avançadas e termos negativos para maximizar a conversão.',
attachments: [
{
id: 'att-feat-5',
name: 'Lista_Palavras_Chave_Negativas_Universal.xlsx',
type: 'spreadsheet',
url: '#',
size: '640 KB',
description: 'Mais de 2.000 palavras-chave negativas essenciais para economizar orçamento no Google Ads.'
}
]
},
{
id: 'l-feat-4',
title: '04. Funil de Vendas de Alta Conversão & Otimização de Checkout',
duration: '38m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Reduzindo o abandono de carrinho e aumentando o ticket médio com upsells, order bumps e automações de CRM.',
attachments: [
{
id: 'att-feat-6',
name: 'Blueprint_Funil_Vendas_Infoproduto_Ecommerce.pdf',
type: 'pdf',
url: '#',
size: '5.1 MB',
description: 'Mapeamento visual do fluxo de tráfego, página de vendas e checkout.'
}
]
},
{
id: 'l-feat-5',
title: '05. Dashboards White-Label & Gestão de Relatórios para Clientes',
duration: '28m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Como apresentar dados e ROI de forma cristalina para clientes sem gastar horas montando relatórios manuais.',
attachments: [
{
id: 'att-feat-7',
name: 'Modelo_Relatorio_Executivo_Mensal.pdf',
type: 'pdf',
url: '#',
size: '2.3 MB',
description: 'Template de relatório executivo que comprova o valor do gestor de tráfego.'
}
]
},
{
id: 'l-feat-6',
title: '06. Plano de Ação: Escalando de R$ 10k para R$ 100k/mês',
duration: '42m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'O passo a passo estratégico para gerenciar grandes volumes de verba sem perder a margem de contribuição.',
attachments: [
{
id: 'att-feat-8',
name: 'Roadmap_Escala_Orcamento_Planilha.xlsx',
type: 'spreadsheet',
url: '#',
size: '1.1 MB',
description: 'Cronograma e matriz de decisão para aumentos graduais de orçamento diário.'
}
]
}
],
tips: [
"Monitore o Blended ROAS diariamente para ter clareza do retorno global sobre o investimento.",
"Criativos representam mais de 70% do sucesso no Meta Ads atual. Teste pelo menos 5 novos ângulos semanais.",
"Nunca aumente o orçamento em mais de 20% a cada 48h em conjuntos ABO para não reiniciar o aprendizado da máquina.",
"Analise os materiais complementares em PDF e Planilhas anexados em cada aula para acelerar sua implementação."
],
attachments: [
{
id: 'att-master-pack',
name: 'Kit_Completo_Materiais_Escala_Compor_HUB.zip',
type: 'zip',
url: '#',
size: '62.4 MB',
description: 'Todos os PDFs, Planilhas, Modelos de Relatório e Scripts do curso compactados em um único arquivo.'
},
{
id: 'att-master-pdf',
name: 'Apostila_Oficial_Gestao_Trafego_Pago_2026.pdf',
type: 'pdf',
url: '#',
size: '12.8 MB',
description: 'Apostila completa de 94 páginas com resumos teóricos e práticos.'
}
]
};
// Realistic library of courses with attached downloadable assets
export const INITIAL_COURSES: Course[] = [
FEATURED_COURSE,
{
id: 'course-meta-ads-pro',
title: 'Meta Ads Pro: Segmentações, Públicos & Pixel CAPI',
category: 'Tráfego Pago & Meta Ads',
thumbnail: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
duration: '5 Aulas • 2h 50m',
description: 'Aprenda a configurar a API de Conversões do Meta, gerenciar eventos prioritários e explorar públicos de alta conversão.',
instructor: 'Lucas Farias',
instructorRole: 'Especialista em Tracking & Meta Ads',
lessons: [
{
id: 'c-meta-1',
title: '01. Configuração do Pixel e API de Conversões (CAPI)',
duration: '32m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Tracking avançado com Google Tag Manager (GTM) e servidor para 100% de precisão nos eventos.',
attachments: [
{
id: 'meta-att-1',
name: 'Container_GTM_Pre_Configurado_Meta_CAPI.json',
type: 'doc',
url: '#',
size: '220 KB',
description: 'Arquivo JSON pronto para importar no Google Tag Manager com todos os eventos essenciais.'
}
]
},
{
id: 'c-meta-2',
title: '02. Criação de Públicos Personalizados e Lookalikes',
duration: '28m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Construindo públicos de compradores, visitantes de carrinho e engajamento no Instagram.',
attachments: [
{
id: 'meta-att-2',
name: 'Guia_Estrategico_Publicos_Meta.pdf',
type: 'pdf',
url: '#',
size: '2.1 MB',
description: 'Matriz de segmentação por temperatura de público (Frio, Morno, Quente).'
}
]
},
{
id: 'c-meta-3',
title: '03. Testes A/B Científicos de Criativos',
duration: '35m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Metodologia para testar manchetes, ganchos e CTAs sem desperdiçar verba.'
},
{
id: 'c-meta-4',
title: '04. Estratégias de Remarketing Dinâmico',
duration: '30m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Recuperando vendas com catálogos de produtos e mensagens personalizadas de urgência.'
},
{
id: 'c-meta-5',
title: '05. Contingência & Aquecimento de Ativos',
duration: '45m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Proteja suas contas de anúncios com uma estrutura de contingência profissional.'
}
],
tips: [
"A API de Conversões (CAPI) recupera em média 25% mais dados de eventos perdidos pelo iOS.",
"Teste criativos sempre com público amplo (Broad) para validar a força do anúncio por si só."
],
attachments: [
{
id: 'meta-att-global-1',
name: 'Checklist_Contingencia_Meta_Ads.pdf',
type: 'pdf',
url: '#',
size: '1.8 MB',
description: 'Passo a passo para blindar seus BMs, perfis e contas de anúncios.'
},
{
id: 'meta-att-global-2',
name: 'Planilha_Teste_AB_Criativos.xlsx',
type: 'spreadsheet',
url: '#',
size: '950 KB',
description: 'Acompanhamento do CTR e CPA por variação de criativo testado.'
}
]
},
{
id: 'course-google-ads-expert',
title: 'Google Ads Expert: Rede de Pesquisa & Performance Max',
category: 'Google Ads & YouTube',
thumbnail: 'https://images.unsplash.com/photo-1572021335469-31706a17aaef?auto=format&fit=crop&w=800&q=80',
duration: '4 Aulas • 2h 15m',
description: 'Domine a intenção de busca no Google. Como estruturar campanhas de Pesquisa de alto índice de qualidade e campanhas PMax focadas em ROAS.',
instructor: 'Renato Silveira',
instructorRole: 'Google Premier Partner',
lessons: [
{
id: 'c-goog-1',
title: '01. Índice de Qualidade & Correspondência de Palavras',
duration: '30m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Como pagar menos por clique e aparecer nas primeiras posições do Google.',
attachments: [
{
id: 'goog-att-1',
name: 'Guia_Indice_Qualidade_10_de_10.pdf',
type: 'pdf',
url: '#',
size: '3.2 MB',
description: 'As 3 regras para alcançar nota 10/10 no índice de qualidade do Google Ads.'
}
]
},
{
id: 'c-goog-2',
title: '02. Configuração Avançada de Performance Max (PMax)',
duration: '42m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Alimentando o algoritmo com sinais de público precisos e exclusões estratégicas.'
},
{
id: 'c-goog-3',
title: '03. Google Shopping & Otimização de Títulos no Feed',
duration: '33m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Como ranquear no topo do Google Shopping reestruturando os títulos dos seus produtos.'
},
{
id: 'c-goog-4',
title: '04. Estratégias de Lances: Maximizar Conversões vs tROAS',
duration: '30m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'O momento exato de transicionar lances manuais para lances automatizados inteligentes.'
}
],
tips: [
"Adicione palavras-chave negativas semanalmente na aba de Termos de Pesquisa.",
"No Google Ads, o CTR do anúncio dita mais de 50% do seu custo por clique real."
],
attachments: [
{
id: 'goog-att-all',
name: 'Kit_Templates_Anuncios_Google_Search.zip',
type: 'zip',
url: '#',
size: '14.5 MB',
description: 'Modelos de textos, títulos e extensões de anúncio de alta taxa de clique.'
}
]
},
{
id: 'course-creative-lab',
title: 'Design & Copywriting de Criativos que Vendem',
category: 'Copywriting & Criativos',
thumbnail: 'https://images.unsplash.com/photo-1542744094-3a31f272c490?auto=format&fit=crop&w=800&q=80',
duration: '4 Aulas • 2h 20m',
description: 'Transforme ideias em criativos de alta conversão. Estrutura de roteiros para UGC, ganchos magnéticos e psicologia de compra.',
instructor: 'Camila Rossi',
instructorRole: 'Diretora de Criação & Copywriter',
lessons: [
{
id: 'c-creat-1',
title: '01. Os 10 Ganchos (Hooks) Mais Poderosos para Vídeos Curtos',
duration: '35m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Como prender a atenção do usuário nos primeiros 3 segundos do Reels e TikTok.',
attachments: [
{
id: 'creat-att-1',
name: '50_Ganchos_Validados_para_Reels_e_TikTok.pdf',
type: 'pdf',
url: '#',
size: '4.7 MB',
description: '50 modelos prontos de frases de impacto para abertura de vídeos de tráfego pago.'
}
]
},
{
id: 'c-creat-2',
title: '02. Roteirização de Anúncios UGC (User Generated Content)',
duration: '40m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Como orientar criadores de conteúdo e clientes para gravarem depoimentos autênticos.'
},
{
id: 'c-creat-3',
title: '03. Anúncios Estáticos: Tipografia, Contraste e Quebra de Padrão',
duration: '32m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Design estratégico no Feed do Instagram para aumentar a taxa de cliques (CTR).'
},
{
id: 'c-creat-4',
title: '04. Framework de Análise de Fadiga de Criativo',
duration: '33m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Como identificar quando um anúncio saturou antes de perder dinheiro.'
}
],
tips: [
"O gancho do anúncio (primeiros 3 segundos) define mais de 80% do CPA final.",
"Criativos simples com estética nativa (parecidos com stories de amigos) costumam converter melhor que vídeos ultra-produzidos."
],
attachments: [
{
id: 'creat-att-all',
name: 'Pack_Figma_Templates_Anuncios_Feed_Stories.zip',
type: 'zip',
url: '#',
size: '32.1 MB',
description: 'Mais de 30 templates editáveis no Figma prontos para exportação.'
}
]
},
{
id: 'course-agency-scale',
title: 'Gestão de Agência & Relatórios para Clientes de Tráfego',
category: 'Gestão & Processos de Agência',
thumbnail: 'https://images.unsplash.com/photo-1552664730-d307ca884978?auto=format&fit=crop&w=800&q=80',
duration: '4 Aulas • 2h 05m',
description: 'Como precificar seus serviços, reter clientes com relatórios claros de ROI e organizar o fluxo de trabalho da sua equipe.',
instructor: 'Mário Morgado',
instructorRole: 'Fundador & Consultor de Performance',
lessons: [
{
id: 'c-ag-1',
title: '01. Modelos de Precificação: Fixo + Variável sobre ROAS',
duration: '28m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Como cobrar comissões sobre resultados sem assumir riscos desnecessários.',
attachments: [
{
id: 'ag-att-1',
name: 'Contrato_Prestacao_Servicos_Trafego_Pago.docx',
type: 'doc',
url: '#',
size: '145 KB',
description: 'Modelo jurídico completo de contrato de prestação de serviços de gestão de tráfego.'
}
]
},
{
id: 'c-ag-2',
title: '02. Onboarding Perfeito de Novos Clientes',
duration: '32m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'O formulário de briefing e alinhamento de expectativas que evita cancelamentos prematuros.'
},
{
id: 'c-ag-3',
title: '03. Reunião Mensal de Resultados: A Apresentação Ideal',
duration: '35m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Mostrando faturamento real e lucro líquido sem afogar o cliente em termos técnicos.'
},
{
id: 'c-ag-4',
title: '04. Delegando Tarefas e Contratando Designers/Copywriters',
duration: '30m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Processos de esteira de produção para gerenciar mais de 20 clientes simultâneos.'
}
],
tips: [
"Clientes não compram cliques ou impressões; eles compram faturamento previsível e tranquilidade.",
"Envie micro-atualizações semanais pelo WhatsApp com o faturamento gerado para manter o cliente seguro."
],
attachments: [
{
id: 'ag-att-pack',
name: 'Kit_Processos_e_Documentos_Agencia.zip',
type: 'zip',
url: '#',
size: '18.3 MB',
description: 'Contratos, briefings de onboarding, planilhas de precificação e apresentações em PowerPoint.'
}
]
},
{
id: 'course-funnel-cro',
title: 'Funis de Vendas, CRO & Otimização de Taxa de Conversão',
category: 'Funis de Vendas & ROI',
thumbnail: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=800&q=80',
duration: '3 Aulas • 1h 45m',
description: 'Dobre o resultado do seu tráfego sem gastar 1 real a mais em anúncios otimizando as páginas de destino e o checkout.',
instructor: 'Lucas Farias',
instructorRole: 'Especialista em CRO & Funis',
lessons: [
{
id: 'c-cro-1',
title: '01. Diagnóstico de Gargalos no Funil de Vendas',
duration: '35m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Onde seus visitantes estão travando: Página, Carrinho ou Checkout?',
attachments: [
{
id: 'cro-att-1',
name: 'Planilha_Diagnostico_Funil_Vendas.xlsx',
type: 'spreadsheet',
url: '#',
size: '820 KB',
description: 'Insira suas métricas e descubra automaticamente onde está a maior perda financeira do seu funil.'
}
]
},
{
id: 'c-cro-2',
title: '02. Otimização de Landing Pages de Alta Conversão',
duration: '40m',
videoUrl: SAMPLE_VIDEO,
isFree: true,
description: 'Estrutura visual, velocidade de carregamento e provas sociais que convertem.'
},
{
id: 'c-cro-3',
title: '03. Recuperação de Vendas via WhatsApp & E-mail',
duration: '30m',
videoUrl: SAMPLE_VIDEO,
isFree: false,
description: 'Sequências de mensagens automáticas que recuperam até 35% dos checkouts abandonados.'
}
],
tips: [
"Uma página que demora mais de 3 segundos para carregar perde em média 40% dos visitantes do anúncio.",
"Order bumps no checkout com ticket baixo aumentam o faturamento total em até 18% imediatamente."
],
attachments: [
{
id: 'cro-att-all',
name: 'Scripts_Recuperacao_Carrinho_WhatsApp.pdf',
type: 'pdf',
url: '#',
size: '1.9 MB',
description: 'Mensagens prontas para copiar e colar na sua automação de recuperação.'
}
]
}
];
export const generateMockSections = (): Section[] => {
return [
{
title: 'Em Destaque • Tráfego & Performance',
variant: 'landscape',
courses: INITIAL_COURSES
},
{
title: 'Cursos com Pacotes de Materiais & Planilhas',
variant: 'landscape',
courses: INITIAL_COURSES.filter(c => c.attachments && c.attachments.length > 0)
},
{
title: 'Formações Recomendadas Compor HUB',
variant: 'portrait',
courses: INITIAL_COURSES
},
{
title: 'Criativos, Copywriting & Design',
variant: 'landscape',
courses: INITIAL_COURSES.filter(c => c.category.includes('Criativos') || c.category.includes('Funis'))
}
];
};

69
context/AuthContext.tsx Normal file
View File

@@ -0,0 +1,69 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { User, UserRole } from '../types';
interface AuthContextType {
user: User | null;
login: (email: string) => boolean;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check localStorage on mount with error handling
const storedUser = localStorage.getItem('educlean_user');
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
if (parsedUser && parsedUser.email) {
setUser(parsedUser);
}
} catch (error) {
console.error("Failed to parse user session, clearing storage:", error);
localStorage.removeItem('educlean_user');
}
}
setIsLoading(false);
}, []);
const login = (email: string): boolean => {
let newUser: User | null = null;
if (email === 'admin@prof.com') {
newUser = { email, name: 'Prof. Anderson', role: 'professor' };
} else if (email === 'aluno@test.com') {
newUser = { email, name: 'Aluno Demo', role: 'student' };
}
if (newUser) {
setUser(newUser);
localStorage.setItem('educlean_user', JSON.stringify(newUser));
return true;
}
return false;
};
const logout = () => {
setUser(null);
localStorage.removeItem('educlean_user');
};
return (
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

124
context/ToastContext.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { CheckCircle2, XCircle, Info, Download, X, FileText, Check } from 'lucide-react';
export type ToastType = 'success' | 'error' | 'info' | 'download';
export interface Toast {
id: string;
message: string;
type: ToastType;
fileName?: string;
fileSize?: string;
}
export interface ToastContextType {
showToast: (message: string, type?: ToastType, fileName?: string, fileSize?: string) => void;
showDownloadToast: (fileName: string, fileSize?: string) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'success', fileName?: string, fileSize?: string) => {
const id = Date.now().toString() + Math.random().toString(36).substring(2, 5);
setToasts((prev) => [...prev, { id, message, type, fileName, fileSize }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const showDownloadToast = useCallback((fileName: string, fileSize?: string) => {
const id = Date.now().toString() + Math.random().toString(36).substring(2, 5);
setToasts((prev) => [...prev, {
id,
message: `O material "${fileName}" foi baixado com sucesso.`,
type: 'download',
fileName,
fileSize
}]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4500);
}, []);
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
};
return (
<ToastContext.Provider value={{ showToast, showDownloadToast }}>
{children}
<div className="fixed bottom-6 right-6 z-[300] flex flex-col gap-3 pointer-events-none">
{toasts.map((toast) => {
if (toast.type === 'download') {
return (
<div
key={toast.id}
className="pointer-events-auto relative overflow-hidden flex items-center gap-3.5 bg-[#0d0e12]/95 backdrop-blur-2xl border border-emerald-500/30 text-white p-4 rounded-2xl shadow-2xl shadow-emerald-500/15 animate-fade-in-up min-w-[320px] max-w-[420px]"
>
{/* Emerald vertical bar */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-emerald-400 to-teal-500"></div>
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center shrink-0 text-emerald-400 shadow-inner">
<Download className="w-5 h-5 animate-bounce" style={{ animationIterationCount: 2 }} />
</div>
<div className="flex-1 min-w-0 pr-1">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] font-extrabold tracking-wider uppercase text-emerald-400 bg-emerald-500/15 px-2 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
Download Concluído
</span>
</div>
<p className="text-sm font-bold text-white truncate leading-tight" title={toast.fileName || toast.message}>
{toast.fileName || toast.message}
</p>
<div className="flex items-center gap-2 text-xs text-slate-400 mt-1">
<span>Salvo no seu dispositivo</span>
{toast.fileSize && (
<>
<span>•</span>
<span className="font-mono text-emerald-400/90 font-medium">{toast.fileSize}</span>
</>
)}
</div>
</div>
<button
onClick={() => removeToast(toast.id)}
className="text-slate-500 hover:text-white p-1 rounded-lg hover:bg-white/10 transition-colors shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
return (
<div
key={toast.id}
className="pointer-events-auto flex items-center gap-3 bg-[#121318]/95 backdrop-blur-2xl border border-white/10 text-white px-5 py-4 rounded-2xl shadow-2xl animate-fade-in-up min-w-[300px]"
>
{toast.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />}
{toast.type === 'error' && <XCircle className="w-5 h-5 text-rose-400 shrink-0" />}
{toast.type === 'info' && <Info className="w-5 h-5 text-sky-400 shrink-0" />}
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button onClick={() => removeToast(toast.id)} className="text-slate-500 hover:text-white shrink-0">
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) throw new Error('useToast must be used within ToastProvider');
return context;
};

84
index.css Normal file
View File

@@ -0,0 +1,84 @@
:root {
/* Typography Tokens */
--font-display: "Manrope", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-body: "Manrope", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Border Radii */
--radius-tag: 980px;
--radius-button: 980px;
--radius-card: 8px;
--radius-image: 8px;
--radius-input: 8px;
/* Shadows */
--shadow-xl: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
/* Layout */
--page-max-width: 1440px;
}
body {
font-family: var(--font-body);
background-color: #000000;
color: #f4f4f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6, .font-display, .font-heading {
font-family: var(--font-display);
}
/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Custom scrollbar for modals & panels */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #09090b;
}
::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 980px;
}
::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
/* Smooth drag cursor */
.cursor-grab {
cursor: grab;
}
.cursor-grabbing {
cursor: grabbing;
user-select: none;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

89
index.html Normal file
View File

@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compor HUB | Cursos & Central de Materiais</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/index.css">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', 'sans-serif'],
display: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif'],
heading: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif'],
body: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif'],
},
fontSize: {
'caption': ['12px', { lineHeight: '1.5', letterSpacing: '-0.01em' }],
'body-sm': ['14px', { lineHeight: '1.5', letterSpacing: '-0.01em' }],
'body': ['16px', { lineHeight: '1.6', letterSpacing: '-0.01em' }],
'subheading': ['20px', { lineHeight: '1.35', letterSpacing: '-0.02em' }],
'heading-sm': ['26px', { lineHeight: '1.3', letterSpacing: '-0.025em' }],
'heading': ['36px', { lineHeight: '1.25', letterSpacing: '-0.03em' }],
'heading-lg': ['44px', { lineHeight: '1.2', letterSpacing: '-0.03em' }],
'display': ['52px', { lineHeight: '1.15', letterSpacing: '-0.035em' }],
},
letterSpacing: {
'caption': '-0.01em',
'body-sm': '-0.01em',
'body': '-0.01em',
'subheading': '-0.02em',
'heading-sm': '-0.025em',
'heading': '-0.03em',
'heading-lg': '-0.03em',
'display': '-0.035em',
},
borderRadius: {
'tag': '980px',
'button': '980px',
'card': '8px',
'image': '8px',
'input': '8px',
'pill': '980px',
},
boxShadow: {
'xl': '0 10px 30px -5px rgba(0, 0, 0, 0.3)',
'glow': '0 0 35px -5px rgba(249, 115, 22, 0.25)',
},
maxWidth: {
'page': '1440px',
},
colors: {
gray: {
850: '#1f1f1f',
900: '#121212',
950: '#0a0a0a',
}
}
}
}
}
</script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.2.0",
"react/": "https://esm.sh/react@18.2.0/",
"react-dom": "https://esm.sh/react-dom@18.2.0",
"react-dom/": "https://esm.sh/react-dom@18.2.0/",
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
"react-router-dom": "https://esm.sh/react-router-dom@6.22.3?external=react,react-dom",
"lucide-react": "https://esm.sh/lucide-react@0.344.0",
"localforage": "https://esm.sh/localforage@1.10.0",
"@google/genai": "https://esm.sh/@google/genai"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

8
metadata.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "Compor HUB",
"description": "Portal de Cursos e Central de Download de Materiais (PDFs, Planilhas, Modelos e Checklists) para Gestão de Tráfego Pago e Performance.",
"requestFramePermissions": [],
"majorCapabilities": [
"MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"
]
}

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "educlean",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "6.22.3",
"lucide-react": "0.344.0",
"localforage": "1.10.0",
"@google/genai": "latest"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

905
pages/ManageCourses.tsx Normal file
View File

@@ -0,0 +1,905 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Plus,
Settings,
Users,
BarChart,
Trash2,
Edit2,
X,
MessageSquare,
Send,
Image as ImageIcon,
Video,
Sparkles,
Loader2,
FileText,
FileSpreadsheet,
FileArchive,
Link as LinkIcon,
Paperclip,
Lightbulb,
ListVideo,
Check,
Lock,
Unlock,
UploadCloud,
Globe,
Download
} from 'lucide-react';
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
import { useToast } from '../context/ToastContext';
import { getCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
import { generateStudentReply } from '../services/ai';
import { CATEGORIES } from '../constants';
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
// 1. Create/Edit Course Modal
const CourseModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSave: (course: Course) => void;
initialData?: Course;
}> = ({ isOpen, onClose, onSave, initialData }) => {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('');
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
const [instructor, setInstructor] = useState('Mário Morgado');
const [instructorRole, setInstructorRole] = useState('Especialista em Performance');
// Lesson Management
const [lessons, setLessons] = useState<Lesson[]>([]);
// New Lesson State
const [newLessonTitle, setNewLessonTitle] = useState('');
const [newLessonDuration, setNewLessonDuration] = useState('');
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
const [videoSourceType, setVideoSourceType] = useState<'upload' | 'link'>('upload');
const [selectedFileName, setSelectedFileName] = useState('');
const lessonFileInputRef = useRef<HTMLInputElement>(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 [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
if (initialData) {
setTitle(initialData.title);
setDescription(initialData.description);
setCategory(initialData.category);
setThumbnailPreview(initialData.thumbnail);
setInstructor(initialData.instructor || 'Mário Morgado');
setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
setTips(initialData.tips || []);
setAttachments(initialData.attachments || []);
setLessons(initialData.lessons || []);
} else {
setTitle('');
setDescription('');
setCategory(CATEGORIES[0]);
setThumbnailPreview('');
setInstructor('Mário Morgado');
setInstructorRole('Especialista em Performance');
setTips([]);
setAttachments([]);
setLessons([]);
}
}
}, [isOpen, initialData]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const objectUrl = URL.createObjectURL(file);
setThumbnailPreview(objectUrl);
}
};
const handleLessonVideoFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const objectUrl = URL.createObjectURL(file);
setNewLessonVideoUrl(objectUrl);
setSelectedFileName(file.name);
if (!newLessonDuration) setNewLessonDuration('15m');
}
};
const handleAddLesson = () => {
if (!newLessonTitle.trim()) {
alert("Digite um título para a aula.");
return;
}
if (!newLessonVideoUrl.trim()) {
alert("Selecione um vídeo ou insira um link.");
return;
}
const newLesson: Lesson = {
id: `lesson-${Date.now()}`,
title: newLessonTitle,
duration: newLessonDuration || '15m',
videoUrl: newLessonVideoUrl,
isFree: newLessonIsFree,
description: ''
};
setLessons([...lessons, newLesson]);
setNewLessonTitle('');
setNewLessonDuration('');
setNewLessonVideoUrl('');
setSelectedFileName('');
setNewLessonIsFree(false);
if (lessonFileInputRef.current) lessonFileInputRef.current.value = '';
};
const removeLesson = (id: string) => {
setLessons(lessons.filter(l => l.id !== id));
};
const toggleLessonAccess = (id: string) => {
setLessons(lessons.map(l => l.id === id ? { ...l, isFree: !l.isFree } : l));
};
const handleAddTip = () => {
if (newTip.trim()) {
setTips([...tips, newTip.trim()]);
setNewTip('');
}
};
const removeTip = (idx: number) => {
setTips(tips.filter((_, i) => i !== idx));
};
const handleAddAttachment = () => {
if (!newAttName.trim()) {
alert("Informe o nome do arquivo para download.");
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()
};
setAttachments([...attachments, att]);
setNewAttName('');
setNewAttSize('');
setNewAttUrl('');
setNewAttDesc('');
};
const removeAttachment = (id: string) => {
setAttachments(attachments.filter(a => a.id !== id));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (lessons.length === 0) {
alert("Adicione pelo menos uma aula à trilha.");
return;
}
setIsSubmitting(true);
const durationStr = `${lessons.length} Aulas`;
onSave({
id: initialData?.id || `course-${Date.now()}`,
title,
description,
category: category || CATEGORIES[0],
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
duration: durationStr,
instructor,
instructorRole,
views: initialData?.views || 0,
progress: initialData?.progress || 0,
lessons: lessons,
tips: tips,
attachments: attachments
});
setIsSubmitting(false);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={onClose} />
<div className="relative w-full max-w-6xl bg-[#141416] rounded-3xl border border-white/10 p-6 md:p-8 shadow-2xl animate-fade-in-up max-h-[92vh] overflow-y-auto flex flex-col lg:flex-row gap-8">
{/* Left Column: Course Details, Tips & Downloads */}
<div className="w-full lg:w-5/12 space-y-6">
<div className="flex justify-between items-center lg:hidden mb-2">
<h2 className="text-xl font-bold text-white">{initialData ? 'Editar Curso' : 'Criar Curso'}</h2>
<button onClick={onClose}><X className="text-white" /></button>
</div>
{/* Thumbnail */}
<div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
<div
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group"
onClick={() => fileInputRef.current?.click()}
>
{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>
</div>
</>
) : (
<div className="text-center p-6">
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
<p className="text-gray-300 text-xs font-medium">Clique para selecionar imagem</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Título do Curso</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Masterclass: Escala de Tráfego Pago"
className="w-full bg-black/50 text-white text-base font-bold border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Categoria</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
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"
>
{CATEGORIES.map(cat => (
<option key={cat} value={cat} className="bg-zinc-900">{cat}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label>
<input
type="text"
value={instructor}
onChange={(e) => setInstructor(e.target.value)}
placeholder="Nome do Instrutor"
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"
/>
</div>
</div>
<div>
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Descrição do Curso</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="O que os alunos vão aprender e dominar neste curso?"
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-4 py-2.5 text-xs focus:outline-none focus:border-orange-500/50 resize-none leading-relaxed"
/>
</div>
</div>
{/* Downloadable Materials Section */}
<div className="bg-black/40 p-4 rounded-2xl border border-white/10 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-bold text-orange-400 uppercase tracking-wider flex items-center gap-1.5">
<Download className="w-4 h-4" />
Materiais Complementares ({attachments.length})
</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="Nome do arquivo (ex: Planilha_ROAS.xlsx)"
className="col-span-2 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500/50"
/>
<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="Tamanho (ex: 2.4 MB)"
className="w-28 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
/>
<input
type="text"
value={newAttDesc}
onChange={(e) => setNewAttDesc(e.target.value)}
placeholder="Breve descrição do material..."
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
/>
<button
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"
>
Adicionar
</button>
</div>
</div>
{/* List of Attachments */}
<div className="space-y-1.5 max-h-36 overflow-y-auto pr-1 pt-1">
{attachments.map((att) => (
<div key={att.id} className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5 text-xs">
<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>
</div>
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="text-gray-500 hover:text-red-400 p-1"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
</div>
{/* Right Column: Lessons Builder */}
<div className="flex-1 flex flex-col justify-between">
<div>
<div className="hidden lg:flex justify-between items-center mb-6">
<h2 className="text-2xl font-extrabold text-white">
{initialData ? 'Gerenciar Trilha & Aulas' : 'Estruturar Grade de Aulas'}
</h2>
<button onClick={onClose} className="p-2 bg-white/5 rounded-full hover:bg-white/20 transition-colors">
<X className="w-5 h-5 text-white" />
</button>
</div>
<div className="bg-black/50 rounded-2xl border border-white/10 overflow-hidden mb-6">
<div className="p-4 border-b border-white/5 bg-zinc-900/50 flex items-center justify-between">
<div className="flex items-center gap-2">
<ListVideo className="w-4 h-4 text-orange-400" />
<h3 className="font-bold text-white text-sm">Grade do Curso</h3>
</div>
<span className="bg-orange-500/10 text-orange-400 border border-orange-500/20 px-3 py-0.5 rounded-full text-xs font-bold">
{lessons.length} Aulas
</span>
</div>
{/* Lesson List */}
<div className="overflow-y-auto p-4 space-y-2.5 max-h-[300px]">
{lessons.length === 0 && (
<div className="flex flex-col items-center justify-center py-10 text-gray-500 space-y-2">
<ListVideo className="w-8 h-8 opacity-20 text-orange-400" />
<p className="text-xs">Nenhuma aula cadastrada ainda. Adicione abaixo.</p>
</div>
)}
{lessons.map((lesson, index) => (
<div key={lesson.id} className="flex items-center gap-3 bg-white/[0.04] p-3 rounded-xl border border-white/5 group hover:border-orange-500/30 transition-all">
<div className="flex items-center justify-center w-6 h-6 rounded-lg bg-white/10 text-gray-400 font-mono text-xs font-bold">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-white font-medium text-xs md:text-sm truncate">{lesson.title}</h4>
<div className="flex items-center gap-2 text-[11px] text-gray-500 mt-0.5">
<span className="flex items-center gap-1"><Video className="w-3 h-3 text-orange-400/70"/> {lesson.duration}</span>
{lesson.isFree ? (
<span className="text-emerald-400 font-bold bg-emerald-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">GRÁTIS</span>
) : (
<span className="text-purple-400 font-bold bg-purple-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">EXCLUSIVA</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => toggleLessonAccess(lesson.id)}
className={`p-1.5 rounded-lg transition-colors ${lesson.isFree ? 'text-emerald-400 hover:bg-emerald-500/10' : 'text-purple-400 hover:bg-purple-500/10'}`}
title={lesson.isFree ? "Tornar Exclusiva para Membros" : "Tornar Grátis"}
>
{lesson.isFree ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
</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"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
{/* Add Lesson Form Box */}
<div className="p-4 bg-zinc-950 border-t border-white/5 space-y-3">
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5 text-orange-400" />
Cadastrar Nova Aula
</h4>
<div className="flex gap-2">
<input
type="text"
value={newLessonTitle}
onChange={(e) => setNewLessonTitle(e.target.value)}
placeholder="Título da Aula (ex: 01. Configuração 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="Duração (ex: 25m)"
className="w-28 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
/>
</div>
{/* Video Source Tabs */}
<div className="flex gap-2 items-center">
<div className="flex bg-white/5 p-0.5 rounded-lg border border-white/5">
<button
type="button"
onClick={() => setVideoSourceType('upload')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'upload' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
>
Vídeo MP4
</button>
<button
type="button"
onClick={() => setVideoSourceType('link')}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'link' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
>
URL Externa
</button>
</div>
<div className="flex-1">
{videoSourceType === 'upload' ? (
<div
onClick={() => lessonFileInputRef.current?.click()}
className="border border-dashed border-white/20 hover:border-orange-500/50 rounded-xl py-2 px-3 text-center cursor-pointer transition-all bg-white/[0.02]"
>
<input
ref={lessonFileInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={handleLessonVideoFileSelect}
/>
<span className="text-xs text-gray-300 truncate block">
{selectedFileName ? `Arquivo: ${selectedFileName}` : 'Selecionar arquivo de vídeo'}
</span>
</div>
) : (
<input
type="text"
value={newLessonVideoUrl}
onChange={(e) => setNewLessonVideoUrl(e.target.value)}
placeholder="https://commondatastorage.googleapis.com/...mp4"
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
/>
)}
</div>
<button
type="button"
onClick={handleAddLesson}
className="bg-white hover:bg-orange-500 hover:text-white text-black font-bold px-4 py-2 rounded-xl text-xs transition-all shrink-0"
>
Adicionar Aula
</button>
</div>
</div>
</div>
</div>
{/* Submit Button */}
<div className="pt-4 border-t border-white/5">
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="w-full bg-orange-500 hover:bg-orange-600 text-white font-extrabold text-base py-4 rounded-2xl active:scale-95 transition-all shadow-lg shadow-orange-500/25"
>
{isSubmitting ? 'Salvando...' : (initialData ? 'Salvar Alterações no Curso' : 'Publicar Curso & Materiais')}
</button>
</div>
</div>
</div>
</div>
);
};
export const ManageCourses: React.FC = () => {
const [courses, setCourses] = useState<Course[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'courses' | 'comments'>('courses');
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [replyText, setReplyText] = useState('');
const [isGeneratingReply, setIsGeneratingReply] = useState(false);
const { showToast } = useToast();
const loadData = async () => {
setLoading(true);
const data = await getCourses();
setCourses(data);
// Load all comments
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = [];
for (const c of data) {
const courseComments = await getComments(c.id);
courseComments.forEach(comm => {
commentsAccumulator.push({ comment: comm, courseTitle: c.title });
});
}
setAllComments(commentsAccumulator);
setLoading(false);
};
useEffect(() => {
loadData();
}, []);
const handleCreate = () => {
setEditingCourse(undefined);
setIsModalOpen(true);
};
const handleEdit = (course: Course) => {
setEditingCourse(course);
setIsModalOpen(true);
};
const handleDelete = async (id: string) => {
if (window.confirm('Tem certeza que deseja excluir este curso?')) {
await deleteCourse(id);
showToast('Curso removido com sucesso!', 'success');
loadData();
}
};
const handleSave = async (course: Course) => {
await saveCourse(course);
showToast('Curso e materiais salvos com sucesso!', 'success');
loadData();
};
const handleSendReply = async (commentId: string) => {
if (!replyText.trim()) return;
const target = allComments.find(c => c.comment.id === commentId);
if (target) {
const updatedComment: Comment = {
...target.comment,
reply: replyText,
replyDate: new Date().toLocaleDateString('pt-BR')
};
await saveComment(updatedComment);
showToast('Resposta enviada com sucesso!', 'success');
setReplyingTo(null);
setReplyText('');
loadData();
}
};
const handleGenerateAIReply = async (commentText: string, courseTitle: string) => {
setIsGeneratingReply(true);
try {
const aiReply = await generateStudentReply(commentText, courseTitle);
setReplyText(aiReply);
} catch (e) {
showToast('Erro ao gerar resposta com IA.', 'error');
} finally {
setIsGeneratingReply(false);
}
};
// Metrics
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0);
const totalMaterials = courses.reduce((acc, c) => {
const globalCount = c.attachments?.length || 0;
const lessonCount = c.lessons.reduce((lAcc, l) => lAcc + (l.attachments?.length || 0), 0);
return acc + globalCount + lessonCount;
}, 0);
return (
<div className="min-h-screen bg-[#09090b] text-white pt-24 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-8 border-b border-white/10">
<div>
<div className="flex items-center gap-2 text-caption font-semibold uppercase tracking-caption text-orange-400 mb-2">
<span>Compor HUB</span>
<span>•</span>
<span>Painel do Instrutor</span>
</div>
<h1 className="font-display text-subheading sm:text-heading-sm md:text-heading font-bold tracking-heading leading-heading">
Gestão de Cursos & Materiais
</h1>
<p className="text-body-sm font-normal text-gray-400 mt-1 leading-body-sm tracking-body-sm">
Cadastre videoaulas, organize trilhas e disponibilize arquivos de apoio para download.
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleCreate}
className="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm px-6 py-3 rounded-[980px] shadow-xl shadow-orange-500/20 transition-all active:scale-95"
>
<Plus className="w-4 h-4" />
<span>Novo Curso</span>
</button>
</div>
</div>
{/* Metrics Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 my-8">
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Cursos</span>
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{courses.length}</p>
</div>
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Aulas</span>
<p className="font-display text-subheading md:text-heading-sm font-bold text-orange-400 mt-1">{totalLessons}</p>
</div>
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Materiais para Download</span>
<p className="font-display text-subheading md:text-heading-sm font-bold text-emerald-400 mt-1">{totalMaterials}</p>
</div>
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Visualizações Acumuladas</span>
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalViews.toLocaleString('pt-BR')}</p>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex gap-4 border-b border-white/10 mb-8">
<button
onClick={() => setActiveTab('courses')}
className={`pb-3 text-body-sm font-semibold tracking-body-sm transition-all border-b-2 ${
activeTab === 'courses'
? 'border-orange-500 text-white'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
Cursos Cadastrados ({courses.length})
</button>
<button
onClick={() => setActiveTab('comments')}
className={`pb-3 text-body-sm font-semibold tracking-body-sm transition-all border-b-2 flex items-center gap-2 ${
activeTab === 'comments'
? 'border-orange-500 text-white'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
<MessageSquare className="w-4 h-4" />
Dúvidas dos Alunos ({allComments.length})
</button>
</div>
{/* Tab: Courses List */}
{activeTab === 'courses' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => {
const courseAttachments = (course.attachments?.length || 0) + course.lessons.reduce((acc, l) => acc + (l.attachments?.length || 0), 0);
return (
<div
key={course.id}
className="bg-zinc-900/80 border border-white/10 rounded-[8px] overflow-hidden flex flex-col justify-between hover:border-orange-500/40 transition-all duration-300 group shadow-xl"
>
<div>
{/* Thumbnail */}
<div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]">
<img
src={course.thumbnail}
alt={course.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<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>
</div>
{/* Body */}
<div className="p-5">
<h3 className="font-display font-bold text-subheading text-white mb-2 line-clamp-1 group-hover:text-orange-400 transition-colors tracking-subheading leading-subheading">
{course.title}
</h3>
<p className="text-caption text-gray-400 line-clamp-2 leading-caption tracking-caption font-normal mb-4">
{course.description}
</p>
<div className="flex items-center gap-3 text-caption text-gray-400 pt-3 border-t border-white/5 font-semibold tracking-caption">
<span className="flex items-center gap-1 text-gray-300 font-semibold">
<Video className="w-3.5 h-3.5 text-orange-400" />
{course.lessons.length} Aulas
</span>
<span>•</span>
<span className="flex items-center gap-1 text-gray-300 font-semibold">
<Download className="w-3.5 h-3.5 text-emerald-400" />
{courseAttachments} Materiais
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="p-4 bg-black/40 border-t border-white/5 flex items-center justify-between gap-2">
<button
onClick={() => handleEdit(course)}
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-[980px] bg-white/5 hover:bg-orange-500 hover:text-white text-body-sm font-semibold tracking-body-sm transition-colors text-gray-200"
>
<Edit2 className="w-3.5 h-3.5" />
Editar Conteúdo
</button>
<button
onClick={() => handleDelete(course.id)}
className="p-2.5 rounded-[980px] bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
title="Excluir Curso"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)}
{/* Tab: Comments / Questions */}
{activeTab === 'comments' && (
<div className="space-y-4 max-w-4xl">
{allComments.length === 0 ? (
<div className="text-center py-20 text-gray-500">
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-30 text-orange-400" />
<p className="text-body-sm font-normal text-gray-400">Nenhuma dúvida enviada por alunos até o momento.</p>
</div>
) : (
allComments.map(({ comment, courseTitle }) => (
<div key={comment.id} className="p-6 rounded-[8px] bg-zinc-900/60 border border-white/10 space-y-4 shadow-xl">
<div className="flex items-center justify-between">
<div>
<span className="text-caption text-orange-400 font-semibold uppercase tracking-caption">{courseTitle}</span>
<h4 className="font-semibold text-white text-body-sm tracking-body-sm">{comment.user}</h4>
</div>
<span className="text-caption text-gray-500 font-mono">{comment.date}</span>
</div>
<p className="text-body-sm text-gray-300 bg-black/30 p-3.5 rounded-[8px] border border-white/5 leading-body-sm tracking-body-sm font-normal">
{comment.text}
</p>
{comment.reply ? (
<div className="pl-4 border-l-2 border-orange-500 bg-white/[0.02] p-3 rounded-r-[8px]">
<span className="text-caption font-semibold text-orange-400 uppercase tracking-caption">Sua Resposta ({comment.replyDate}):</span>
<p className="text-body-sm text-gray-200 mt-1 leading-body-sm">{comment.reply}</p>
</div>
) : (
<div>
{replyingTo === comment.id ? (
<div className="space-y-3 pt-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Escreva a resposta para o aluno..."
className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none"
rows={3}
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleGenerateAIReply(comment.text, courseTitle)}
disabled={isGeneratingReply}
className="inline-flex items-center gap-1.5 text-body-sm text-orange-400 hover:text-orange-300 font-semibold"
>
<Sparkles className="w-3.5 h-3.5" />
{isGeneratingReply ? 'Gerando com IA...' : 'Sugerir Resposta com IA'}
</button>
<div className="flex gap-2">
<button
onClick={() => { setReplyingTo(null); setReplyText(''); }}
className="px-3.5 py-2 rounded-[980px] text-body-sm text-gray-400 hover:text-white font-semibold"
>
Cancelar
</button>
<button
onClick={() => handleSendReply(comment.id)}
className="px-5 py-2 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm shadow-xl"
>
Enviar Resposta
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => setReplyingTo(comment.id)}
className="text-body-sm font-semibold text-orange-400 hover:text-orange-300 tracking-body-sm"
>
Responder Dúvida &rarr;
</button>
)}
</div>
)}
</div>
))
)}
</div>
)}
{/* Create/Edit Modal */}
<CourseModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSave}
initialData={editingCourse}
/>
</div>
);
};

948
pages/MaterialsPage.tsx Normal file
View File

@@ -0,0 +1,948 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Folder,
FileSpreadsheet,
FileText,
FileArchive,
FileCode,
Link as LinkIcon,
Search,
Download,
ChevronRight,
ArrowLeft,
Sparkles,
Copy,
BookOpen,
Undo2,
Eye,
LayoutGrid,
List,
HardDrive,
X,
Play
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Course, Attachment, AttachmentType } from '../types';
import { getCourses } from '../services/db';
import { useToast } from '../context/ToastContext';
interface MaterialsPageProps {
onPlayCourse: (course: Course) => void;
}
interface MaterialFileItem {
id: string;
name: string;
type: string;
formatType: AttachmentType;
size: string;
modified: string;
course: Course;
lessonTitle?: string;
description?: string;
url: string;
}
export const MaterialsPage: React.FC<MaterialsPageProps> = ({ onPlayCourse }) => {
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
// Navigation & View Mode State
const [activeFolder, setActiveFolder] = useState<string>('downloads');
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
const [selectedItem, setSelectedItem] = useState<MaterialFileItem | null>(null);
const [previewItem, setPreviewItem] = useState<MaterialFileItem | null>(null);
// Filter Chip State (All, XLSX, PDF, ZIP, DOC)
const [typeFilter, setTypeFilter] = useState<string>('all');
// Table Sort State
const [sortBy, setSortBy] = useState<'name' | 'type' | 'size' | 'modified'>('name');
const [sortAsc, setSortAsc] = useState<boolean>(true);
// Context Menu State
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
item: MaterialFileItem | null;
}>({ visible: false, x: 0, y: 0, item: null });
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const navigate = useNavigate();
const { showToast, showDownloadToast } = useToast();
const contextMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
window.scrollTo(0, 0);
getCourses().then(data => {
setCourses(data);
setLoading(false);
});
const handleClickOutside = (e: MouseEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(prev => ({ ...prev, visible: false }));
}
};
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
// Format type helper
const getFileExtension = (att: Attachment) => {
if (att.type === 'spreadsheet') return 'XLSX';
if (att.type === 'pdf') return 'PDF';
if (att.type === 'zip') return 'ZIP';
if (att.type === 'doc') return 'DOC';
if (att.type === 'link') return 'JSON';
return 'TXT';
};
// Compile file list
const allFiles: MaterialFileItem[] = useMemo(() => {
const list: MaterialFileItem[] = [];
courses.forEach((course, cIdx) => {
if (course.attachments && course.attachments.length > 0) {
course.attachments.forEach((att, aIdx) => {
const fileId = `f-c-${course.id}-${att.id || aIdx}`;
list.push({
id: fileId,
name: att.name,
type: getFileExtension(att),
formatType: att.type,
size: att.size || `${(aIdx + 1) * 14 + 120} KB`,
modified: `2026. 08. 1${(aIdx % 8) + 1}`,
course,
description: att.description,
url: att.url || '#'
});
});
}
if (course.lessons) {
course.lessons.forEach((lesson, lIdx) => {
if (lesson.attachments && lesson.attachments.length > 0) {
lesson.attachments.forEach((att, laIdx) => {
const fileId = `f-l-${course.id}-${lesson.id}-${att.id || laIdx}`;
list.push({
id: fileId,
name: att.name,
type: getFileExtension(att),
formatType: att.type,
size: att.size || `${(lIdx + 2) * 22 + 210} KB`,
modified: `2026. 08. 1${(lIdx % 9) + 1}`,
course,
lessonTitle: lesson.title,
description: att.description,
url: att.url || '#'
});
});
}
});
}
});
return list;
}, [courses]);
// Set default selected item if none selected
useEffect(() => {
if (allFiles.length > 0 && !selectedItem) {
setSelectedItem(allFiles[0]);
}
}, [allFiles, selectedItem]);
const categories = useMemo(() => {
return Array.from(new Set(courses.map(c => c.category)));
}, [courses]);
// Filtered and Sorted Files
const displayedFiles = useMemo(() => {
return allFiles.filter(file => {
// Type Chip filter
if (typeFilter !== 'all') {
if (typeFilter === 'xlsx' && file.type !== 'XLSX') return false;
if (typeFilter === 'pdf' && file.type !== 'PDF') return false;
if (typeFilter === 'zip' && file.type !== 'ZIP') return false;
if (typeFilter === 'doc' && file.type !== 'DOC' && file.type !== 'TXT') return false;
}
// Search Query
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase();
const matchesName = file.name.toLowerCase().includes(query);
const matchesType = file.type.toLowerCase().includes(query);
const matchesCourse = file.course.title.toLowerCase().includes(query);
const matchesDesc = file.description?.toLowerCase().includes(query);
if (!matchesName && !matchesType && !matchesCourse && !matchesDesc) {
return false;
}
}
// Folder navigation filter
if (activeFolder === 'downloads' || activeFolder === 'my-files' || activeFolder === 'recent') {
return true;
}
if (activeFolder === 'zip') {
return file.type === 'ZIP';
}
if (activeFolder === 'pdf') {
return file.type === 'PDF';
}
if (activeFolder === 'xlsx') {
return file.type === 'XLSX';
}
if (categories.includes(activeFolder)) {
return file.course.category === activeFolder;
}
return true;
}).sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
if (sortBy === 'type') cmp = a.type.localeCompare(b.type);
if (sortBy === 'size') cmp = a.size.localeCompare(b.size);
if (sortBy === 'modified') cmp = a.modified.localeCompare(b.modified);
return sortAsc ? cmp : -cmp;
});
}, [allFiles, searchTerm, activeFolder, typeFilter, sortBy, sortAsc, categories]);
const handleSort = (column: 'name' | 'type' | 'size' | 'modified') => {
if (sortBy === column) {
setSortAsc(!sortAsc);
} else {
setSortBy(column);
setSortAsc(true);
}
};
const handleDownload = (item: MaterialFileItem) => {
setDownloadingId(item.id);
setContextMenu(prev => ({ ...prev, visible: false }));
setTimeout(() => {
setDownloadingId(null);
showDownloadToast(item.name, item.size);
const link = document.createElement('a');
link.href = item.url;
link.download = item.name;
link.target = '_blank';
link.rel = 'noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, 400);
};
const handleContextMenu = (e: React.MouseEvent, item: MaterialFileItem) => {
e.preventDefault();
e.stopPropagation();
setSelectedItem(item);
setContextMenu({
visible: true,
x: Math.min(e.clientX, window.innerWidth - 240),
y: Math.min(e.clientY, window.innerHeight - 280),
item
});
};
// Color Coded File Icon Component with Dark Theme Styling
const renderFileIcon = (type: string, sizeClass: string = "w-5 h-5") => {
switch (type.toUpperCase()) {
case 'XLSX':
case 'SPREADSHEET':
return <FileSpreadsheet className={`${sizeClass} text-emerald-400 shrink-0`} />;
case 'PDF':
return <FileText className={`${sizeClass} text-rose-400 shrink-0`} />;
case 'ZIP':
case 'RAR':
return <FileArchive className={`${sizeClass} text-amber-400 shrink-0`} />;
case 'DOC':
case 'MD':
case 'TXT':
return <FileCode className={`${sizeClass} text-blue-400 shrink-0`} />;
default:
return <LinkIcon className={`${sizeClass} text-orange-400 shrink-0`} />;
}
};
return (
<div className="min-h-screen bg-black text-white selection:bg-orange-500/30 selection:text-white pt-24 pb-20 font-sans">
{/* Ambient background glow matching Home */}
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-[700px] h-[400px] bg-orange-500/5 rounded-full blur-[140px]" />
<div className="absolute top-1/3 -right-20 w-[450px] h-[450px] bg-amber-500/5 rounded-full blur-[160px]" />
</div>
<div className="relative z-10 max-w-[1440px] mx-auto px-6 md:px-12 space-y-8">
{/* Breadcrumb + Header Hero */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-white/10">
<div className="space-y-3">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
<button
onClick={() => navigate('/')}
className="hover:text-orange-400 transition-colors flex items-center gap-1.5"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Início</span>
</button>
<span className="text-gray-600">/</span>
<span className="text-orange-400">Central de Recursos</span>
</div>
{/* Title */}
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white tracking-tight leading-tight">
Biblioteca de <span className="text-orange-500">Materiais</span>
</h1>
<p className="text-sm sm:text-base text-gray-300 max-w-2xl font-normal leading-relaxed">
Explore e faça o download de todas as planilhas de ROI, modelos de criativos, relatórios e arquivos complementares das aulas em um ambiente integrado.
</p>
</div>
{/* Quick Search & Switcher Controls */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
{/* Search Input */}
<div className="relative min-w-[260px] sm:min-w-[320px]">
<Search className="w-4 h-4 text-gray-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Pesquisar material, planilha ou aula..."
className="w-full bg-zinc-900/90 rounded-[980px] pl-11 pr-10 py-2.5 text-sm text-white placeholder:text-gray-400 font-normal focus:outline-none focus:ring-2 focus:ring-orange-500/30 transition-all"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* View Switcher: Grid vs List */}
<div className="flex items-center bg-zinc-900/90 p-1 rounded-[980px] shrink-0 self-end sm:self-auto">
<button
onClick={() => setViewMode('grid')}
className={`flex items-center gap-1.5 px-4 py-2 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all ${
viewMode === 'grid'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
: 'text-gray-400 hover:text-white'
}`}
title="Visualização em Grade de Cards"
>
<LayoutGrid className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Grade</span>
</button>
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-4 py-2 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all ${
viewMode === 'list'
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
: 'text-gray-400 hover:text-white'
}`}
title="Visualização em Lista de Tabela"
>
<List className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Lista</span>
</button>
</div>
</div>
</div>
{/* Main Content Explorer Area */}
<div className="flex flex-col lg:flex-row items-start gap-6">
{/* Left Sidebar: Drive Structure */}
<aside className="w-full lg:w-64 bg-zinc-950/80 rounded-2xl p-4 shadow-xl shrink-0 select-none">
<div className="flex items-center gap-2.5 px-3 py-2 text-xs font-bold uppercase tracking-wider text-orange-400 pb-3 mb-2">
<HardDrive className="w-4 h-4" />
<span>Navegação do Drive</span>
</div>
<div className="space-y-6 text-sm font-medium">
{/* Primary Folder */}
<div className="space-y-1">
<button
onClick={() => { setActiveFolder('downloads'); setTypeFilter('all'); setSelectedItem(null); }}
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all ${
activeFolder === 'downloads' && typeFilter === 'all'
? 'bg-orange-500/20 text-orange-400 font-bold'
: 'text-gray-300 hover:bg-white/5 hover:text-white'
}`}
>
<div className="flex items-center gap-3">
<HardDrive className="w-4 h-4 text-orange-400" />
<span>Todos os Arquivos</span>
</div>
<span className="text-xs font-mono px-2 py-0.5 rounded-[980px] bg-white/10 text-gray-300 font-semibold">
{allFiles.length}
</span>
</button>
</div>
{/* Trilhas & Categorias */}
<div>
<div className="text-xs font-bold uppercase tracking-wider text-gray-400 px-3 pb-2 flex items-center justify-between">
<span>Trilhas de Cursos</span>
</div>
<div className="space-y-1">
{categories.map(cat => {
const count = allFiles.filter(f => f.course.category === cat).length;
const isActive = activeFolder === cat;
return (
<button
key={cat}
onClick={() => { setActiveFolder(cat); setTypeFilter('all'); setSelectedItem(null); }}
className={`w-full flex items-center justify-between px-3.5 py-2 rounded-xl text-xs transition-all ${
isActive
? 'bg-orange-500/20 text-orange-400 font-bold'
: 'text-gray-400 hover:bg-white/5 hover:text-white'
}`}
>
<div className="flex items-center gap-2.5 min-w-0 pr-1">
<Folder className={`w-3.5 h-3.5 shrink-0 ${isActive ? 'text-orange-400 fill-orange-400/20' : 'text-gray-500'}`} />
<span className="truncate">{cat}</span>
</div>
<span className="text-xs text-gray-500 font-mono">{count}</span>
</button>
);
})}
</div>
</div>
{/* Formatos Rápidos */}
<div>
<div className="text-xs font-bold uppercase tracking-wider text-gray-400 px-3 pb-2">
Formatos de Download
</div>
<div className="space-y-1 text-xs">
<button
onClick={() => { setTypeFilter('xlsx'); setActiveFolder('downloads'); setSelectedItem(null); }}
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
typeFilter === 'xlsx' ? 'bg-emerald-500/20 text-emerald-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
}`}
>
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
<span>Planilhas Excel (.xlsx)</span>
</button>
<button
onClick={() => { setTypeFilter('pdf'); setActiveFolder('downloads'); setSelectedItem(null); }}
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
typeFilter === 'pdf' ? 'bg-rose-500/20 text-rose-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
}`}
>
<FileText className="w-3.5 h-3.5 text-rose-400" />
<span>Documentos PDF (.pdf)</span>
</button>
<button
onClick={() => { setTypeFilter('zip'); setActiveFolder('downloads'); setSelectedItem(null); }}
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
typeFilter === 'zip' ? 'bg-amber-500/20 text-amber-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
}`}
>
<FileArchive className="w-3.5 h-3.5 text-amber-400" />
<span>Pacotes Compactados (.zip)</span>
</button>
</div>
</div>
</div>
{/* Storage Gauge */}
<div className="mt-8 pt-4 border-t border-white/5 text-xs">
<div className="flex items-center gap-2 text-gray-300 font-semibold mb-2">
<HardDrive className="w-4 h-4 text-orange-400" />
<span>Espaço de Arquivos</span>
</div>
<div className="w-full h-1.5 bg-white/10 rounded-[980px] overflow-hidden mb-2">
<div className="h-full w-1/3 bg-gradient-to-r from-orange-500 to-amber-500 rounded-[980px]"></div>
</div>
<p className="text-[11px] text-gray-400 leading-tight">
Acesso Ilimitado de Alta Velocidade para Alunos Compor HUB.
</p>
</div>
</aside>
{/* Main Files Area */}
<main className="flex-1 min-w-0 bg-zinc-950/80 rounded-2xl shadow-2xl overflow-hidden flex flex-col min-h-[700px]">
{/* Explorer Header */}
<div className="p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-xl sm:text-2xl font-bold text-white capitalize flex items-center gap-2">
<span>
{activeFolder === 'downloads'
? (typeFilter !== 'all' ? `Filtro: ${typeFilter.toUpperCase()}` : 'Todos os Materiais')
: activeFolder}
</span>
</h2>
<span className="text-xs font-mono px-3 py-1 rounded-[980px] bg-white/10 text-orange-400 font-bold">
{displayedFiles.length} {displayedFiles.length === 1 ? 'item' : 'itens'}
</span>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{(searchTerm || typeFilter !== 'all' || activeFolder !== 'downloads') && (
<button
onClick={() => {
setSearchTerm('');
setTypeFilter('all');
setActiveFolder('downloads');
showToast('Filtros redefinidos.', 'info');
}}
className="px-4 py-2 rounded-[980px] text-xs font-semibold bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white flex items-center gap-1.5 transition-colors"
>
<Undo2 className="w-3.5 h-3.5 text-orange-400" />
<span>Limpar Filtros</span>
</button>
)}
<button
onClick={() => {
showToast('Materiais organizados por relevância.', 'success');
setSortBy('type');
setSortAsc(true);
}}
className="px-4 py-2 rounded-[980px] text-xs font-bold bg-orange-500 hover:bg-orange-600 active:scale-95 text-white flex items-center gap-1.5 shadow-lg shadow-orange-500/25 transition-all"
>
<Sparkles className="w-3.5 h-3.5" />
<span>Ordenar por Tipo</span>
</button>
</div>
</div>
{/* Files Content: Grid or List */}
<div className="flex-1 overflow-y-auto">
{/* EMPTY STATE */}
{displayedFiles.length === 0 ? (
<div className="py-32 text-center text-gray-400 px-6">
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-4 text-orange-400">
<Folder className="w-8 h-8 opacity-60" />
</div>
<h3 className="text-lg font-bold text-white mb-1">Nenhum material encontrado</h3>
<p className="text-sm text-gray-400 max-w-md mx-auto mb-6">
Não encontramos arquivos correspondentes aos filtros selecionados. Tente ajustar o termo de pesquisa ou limpar os filtros.
</p>
<button
onClick={() => {
setSearchTerm('');
setTypeFilter('all');
setActiveFolder('downloads');
}}
className="px-6 py-2.5 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white font-bold text-xs uppercase tracking-wider transition-all shadow-lg shadow-orange-500/25"
>
Ver Todos os Arquivos
</button>
</div>
) : viewMode === 'grid' ? (
/* GRID VIEW */
<div className="p-6 space-y-8">
{/* Category Fast-Folders (When in All or Category View) */}
{activeFolder === 'downloads' && typeFilter === 'all' && !searchTerm && (
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3.5 flex items-center gap-2">
<Folder className="w-4 h-4 text-orange-400" />
<span>Pastas por Módulo</span>
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3.5">
{categories.map(cat => {
const count = allFiles.filter(f => f.course.category === cat).length;
return (
<div
key={cat}
onClick={() => setActiveFolder(cat)}
className="bg-zinc-900/60 hover:bg-zinc-900 rounded-xl p-4 cursor-pointer hover:shadow-xl transition-all flex items-center justify-between group"
>
<div className="flex items-center gap-3 min-w-0 pr-2">
<div className="w-10 h-10 rounded-xl bg-orange-500/15 flex items-center justify-center text-orange-400 shrink-0 group-hover:scale-110 transition-transform">
<Folder className="w-5 h-5 fill-orange-500/20" />
</div>
<div className="min-w-0">
<h4 className="text-xs font-bold text-white truncate group-hover:text-orange-400 transition-colors">
{cat}
</h4>
<p className="text-[11px] text-gray-400 font-mono mt-0.5">{count} arquivos</p>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-orange-400 group-hover:translate-x-1 transition-all shrink-0" />
</div>
);
})}
</div>
</div>
)}
{/* Material Cards Grid */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3.5 flex items-center gap-2">
<HardDrive className="w-4 h-4 text-orange-400" />
<span>Arquivos para Download</span>
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{displayedFiles.map(file => {
const isSelected = selectedItem?.id === file.id;
const isDownloading = downloadingId === file.id;
return (
<div
key={file.id}
onClick={() => setSelectedItem(file)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`bg-zinc-900/60 hover:bg-zinc-900 rounded-xl overflow-hidden cursor-pointer transition-all hover:shadow-2xl flex flex-col justify-between group ${
isSelected
? 'ring-2 ring-orange-500 bg-zinc-900'
: ''
}`}
>
{/* Card Header with Icon */}
<div className="p-4 bg-black/20 flex items-center justify-between">
<div className="flex items-center gap-3">
{renderFileIcon(file.type, "w-6 h-6")}
<div>
<span className="text-[11px] font-bold font-mono uppercase px-2 py-0.5 rounded bg-white/10 text-gray-300">
{file.type}
</span>
<span className="text-[11px] font-mono text-gray-500 ml-2">
{file.size}
</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setPreviewItem(file);
}}
className="p-1.5 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="Pré-visualizar detalhes"
>
<Eye className="w-4 h-4" />
</button>
</div>
{/* Card Body */}
<div className="p-4 flex-1 flex flex-col justify-between space-y-4">
<div>
<h4 className="text-sm font-bold text-white line-clamp-2 group-hover:text-orange-400 transition-colors leading-snug mb-1.5">
{file.name}
</h4>
<p className="text-xs text-gray-400 line-clamp-1 font-medium flex items-center gap-1.5">
<BookOpen className="w-3.5 h-3.5 text-orange-400 shrink-0" />
<span className="truncate">{file.course.title}</span>
</p>
{file.lessonTitle && (
<p className="text-[11px] text-gray-500 line-clamp-1 mt-1 font-normal">
Aula: {file.lessonTitle}
</p>
)}
</div>
{/* Card Footer Actions */}
<div className="pt-3 flex items-center justify-between gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onPlayCourse(file.course);
}}
className="text-xs text-gray-400 hover:text-orange-400 flex items-center gap-1 transition-colors truncate"
title="Assistir aula associada"
>
<Play className="w-3 h-3 text-orange-400 shrink-0" />
<span className="truncate">Ver Aula</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
}}
disabled={isDownloading}
className="px-4 py-2 rounded-[980px] bg-orange-500 hover:bg-orange-600 active:scale-95 text-white text-xs font-bold flex items-center gap-1.5 shadow-md shadow-orange-500/20 transition-all shrink-0"
>
<Download className={`w-3.5 h-3.5 ${isDownloading ? 'animate-bounce' : ''}`} />
<span>{isDownloading ? 'Baixando...' : 'Baixar'}</span>
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
/* LIST VIEW (TABLE) */
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-black/60 text-xs font-bold uppercase tracking-wider text-gray-400 select-none sticky top-0 z-10 backdrop-blur-md">
<th
onClick={() => handleSort('name')}
className="py-4 px-6 cursor-pointer hover:text-white transition-colors"
>
<div className="flex items-center gap-1.5">
<span>Nome do Arquivo</span>
{sortBy === 'name' && (
<span className="text-orange-400 font-bold">{sortAsc ? '↑' : '↓'}</span>
)}
</div>
</th>
<th className="py-4 px-6 hidden md:table-cell">
<span>Curso / Trilha</span>
</th>
<th className="py-4 px-6 text-center w-36">
<span>Ações</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 text-sm font-medium">
{displayedFiles.map((file) => {
const isSelected = selectedItem?.id === file.id;
return (
<tr
key={file.id}
onClick={() => setSelectedItem(file)}
onContextMenu={(e) => handleContextMenu(e, file)}
className={`group transition-all cursor-pointer select-none ${
isSelected
? 'bg-orange-500/10'
: 'hover:bg-white/[0.03]'
}`}
>
{/* Name column */}
<td className="py-4 px-6">
<div className="flex items-center gap-3.5">
{renderFileIcon(file.type, "w-6 h-6")}
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`font-bold transition-colors truncate text-sm ${
isSelected ? 'text-orange-400' : 'text-white group-hover:text-orange-400'
}`}>
{file.name}
</span>
</div>
{file.lessonTitle && (
<p className="text-xs text-gray-400 truncate mt-0.5 font-normal">
Aula: {file.lessonTitle}
</p>
)}
</div>
</div>
</td>
{/* Course / Owner */}
<td className="py-4 px-6 hidden md:table-cell text-gray-300">
<span className="inline-flex items-center gap-1.5 bg-zinc-900 px-3 py-1 rounded-[980px] text-xs font-semibold text-gray-300">
<BookOpen className="w-3.5 h-3.5 text-orange-400 shrink-0" />
<span className="truncate max-w-[200px]">{file.course.title}</span>
</span>
</td>
{/* Actions */}
<td className="py-4 px-6 text-center">
<div className="flex items-center justify-center gap-1.5">
<button
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
}}
className="p-2 rounded-[980px] bg-orange-500/10 hover:bg-orange-500 text-orange-400 hover:text-white transition-all shadow-sm"
title="Baixar arquivo"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setPreviewItem(file);
}}
className="p-2 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
title="Ver detalhes"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onPlayCourse(file.course);
}}
className="p-2 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-orange-400 transition-colors"
title="Assistir aula"
>
<Play className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Table Footer */}
<div className="p-4 px-6 bg-black/40 flex items-center justify-between text-xs text-gray-400">
<span className="font-semibold">{displayedFiles.length} item(s) listados</span>
<span className="hidden sm:inline">Dica: Clique com o botão direito para o menu de ações rápido</span>
</div>
</main>
</div>
</div>
{/* QUICK PREVIEW MODAL */}
{previewItem && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-4 animate-in fade-in duration-200">
<div className="bg-zinc-950 border border-white/15 rounded-[12px] max-w-lg w-full p-6 shadow-2xl space-y-6 animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between pb-4 border-b border-white/10">
<div className="flex items-center gap-3">
{renderFileIcon(previewItem.type)}
<div>
<h3 className="text-base font-bold text-white truncate max-w-xs">{previewItem.name}</h3>
<p className="text-xs text-gray-400 font-mono">{previewItem.size} • {previewItem.type}</p>
</div>
</div>
<button
onClick={() => setPreviewItem(null)}
className="p-1.5 rounded-full hover:bg-white/10 text-gray-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3 text-sm">
<div className="p-4 rounded-[8px] bg-zinc-900 border border-white/10 space-y-2">
<div className="flex justify-between text-xs">
<span className="text-gray-400">Trilha:</span>
<span className="text-white font-semibold">{previewItem.course.title}</span>
</div>
{previewItem.lessonTitle && (
<div className="flex justify-between text-xs">
<span className="text-gray-400">Aula:</span>
<span className="text-white font-semibold">{previewItem.lessonTitle}</span>
</div>
)}
<div className="flex justify-between text-xs">
<span className="text-gray-400">Instrutor:</span>
<span className="text-orange-400 font-semibold">{previewItem.course.instructor.name}</span>
</div>
</div>
{previewItem.description && (
<div className="text-xs text-gray-300 leading-relaxed bg-white/5 p-3 rounded-[8px] border border-white/10">
{previewItem.description}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
onClick={() => {
onPlayCourse(previewItem.course);
setPreviewItem(null);
}}
className="px-5 py-2.5 rounded-[980px] bg-white/10 hover:bg-white/15 text-white text-xs font-bold flex items-center gap-2 transition-colors"
>
<Play className="w-4 h-4 text-orange-400" />
<span>Assistir Aula</span>
</button>
<button
onClick={() => {
handleDownload(previewItem);
setPreviewItem(null);
}}
className="px-6 py-2.5 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white text-xs font-bold flex items-center gap-2 shadow-lg shadow-orange-500/25 transition-all"
>
<Download className="w-4 h-4" />
<span>Baixar Material</span>
</button>
</div>
</div>
</div>
)}
{/* Context Menu Popup */}
{contextMenu.visible && contextMenu.item && (
<div
ref={contextMenuRef}
style={{ top: contextMenu.y, left: contextMenu.x }}
className="fixed z-50 w-60 bg-zinc-950 border border-white/15 rounded-[12px] shadow-2xl py-2 text-xs text-gray-200 animate-in fade-in zoom-in-95 duration-100 select-none"
>
<button
onClick={() => handleDownload(contextMenu.item!)}
className="w-full text-left px-4 py-2.5 hover:bg-orange-500 hover:text-white flex items-center gap-2.5 transition-colors font-bold text-orange-400"
>
<Download className="w-4 h-4" />
<span>Fazer Download</span>
</button>
<button
onClick={() => {
onPlayCourse(contextMenu.item!.course);
setContextMenu(prev => ({ ...prev, visible: false }));
}}
className="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-2.5 transition-colors font-medium text-white"
>
<Play className="w-4 h-4 text-orange-400" />
<span>Assistir Aula do Curso</span>
</button>
<button
onClick={() => {
navigator.clipboard.writeText(window.location.origin + contextMenu.item!.url);
showToast('Link do arquivo copiado!', 'info');
setContextMenu(prev => ({ ...prev, visible: false }));
}}
className="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-2.5 transition-colors font-medium text-gray-300"
>
<Copy className="w-4 h-4 text-gray-400" />
<span>Copiar Link do Arquivo</span>
</button>
<div className="h-px bg-white/10 my-1"></div>
<button
onClick={() => setContextMenu(prev => ({ ...prev, visible: false }))}
className="w-full text-left px-4 py-2 hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
>
<span>Fechar</span>
</button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 90" fill="none" width="100%" height="100%">
<!-- "compor" in white for dark background -->
<text
x="5"
y="66"
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
font-size="64"
font-weight="900"
fill="#FFFFFF"
letter-spacing="-2px"
>compor</text>
<!-- 3 Orange Dots in precise triangle (2 top, 1 bottom) -->
<g fill="#EA721D">
<circle cx="282" cy="22" r="8" />
<circle cx="304" cy="22" r="8" />
<circle cx="293" cy="38" r="8" />
</g>
<!-- "HUB" in Orange -->
<text
x="326"
y="66"
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
font-size="66"
font-weight="900"
fill="#EA721D"
letter-spacing="0px"
>HUB</text>
</svg>

After

Width:  |  Height:  |  Size: 935 B

30
public/Compor Hub.svg Normal file
View File

@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 90" fill="none" width="100%" height="100%">
<!-- "compor" in dark charcoal for light background -->
<text
x="5"
y="66"
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
font-size="64"
font-weight="900"
fill="#222222"
letter-spacing="-2px"
>compor</text>
<!-- 3 Orange Dots in precise triangle (2 top, 1 bottom) -->
<g fill="#EA721D">
<circle cx="282" cy="22" r="8" />
<circle cx="304" cy="22" r="8" />
<circle cx="293" cy="38" r="8" />
</g>
<!-- "HUB" in Orange -->
<text
x="326"
y="66"
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
font-size="66"
font-weight="900"
fill="#EA721D"
letter-spacing="0px"
>HUB</text>
</svg>

After

Width:  |  Height:  |  Size: 944 B

12
public/favicon.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
<defs>
<linearGradient id="favOrangeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FB923C" />
<stop offset="100%" stop-color="#EA580C" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="48" fill="#121214" />
<circle cx="38" cy="40" r="14" fill="url(#favOrangeGrad)" />
<circle cx="68" cy="40" r="14" fill="url(#favOrangeGrad)" />
<circle cx="53" cy="66" r="14" fill="url(#favOrangeGrad)" />
</svg>

After

Width:  |  Height:  |  Size: 537 B

19
public/logo-dark-bg.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 540 100" fill="none">
<defs>
<linearGradient id="hubOrangeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FB923C" />
<stop offset="100%" stop-color="#EA580C" />
</linearGradient>
</defs>
<!-- "compor" in negative (white for dark backgrounds) -->
<text x="0" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="#FFFFFF" letter-spacing="-2px">compor</text>
<!-- 3 Orange Dots Brand Icon -->
<circle cx="316" cy="18" r="9" fill="url(#hubOrangeGrad)" />
<circle cx="344" cy="18" r="9" fill="url(#hubOrangeGrad)" />
<circle cx="330" cy="38" r="9" fill="url(#hubOrangeGrad)" />
<!-- "HUB" in vibrant orange -->
<text x="368" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="url(#hubOrangeGrad)" letter-spacing="-0.5px">HUB</text>
</svg>

After

Width:  |  Height:  |  Size: 994 B

19
public/logo-light-bg.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 540 100" fill="none">
<defs>
<linearGradient id="hubOrangeGradLight" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FB923C" />
<stop offset="100%" stop-color="#EA580C" />
</linearGradient>
</defs>
<!-- "compor" in dark charcoal/cinza escuro for light backgrounds -->
<text x="0" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="#1C1E21" letter-spacing="-2px">compor</text>
<!-- 3 Orange Dots Brand Icon -->
<circle cx="316" cy="18" r="9" fill="url(#hubOrangeGradLight)" />
<circle cx="344" cy="18" r="9" fill="url(#hubOrangeGradLight)" />
<circle cx="330" cy="38" r="9" fill="url(#hubOrangeGradLight)" />
<!-- "HUB" in vibrant orange -->
<text x="368" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="url(#hubOrangeGradLight)" letter-spacing="-0.5px">HUB</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

33
services/ai.ts Normal file
View File

@@ -0,0 +1,33 @@
import { GoogleGenAI } from "@google/genai";
export const generateStudentReply = async (studentComment: string, courseContext: string): Promise<string> => {
try {
const apiKey = process.env.API_KEY;
if (!apiKey) {
console.warn("EduClean: API Key is missing. AI features are disabled.");
return "Peço desculpas, mas não consigo gerar uma resposta no momento. Por favor, contate o professor diretamente.";
}
const ai = new GoogleGenAI({ apiKey });
const prompt = `
Você é um assistente de professor útil, encorajador e claro.
Contexto: O aluno está fazendo uma pergunta sobre o curso: "${courseContext}".
Pergunta do Aluno: "${studentComment}"
Tarefa: Escreva uma resposta concisa, didática e amigável (máximo de 2 frases) em Português do Brasil.
Responda diretamente ao ponto do aluno.
`;
const response = await ai.models.generateContent({
model: 'gemini-3-flash-preview',
contents: prompt,
});
return response.text || "";
} catch (error) {
console.error("Gemini API Error:", error);
return "Desculpe, não consegui gerar uma resposta automática agora. Por favor, tente novamente.";
}
};

81
services/db.ts Normal file
View File

@@ -0,0 +1,81 @@
import localforage from 'localforage';
import { Course, Comment, Section } from '../types';
import { INITIAL_COURSES, generateMockSections } from '../constants';
const COURSES_KEY = 'compor_hub_courses_v1';
const COMMENTS_KEY = 'compor_hub_comments_v1';
const PROGRESS_KEY = 'compor_hub_progress_v1';
// Initialize DB with seed courses
export const initDB = async (): Promise<void> => {
const existingCourses = await localforage.getItem<Course[]>(COURSES_KEY);
if (!existingCourses || existingCourses.length === 0) {
await localforage.setItem(COURSES_KEY, INITIAL_COURSES);
}
};
export const getCourses = async (): Promise<Course[]> => {
const courses = await localforage.getItem<Course[]>(COURSES_KEY);
if (!courses || courses.length === 0) {
await initDB();
return INITIAL_COURSES;
}
return courses;
};
export const getCourseById = async (id: string): Promise<Course | null> => {
const courses = await getCourses();
return courses.find(c => c.id === id) || null;
};
export const saveCourse = async (course: Course): Promise<void> => {
const courses = await getCourses();
const index = courses.findIndex(c => c.id === course.id);
if (index >= 0) {
courses[index] = course;
} else {
courses.unshift(course);
}
await localforage.setItem(COURSES_KEY, courses);
};
export const deleteCourse = async (id: string): Promise<void> => {
const courses = await getCourses();
const filtered = courses.filter(c => c.id !== id);
await localforage.setItem(COURSES_KEY, filtered);
};
export const getComments = async (courseId: string): Promise<Comment[]> => {
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
return allComments.filter(c => c.courseId === courseId);
};
export const saveComment = async (comment: Comment): Promise<void> => {
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
const index = allComments.findIndex(c => c.id === comment.id);
if (index >= 0) {
allComments[index] = comment;
} else {
allComments.unshift(comment);
}
await localforage.setItem(COMMENTS_KEY, allComments);
};
export const saveProgress = async (courseId: string, progress: number): Promise<void> => {
const courses = await getCourses();
const course = courses.find(c => c.id === courseId);
if (course) {
course.progress = progress;
await saveCourse(course);
}
};
export const incrementViews = async (courseId: string): Promise<void> => {
const courses = await getCourses();
const course = courses.find(c => c.id === courseId);
if (course) {
course.views = (course.views || 0) + 1;
await saveCourse(course);
}
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

66
types.ts Normal file
View File

@@ -0,0 +1,66 @@
export type SectionVariant = 'landscape' | 'portrait';
export type AttachmentType = 'pdf' | 'spreadsheet' | 'zip' | 'doc' | 'image' | 'link' | 'video';
export interface Attachment {
id: string;
name: string;
type: AttachmentType;
url: string;
size?: string; // e.g. "4.2 MB"
description?: string;
lessonId?: string; // Associated lesson or global course asset
downloadCount?: number;
}
export interface Lesson {
id: string;
title: string;
duration: string;
videoUrl: string;
isFree: boolean; // true = Public, false = Pro (Login required)
description?: string;
attachments?: Attachment[]; // Lesson-specific downloadable materials
}
export interface Course {
id: string;
title: string;
category: string;
thumbnail: string;
lessons: Lesson[];
progress?: number; // 0 to 100
views?: number;
duration: string; // Total duration or lesson count
description: string;
instructor?: string;
instructorRole?: string;
tips?: string[]; // Key takeaways / instructor tips
attachments?: Attachment[]; // Downloadable materials across entire course
}
export interface Section {
title: string;
variant: SectionVariant;
courses: Course[];
}
export type UserRole = 'professor' | 'student';
export interface User {
email: string;
name: string;
role: UserRole;
avatar?: string;
}
export interface Comment {
id: string;
courseId: string;
lessonId?: string;
user: string;
text: string;
date: string;
reply?: string; // Professor's reply
replyDate?: string;
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});