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

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