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:
104
components/CourseCard.tsx
Normal file
104
components/CourseCard.tsx
Normal 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
228
components/CourseGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
571
components/CoursePlayerModal.tsx
Normal file
571
components/CoursePlayerModal.tsx
Normal 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
176
components/CourseRow.tsx
Normal 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
75
components/Hero.tsx
Normal 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
166
components/LoginModal.tsx
Normal 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
88
components/Logo.tsx
Normal 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
194
components/MaterialCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
218
components/MaterialsHubModal.tsx
Normal file
218
components/MaterialsHubModal.tsx
Normal 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
143
components/Navbar.tsx
Normal 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
170
components/SearchModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user