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:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
123
App.tsx
Normal file
123
App.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Navbar } from './components/Navbar';
|
||||
import { Hero } from './components/Hero';
|
||||
import { CourseGrid } from './components/CourseGrid';
|
||||
import { ManageCourses } from './pages/ManageCourses';
|
||||
import { MaterialsPage } from './pages/MaterialsPage';
|
||||
import { CoursePlayerModal } from './components/CoursePlayerModal';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ToastProvider } from './context/ToastContext';
|
||||
import { Course } from './types';
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRole?: 'professor' | 'student' }> = ({ children, allowedRole }) => {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
if (allowedRole && user.role !== allowedRole) {
|
||||
return <Navigate to={user.role === 'professor' ? '/gerenciar' : '/'} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Home component
|
||||
interface HomeProps {
|
||||
onPlay: (course: Course) => void;
|
||||
}
|
||||
|
||||
const Home: React.FC<HomeProps> = ({ onPlay }) => {
|
||||
return (
|
||||
<main>
|
||||
<Hero onPlay={onPlay} />
|
||||
<CourseGrid onCourseSelect={onPlay} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
function AppContent() {
|
||||
const [selectedCourse, setSelectedCourse] = useState<Course | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white font-sans selection:bg-orange-500/30 selection:text-white">
|
||||
{/* Navbar with Direct Materials Hub Trigger */}
|
||||
<Navbar
|
||||
onPlay={setSelectedCourse}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
onPlay={setSelectedCourse}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Full Dedicated Materials & Downloads Page */}
|
||||
<Route
|
||||
path="/materiais"
|
||||
element={
|
||||
<MaterialsPage
|
||||
onPlayCourse={setSelectedCourse}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Professor Route - Protected */}
|
||||
<Route
|
||||
path="/gerenciar"
|
||||
element={
|
||||
<ProtectedRoute allowedRole="professor">
|
||||
<ManageCourses />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all redirect */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
{/* Global Player Modal */}
|
||||
<CoursePlayerModal
|
||||
course={selectedCourse}
|
||||
onClose={() => setSelectedCourse(null)}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 px-6 border-t border-white/10 bg-[#070709]">
|
||||
<div className="max-w-[1920px] mx-auto flex flex-col md:flex-row items-center justify-between gap-6 text-gray-400 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-white">Compor HUB</span>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span>Portal de Cursos & Central de Downloads</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-6 text-xs text-gray-500">
|
||||
<span>© {new Date().getFullYear()} Compor HUB. Todos os direitos reservados.</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AppContent />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
11
README.md
11
README.md
@@ -1,11 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
|
||||
<h1>Built with AI Studio</h2>
|
||||
|
||||
<p>The fastest path from prompt to production with Gemini.</p>
|
||||
|
||||
<a href="https://aistudio.google.com/apps">Start building</a>
|
||||
|
||||
</div>
|
||||
104
components/CourseCard.tsx
Normal file
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>
|
||||
);
|
||||
};
|
||||
561
constants.ts
Normal file
561
constants.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
import { Course, Section, SectionVariant, Lesson, Attachment } from './types';
|
||||
|
||||
export const CATEGORIES = [
|
||||
'Tráfego Pago & Meta Ads',
|
||||
'Google Ads & YouTube',
|
||||
'Funis de Vendas & ROI',
|
||||
'Copywriting & Criativos',
|
||||
'Gestão & Processos de Agência',
|
||||
'Automação & CRM',
|
||||
];
|
||||
|
||||
const SAMPLE_VIDEO = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
|
||||
|
||||
export const FEATURED_COURSE: Course = {
|
||||
id: 'featured-masterclass-roas',
|
||||
title: 'Masterclass: Escala de Tráfego Pago & ROAS 5x+',
|
||||
category: 'Tráfego Pago & Meta Ads',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1551836022-d5d88e9218df?auto=format&fit=crop&w=1920&q=80',
|
||||
duration: '6 Aulas • 3h 45m',
|
||||
description: 'O método completo e validado para escalar campanhas de Meta Ads e Google Ads com previsibilidade, gestão de criativos de alta retenção e controle estrito de CAC e Blended ROAS.',
|
||||
instructor: 'Mário Morgado',
|
||||
instructorRole: 'Head de Performance & Growth',
|
||||
lessons: [
|
||||
{
|
||||
id: 'l-feat-1',
|
||||
title: '01. Arquitetura de Conta & Estratégia de Escala CBO/ABO',
|
||||
duration: '22m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Como montar uma estrutura robusta à prova de bloqueios e pronta para receber orçamentos 5x maiores sem estourar o CPA.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-feat-1',
|
||||
name: 'Planilha_Calculadora_ROAS_e_CAC.xlsx',
|
||||
type: 'spreadsheet',
|
||||
url: '#',
|
||||
size: '1.4 MB',
|
||||
description: 'Calculadora automática para encontrar o ponto de equilíbrio (breakeven) e margem de lucro por campanha.'
|
||||
},
|
||||
{
|
||||
id: 'att-feat-2',
|
||||
name: 'Guia_Estrutura_de_Contas_Meta_2026.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '3.8 MB',
|
||||
description: 'Manual visual com esquemas de públicos abertos, lookalikes e segmentações vantajosas.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'l-feat-2',
|
||||
title: '02. Laboratório de Criativos: Análise de Hook Rate & Hold Rate',
|
||||
duration: '35m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'A anatomia dos criativos vencedores. Métricas de retenção nos primeiros 3 segundos e copies que convertem em escala.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-feat-3',
|
||||
name: 'Templates_Criativos_Photoshop_Figma.zip',
|
||||
type: 'zip',
|
||||
url: '#',
|
||||
size: '48.2 MB',
|
||||
description: 'Pack com 15 layouts de anúncios estáticos e scripts de vídeos curtos prontos para edição.'
|
||||
},
|
||||
{
|
||||
id: 'att-feat-4',
|
||||
name: 'Checklist_Auditoria_de_Criativos.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '890 KB',
|
||||
description: 'Checklist de 12 pontos para aprovação de novos criativos antes de subir na conta.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'l-feat-3',
|
||||
title: '03. Google Ads para E-commerce: Performance Max & Search Alpha',
|
||||
duration: '40m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Dominando feeds do Google Merchant Center, segmentações avançadas e termos negativos para maximizar a conversão.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-feat-5',
|
||||
name: 'Lista_Palavras_Chave_Negativas_Universal.xlsx',
|
||||
type: 'spreadsheet',
|
||||
url: '#',
|
||||
size: '640 KB',
|
||||
description: 'Mais de 2.000 palavras-chave negativas essenciais para economizar orçamento no Google Ads.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'l-feat-4',
|
||||
title: '04. Funil de Vendas de Alta Conversão & Otimização de Checkout',
|
||||
duration: '38m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Reduzindo o abandono de carrinho e aumentando o ticket médio com upsells, order bumps e automações de CRM.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-feat-6',
|
||||
name: 'Blueprint_Funil_Vendas_Infoproduto_Ecommerce.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '5.1 MB',
|
||||
description: 'Mapeamento visual do fluxo de tráfego, página de vendas e checkout.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'l-feat-5',
|
||||
title: '05. Dashboards White-Label & Gestão de Relatórios para Clientes',
|
||||
duration: '28m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Como apresentar dados e ROI de forma cristalina para clientes sem gastar horas montando relatórios manuais.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-feat-7',
|
||||
name: 'Modelo_Relatorio_Executivo_Mensal.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '2.3 MB',
|
||||
description: 'Template de relatório executivo que comprova o valor do gestor de tráfego.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'l-feat-6',
|
||||
title: '06. Plano de Ação: Escalando de R$ 10k para R$ 100k/mês',
|
||||
duration: '42m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'O passo a passo estratégico para gerenciar grandes volumes de verba sem perder a margem de contribuição.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-feat-8',
|
||||
name: 'Roadmap_Escala_Orcamento_Planilha.xlsx',
|
||||
type: 'spreadsheet',
|
||||
url: '#',
|
||||
size: '1.1 MB',
|
||||
description: 'Cronograma e matriz de decisão para aumentos graduais de orçamento diário.'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
tips: [
|
||||
"Monitore o Blended ROAS diariamente para ter clareza do retorno global sobre o investimento.",
|
||||
"Criativos representam mais de 70% do sucesso no Meta Ads atual. Teste pelo menos 5 novos ângulos semanais.",
|
||||
"Nunca aumente o orçamento em mais de 20% a cada 48h em conjuntos ABO para não reiniciar o aprendizado da máquina.",
|
||||
"Analise os materiais complementares em PDF e Planilhas anexados em cada aula para acelerar sua implementação."
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-master-pack',
|
||||
name: 'Kit_Completo_Materiais_Escala_Compor_HUB.zip',
|
||||
type: 'zip',
|
||||
url: '#',
|
||||
size: '62.4 MB',
|
||||
description: 'Todos os PDFs, Planilhas, Modelos de Relatório e Scripts do curso compactados em um único arquivo.'
|
||||
},
|
||||
{
|
||||
id: 'att-master-pdf',
|
||||
name: 'Apostila_Oficial_Gestao_Trafego_Pago_2026.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '12.8 MB',
|
||||
description: 'Apostila completa de 94 páginas com resumos teóricos e práticos.'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Realistic library of courses with attached downloadable assets
|
||||
export const INITIAL_COURSES: Course[] = [
|
||||
FEATURED_COURSE,
|
||||
{
|
||||
id: 'course-meta-ads-pro',
|
||||
title: 'Meta Ads Pro: Segmentações, Públicos & Pixel CAPI',
|
||||
category: 'Tráfego Pago & Meta Ads',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
||||
duration: '5 Aulas • 2h 50m',
|
||||
description: 'Aprenda a configurar a API de Conversões do Meta, gerenciar eventos prioritários e explorar públicos de alta conversão.',
|
||||
instructor: 'Lucas Farias',
|
||||
instructorRole: 'Especialista em Tracking & Meta Ads',
|
||||
lessons: [
|
||||
{
|
||||
id: 'c-meta-1',
|
||||
title: '01. Configuração do Pixel e API de Conversões (CAPI)',
|
||||
duration: '32m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Tracking avançado com Google Tag Manager (GTM) e servidor para 100% de precisão nos eventos.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'meta-att-1',
|
||||
name: 'Container_GTM_Pre_Configurado_Meta_CAPI.json',
|
||||
type: 'doc',
|
||||
url: '#',
|
||||
size: '220 KB',
|
||||
description: 'Arquivo JSON pronto para importar no Google Tag Manager com todos os eventos essenciais.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c-meta-2',
|
||||
title: '02. Criação de Públicos Personalizados e Lookalikes',
|
||||
duration: '28m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Construindo públicos de compradores, visitantes de carrinho e engajamento no Instagram.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'meta-att-2',
|
||||
name: 'Guia_Estrategico_Publicos_Meta.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '2.1 MB',
|
||||
description: 'Matriz de segmentação por temperatura de público (Frio, Morno, Quente).'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c-meta-3',
|
||||
title: '03. Testes A/B Científicos de Criativos',
|
||||
duration: '35m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Metodologia para testar manchetes, ganchos e CTAs sem desperdiçar verba.'
|
||||
},
|
||||
{
|
||||
id: 'c-meta-4',
|
||||
title: '04. Estratégias de Remarketing Dinâmico',
|
||||
duration: '30m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Recuperando vendas com catálogos de produtos e mensagens personalizadas de urgência.'
|
||||
},
|
||||
{
|
||||
id: 'c-meta-5',
|
||||
title: '05. Contingência & Aquecimento de Ativos',
|
||||
duration: '45m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Proteja suas contas de anúncios com uma estrutura de contingência profissional.'
|
||||
}
|
||||
],
|
||||
tips: [
|
||||
"A API de Conversões (CAPI) recupera em média 25% mais dados de eventos perdidos pelo iOS.",
|
||||
"Teste criativos sempre com público amplo (Broad) para validar a força do anúncio por si só."
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: 'meta-att-global-1',
|
||||
name: 'Checklist_Contingencia_Meta_Ads.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '1.8 MB',
|
||||
description: 'Passo a passo para blindar seus BMs, perfis e contas de anúncios.'
|
||||
},
|
||||
{
|
||||
id: 'meta-att-global-2',
|
||||
name: 'Planilha_Teste_AB_Criativos.xlsx',
|
||||
type: 'spreadsheet',
|
||||
url: '#',
|
||||
size: '950 KB',
|
||||
description: 'Acompanhamento do CTR e CPA por variação de criativo testado.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-google-ads-expert',
|
||||
title: 'Google Ads Expert: Rede de Pesquisa & Performance Max',
|
||||
category: 'Google Ads & YouTube',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1572021335469-31706a17aaef?auto=format&fit=crop&w=800&q=80',
|
||||
duration: '4 Aulas • 2h 15m',
|
||||
description: 'Domine a intenção de busca no Google. Como estruturar campanhas de Pesquisa de alto índice de qualidade e campanhas PMax focadas em ROAS.',
|
||||
instructor: 'Renato Silveira',
|
||||
instructorRole: 'Google Premier Partner',
|
||||
lessons: [
|
||||
{
|
||||
id: 'c-goog-1',
|
||||
title: '01. Índice de Qualidade & Correspondência de Palavras',
|
||||
duration: '30m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Como pagar menos por clique e aparecer nas primeiras posições do Google.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'goog-att-1',
|
||||
name: 'Guia_Indice_Qualidade_10_de_10.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '3.2 MB',
|
||||
description: 'As 3 regras para alcançar nota 10/10 no índice de qualidade do Google Ads.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c-goog-2',
|
||||
title: '02. Configuração Avançada de Performance Max (PMax)',
|
||||
duration: '42m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Alimentando o algoritmo com sinais de público precisos e exclusões estratégicas.'
|
||||
},
|
||||
{
|
||||
id: 'c-goog-3',
|
||||
title: '03. Google Shopping & Otimização de Títulos no Feed',
|
||||
duration: '33m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Como ranquear no topo do Google Shopping reestruturando os títulos dos seus produtos.'
|
||||
},
|
||||
{
|
||||
id: 'c-goog-4',
|
||||
title: '04. Estratégias de Lances: Maximizar Conversões vs tROAS',
|
||||
duration: '30m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'O momento exato de transicionar lances manuais para lances automatizados inteligentes.'
|
||||
}
|
||||
],
|
||||
tips: [
|
||||
"Adicione palavras-chave negativas semanalmente na aba de Termos de Pesquisa.",
|
||||
"No Google Ads, o CTR do anúncio dita mais de 50% do seu custo por clique real."
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: 'goog-att-all',
|
||||
name: 'Kit_Templates_Anuncios_Google_Search.zip',
|
||||
type: 'zip',
|
||||
url: '#',
|
||||
size: '14.5 MB',
|
||||
description: 'Modelos de textos, títulos e extensões de anúncio de alta taxa de clique.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-creative-lab',
|
||||
title: 'Design & Copywriting de Criativos que Vendem',
|
||||
category: 'Copywriting & Criativos',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1542744094-3a31f272c490?auto=format&fit=crop&w=800&q=80',
|
||||
duration: '4 Aulas • 2h 20m',
|
||||
description: 'Transforme ideias em criativos de alta conversão. Estrutura de roteiros para UGC, ganchos magnéticos e psicologia de compra.',
|
||||
instructor: 'Camila Rossi',
|
||||
instructorRole: 'Diretora de Criação & Copywriter',
|
||||
lessons: [
|
||||
{
|
||||
id: 'c-creat-1',
|
||||
title: '01. Os 10 Ganchos (Hooks) Mais Poderosos para Vídeos Curtos',
|
||||
duration: '35m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Como prender a atenção do usuário nos primeiros 3 segundos do Reels e TikTok.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'creat-att-1',
|
||||
name: '50_Ganchos_Validados_para_Reels_e_TikTok.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '4.7 MB',
|
||||
description: '50 modelos prontos de frases de impacto para abertura de vídeos de tráfego pago.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c-creat-2',
|
||||
title: '02. Roteirização de Anúncios UGC (User Generated Content)',
|
||||
duration: '40m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Como orientar criadores de conteúdo e clientes para gravarem depoimentos autênticos.'
|
||||
},
|
||||
{
|
||||
id: 'c-creat-3',
|
||||
title: '03. Anúncios Estáticos: Tipografia, Contraste e Quebra de Padrão',
|
||||
duration: '32m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Design estratégico no Feed do Instagram para aumentar a taxa de cliques (CTR).'
|
||||
},
|
||||
{
|
||||
id: 'c-creat-4',
|
||||
title: '04. Framework de Análise de Fadiga de Criativo',
|
||||
duration: '33m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Como identificar quando um anúncio saturou antes de perder dinheiro.'
|
||||
}
|
||||
],
|
||||
tips: [
|
||||
"O gancho do anúncio (primeiros 3 segundos) define mais de 80% do CPA final.",
|
||||
"Criativos simples com estética nativa (parecidos com stories de amigos) costumam converter melhor que vídeos ultra-produzidos."
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: 'creat-att-all',
|
||||
name: 'Pack_Figma_Templates_Anuncios_Feed_Stories.zip',
|
||||
type: 'zip',
|
||||
url: '#',
|
||||
size: '32.1 MB',
|
||||
description: 'Mais de 30 templates editáveis no Figma prontos para exportação.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-agency-scale',
|
||||
title: 'Gestão de Agência & Relatórios para Clientes de Tráfego',
|
||||
category: 'Gestão & Processos de Agência',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1552664730-d307ca884978?auto=format&fit=crop&w=800&q=80',
|
||||
duration: '4 Aulas • 2h 05m',
|
||||
description: 'Como precificar seus serviços, reter clientes com relatórios claros de ROI e organizar o fluxo de trabalho da sua equipe.',
|
||||
instructor: 'Mário Morgado',
|
||||
instructorRole: 'Fundador & Consultor de Performance',
|
||||
lessons: [
|
||||
{
|
||||
id: 'c-ag-1',
|
||||
title: '01. Modelos de Precificação: Fixo + Variável sobre ROAS',
|
||||
duration: '28m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Como cobrar comissões sobre resultados sem assumir riscos desnecessários.',
|
||||
attachments: [
|
||||
{
|
||||
id: 'ag-att-1',
|
||||
name: 'Contrato_Prestacao_Servicos_Trafego_Pago.docx',
|
||||
type: 'doc',
|
||||
url: '#',
|
||||
size: '145 KB',
|
||||
description: 'Modelo jurídico completo de contrato de prestação de serviços de gestão de tráfego.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c-ag-2',
|
||||
title: '02. Onboarding Perfeito de Novos Clientes',
|
||||
duration: '32m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'O formulário de briefing e alinhamento de expectativas que evita cancelamentos prematuros.'
|
||||
},
|
||||
{
|
||||
id: 'c-ag-3',
|
||||
title: '03. Reunião Mensal de Resultados: A Apresentação Ideal',
|
||||
duration: '35m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Mostrando faturamento real e lucro líquido sem afogar o cliente em termos técnicos.'
|
||||
},
|
||||
{
|
||||
id: 'c-ag-4',
|
||||
title: '04. Delegando Tarefas e Contratando Designers/Copywriters',
|
||||
duration: '30m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Processos de esteira de produção para gerenciar mais de 20 clientes simultâneos.'
|
||||
}
|
||||
],
|
||||
tips: [
|
||||
"Clientes não compram cliques ou impressões; eles compram faturamento previsível e tranquilidade.",
|
||||
"Envie micro-atualizações semanais pelo WhatsApp com o faturamento gerado para manter o cliente seguro."
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: 'ag-att-pack',
|
||||
name: 'Kit_Processos_e_Documentos_Agencia.zip',
|
||||
type: 'zip',
|
||||
url: '#',
|
||||
size: '18.3 MB',
|
||||
description: 'Contratos, briefings de onboarding, planilhas de precificação e apresentações em PowerPoint.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'course-funnel-cro',
|
||||
title: 'Funis de Vendas, CRO & Otimização de Taxa de Conversão',
|
||||
category: 'Funis de Vendas & ROI',
|
||||
thumbnail: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=800&q=80',
|
||||
duration: '3 Aulas • 1h 45m',
|
||||
description: 'Dobre o resultado do seu tráfego sem gastar 1 real a mais em anúncios otimizando as páginas de destino e o checkout.',
|
||||
instructor: 'Lucas Farias',
|
||||
instructorRole: 'Especialista em CRO & Funis',
|
||||
lessons: [
|
||||
{
|
||||
id: 'c-cro-1',
|
||||
title: '01. Diagnóstico de Gargalos no Funil de Vendas',
|
||||
duration: '35m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Onde seus visitantes estão travando: Página, Carrinho ou Checkout?',
|
||||
attachments: [
|
||||
{
|
||||
id: 'cro-att-1',
|
||||
name: 'Planilha_Diagnostico_Funil_Vendas.xlsx',
|
||||
type: 'spreadsheet',
|
||||
url: '#',
|
||||
size: '820 KB',
|
||||
description: 'Insira suas métricas e descubra automaticamente onde está a maior perda financeira do seu funil.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'c-cro-2',
|
||||
title: '02. Otimização de Landing Pages de Alta Conversão',
|
||||
duration: '40m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: true,
|
||||
description: 'Estrutura visual, velocidade de carregamento e provas sociais que convertem.'
|
||||
},
|
||||
{
|
||||
id: 'c-cro-3',
|
||||
title: '03. Recuperação de Vendas via WhatsApp & E-mail',
|
||||
duration: '30m',
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
isFree: false,
|
||||
description: 'Sequências de mensagens automáticas que recuperam até 35% dos checkouts abandonados.'
|
||||
}
|
||||
],
|
||||
tips: [
|
||||
"Uma página que demora mais de 3 segundos para carregar perde em média 40% dos visitantes do anúncio.",
|
||||
"Order bumps no checkout com ticket baixo aumentam o faturamento total em até 18% imediatamente."
|
||||
],
|
||||
attachments: [
|
||||
{
|
||||
id: 'cro-att-all',
|
||||
name: 'Scripts_Recuperacao_Carrinho_WhatsApp.pdf',
|
||||
type: 'pdf',
|
||||
url: '#',
|
||||
size: '1.9 MB',
|
||||
description: 'Mensagens prontas para copiar e colar na sua automação de recuperação.'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const generateMockSections = (): Section[] => {
|
||||
return [
|
||||
{
|
||||
title: 'Em Destaque • Tráfego & Performance',
|
||||
variant: 'landscape',
|
||||
courses: INITIAL_COURSES
|
||||
},
|
||||
{
|
||||
title: 'Cursos com Pacotes de Materiais & Planilhas',
|
||||
variant: 'landscape',
|
||||
courses: INITIAL_COURSES.filter(c => c.attachments && c.attachments.length > 0)
|
||||
},
|
||||
{
|
||||
title: 'Formações Recomendadas Compor HUB',
|
||||
variant: 'portrait',
|
||||
courses: INITIAL_COURSES
|
||||
},
|
||||
{
|
||||
title: 'Criativos, Copywriting & Design',
|
||||
variant: 'landscape',
|
||||
courses: INITIAL_COURSES.filter(c => c.category.includes('Criativos') || c.category.includes('Funis'))
|
||||
}
|
||||
];
|
||||
};
|
||||
69
context/AuthContext.tsx
Normal file
69
context/AuthContext.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { User, UserRole } from '../types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (email: string) => boolean;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage on mount with error handling
|
||||
const storedUser = localStorage.getItem('educlean_user');
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
if (parsedUser && parsedUser.email) {
|
||||
setUser(parsedUser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse user session, clearing storage:", error);
|
||||
localStorage.removeItem('educlean_user');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = (email: string): boolean => {
|
||||
let newUser: User | null = null;
|
||||
|
||||
if (email === 'admin@prof.com') {
|
||||
newUser = { email, name: 'Prof. Anderson', role: 'professor' };
|
||||
} else if (email === 'aluno@test.com') {
|
||||
newUser = { email, name: 'Aluno Demo', role: 'student' };
|
||||
}
|
||||
|
||||
if (newUser) {
|
||||
setUser(newUser);
|
||||
localStorage.setItem('educlean_user', JSON.stringify(newUser));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('educlean_user');
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
124
context/ToastContext.tsx
Normal file
124
context/ToastContext.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { CheckCircle2, XCircle, Info, Download, X, FileText, Check } from 'lucide-react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'download';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
fileName?: string;
|
||||
fileSize?: string;
|
||||
}
|
||||
|
||||
export interface ToastContextType {
|
||||
showToast: (message: string, type?: ToastType, fileName?: string, fileSize?: string) => void;
|
||||
showDownloadToast: (fileName: string, fileSize?: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'success', fileName?: string, fileSize?: string) => {
|
||||
const id = Date.now().toString() + Math.random().toString(36).substring(2, 5);
|
||||
setToasts((prev) => [...prev, { id, message, type, fileName, fileSize }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const showDownloadToast = useCallback((fileName: string, fileSize?: string) => {
|
||||
const id = Date.now().toString() + Math.random().toString(36).substring(2, 5);
|
||||
setToasts((prev) => [...prev, {
|
||||
id,
|
||||
message: `O material "${fileName}" foi baixado com sucesso.`,
|
||||
type: 'download',
|
||||
fileName,
|
||||
fileSize
|
||||
}]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4500);
|
||||
}, []);
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, showDownloadToast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-6 right-6 z-[300] flex flex-col gap-3 pointer-events-none">
|
||||
{toasts.map((toast) => {
|
||||
if (toast.type === 'download') {
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="pointer-events-auto relative overflow-hidden flex items-center gap-3.5 bg-[#0d0e12]/95 backdrop-blur-2xl border border-emerald-500/30 text-white p-4 rounded-2xl shadow-2xl shadow-emerald-500/15 animate-fade-in-up min-w-[320px] max-w-[420px]"
|
||||
>
|
||||
{/* Emerald vertical bar */}
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-emerald-400 to-teal-500"></div>
|
||||
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center shrink-0 text-emerald-400 shadow-inner">
|
||||
<Download className="w-5 h-5 animate-bounce" style={{ animationIterationCount: 2 }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 pr-1">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-[10px] font-extrabold tracking-wider uppercase text-emerald-400 bg-emerald-500/15 px-2 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||
Download Concluído
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-white truncate leading-tight" title={toast.fileName || toast.message}>
|
||||
{toast.fileName || toast.message}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 mt-1">
|
||||
<span>Salvo no seu dispositivo</span>
|
||||
{toast.fileSize && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="font-mono text-emerald-400/90 font-medium">{toast.fileSize}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="text-slate-500 hover:text-white p-1 rounded-lg hover:bg-white/10 transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="pointer-events-auto flex items-center gap-3 bg-[#121318]/95 backdrop-blur-2xl border border-white/10 text-white px-5 py-4 rounded-2xl shadow-2xl animate-fade-in-up min-w-[300px]"
|
||||
>
|
||||
{toast.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />}
|
||||
{toast.type === 'error' && <XCircle className="w-5 h-5 text-rose-400 shrink-0" />}
|
||||
{toast.type === 'info' && <Info className="w-5 h-5 text-sky-400 shrink-0" />}
|
||||
<p className="flex-1 text-sm font-medium">{toast.message}</p>
|
||||
<button onClick={() => removeToast(toast.id)} className="text-slate-500 hover:text-white shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) throw new Error('useToast must be used within ToastProvider');
|
||||
return context;
|
||||
};
|
||||
|
||||
84
index.css
Normal file
84
index.css
Normal file
@@ -0,0 +1,84 @@
|
||||
:root {
|
||||
/* Typography Tokens */
|
||||
--font-display: "Manrope", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-body: "Manrope", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Border Radii */
|
||||
--radius-tag: 980px;
|
||||
--radius-button: 980px;
|
||||
--radius-card: 8px;
|
||||
--radius-image: 8px;
|
||||
--radius-input: 8px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-xl: 0 10px 30px -5px rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Layout */
|
||||
--page-max-width: 1440px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background-color: #000000;
|
||||
color: #f4f4f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, .font-display, .font-heading {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Custom scrollbar for modals & panels */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 980px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
/* Smooth drag cursor */
|
||||
.cursor-grab {
|
||||
cursor: grab;
|
||||
}
|
||||
.cursor-grabbing {
|
||||
cursor: grabbing;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
89
index.html
Normal file
89
index.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Compor HUB | Cursos & Central de Materiais</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', 'sans-serif'],
|
||||
display: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif'],
|
||||
heading: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif'],
|
||||
body: ['"Manrope"', 'system-ui', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'sans-serif'],
|
||||
},
|
||||
fontSize: {
|
||||
'caption': ['12px', { lineHeight: '1.5', letterSpacing: '-0.01em' }],
|
||||
'body-sm': ['14px', { lineHeight: '1.5', letterSpacing: '-0.01em' }],
|
||||
'body': ['16px', { lineHeight: '1.6', letterSpacing: '-0.01em' }],
|
||||
'subheading': ['20px', { lineHeight: '1.35', letterSpacing: '-0.02em' }],
|
||||
'heading-sm': ['26px', { lineHeight: '1.3', letterSpacing: '-0.025em' }],
|
||||
'heading': ['36px', { lineHeight: '1.25', letterSpacing: '-0.03em' }],
|
||||
'heading-lg': ['44px', { lineHeight: '1.2', letterSpacing: '-0.03em' }],
|
||||
'display': ['52px', { lineHeight: '1.15', letterSpacing: '-0.035em' }],
|
||||
},
|
||||
letterSpacing: {
|
||||
'caption': '-0.01em',
|
||||
'body-sm': '-0.01em',
|
||||
'body': '-0.01em',
|
||||
'subheading': '-0.02em',
|
||||
'heading-sm': '-0.025em',
|
||||
'heading': '-0.03em',
|
||||
'heading-lg': '-0.03em',
|
||||
'display': '-0.035em',
|
||||
},
|
||||
borderRadius: {
|
||||
'tag': '980px',
|
||||
'button': '980px',
|
||||
'card': '8px',
|
||||
'image': '8px',
|
||||
'input': '8px',
|
||||
'pill': '980px',
|
||||
},
|
||||
boxShadow: {
|
||||
'xl': '0 10px 30px -5px rgba(0, 0, 0, 0.3)',
|
||||
'glow': '0 0 35px -5px rgba(249, 115, 22, 0.25)',
|
||||
},
|
||||
maxWidth: {
|
||||
'page': '1440px',
|
||||
},
|
||||
colors: {
|
||||
gray: {
|
||||
850: '#1f1f1f',
|
||||
900: '#121212',
|
||||
950: '#0a0a0a',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@18.2.0",
|
||||
"react/": "https://esm.sh/react@18.2.0/",
|
||||
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
||||
"react-dom/": "https://esm.sh/react-dom@18.2.0/",
|
||||
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
||||
"react-router-dom": "https://esm.sh/react-router-dom@6.22.3?external=react,react-dom",
|
||||
"lucide-react": "https://esm.sh/lucide-react@0.344.0",
|
||||
"localforage": "https://esm.sh/localforage@1.10.0",
|
||||
"@google/genai": "https://esm.sh/@google/genai"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
index.tsx
Normal file
15
index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
8
metadata.json
Normal file
8
metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "Compor HUB",
|
||||
"description": "Portal de Cursos e Central de Download de Materiais (PDFs, Planilhas, Modelos e Checklists) para Gestão de Tráfego Pago e Performance.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": [
|
||||
"MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"
|
||||
]
|
||||
}
|
||||
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "educlean",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "6.22.3",
|
||||
"lucide-react": "0.344.0",
|
||||
"localforage": "1.10.0",
|
||||
"@google/genai": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
905
pages/ManageCourses.tsx
Normal file
905
pages/ManageCourses.tsx
Normal file
@@ -0,0 +1,905 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Settings,
|
||||
Users,
|
||||
BarChart,
|
||||
Trash2,
|
||||
Edit2,
|
||||
X,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
FileArchive,
|
||||
Link as LinkIcon,
|
||||
Paperclip,
|
||||
Lightbulb,
|
||||
ListVideo,
|
||||
Check,
|
||||
Lock,
|
||||
Unlock,
|
||||
UploadCloud,
|
||||
Globe,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
import { getCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
|
||||
import { generateStudentReply } from '../services/ai';
|
||||
import { CATEGORIES } from '../constants';
|
||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||
|
||||
// 1. Create/Edit Course Modal
|
||||
const CourseModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (course: Course) => void;
|
||||
initialData?: Course;
|
||||
}> = ({ isOpen, onClose, onSave, initialData }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [thumbnailPreview, setThumbnailPreview] = useState<string>('');
|
||||
const [instructor, setInstructor] = useState('Mário Morgado');
|
||||
const [instructorRole, setInstructorRole] = useState('Especialista em Performance');
|
||||
|
||||
// Lesson Management
|
||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||
|
||||
// New Lesson State
|
||||
const [newLessonTitle, setNewLessonTitle] = useState('');
|
||||
const [newLessonDuration, setNewLessonDuration] = useState('');
|
||||
const [newLessonVideoUrl, setNewLessonVideoUrl] = useState('');
|
||||
const [newLessonIsFree, setNewLessonIsFree] = useState(false);
|
||||
const [videoSourceType, setVideoSourceType] = useState<'upload' | 'link'>('upload');
|
||||
const [selectedFileName, setSelectedFileName] = useState('');
|
||||
const lessonFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Tips & Global Attachments
|
||||
const [tips, setTips] = useState<string[]>([]);
|
||||
const [newTip, setNewTip] = useState('');
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
|
||||
// New Attachment State
|
||||
const [newAttName, setNewAttName] = useState('');
|
||||
const [newAttType, setNewAttType] = useState<AttachmentType>('pdf');
|
||||
const [newAttUrl, setNewAttUrl] = useState('');
|
||||
const [newAttSize, setNewAttSize] = useState('');
|
||||
const [newAttDesc, setNewAttDesc] = useState('');
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialData) {
|
||||
setTitle(initialData.title);
|
||||
setDescription(initialData.description);
|
||||
setCategory(initialData.category);
|
||||
setThumbnailPreview(initialData.thumbnail);
|
||||
setInstructor(initialData.instructor || 'Mário Morgado');
|
||||
setInstructorRole(initialData.instructorRole || 'Especialista em Performance');
|
||||
setTips(initialData.tips || []);
|
||||
setAttachments(initialData.attachments || []);
|
||||
setLessons(initialData.lessons || []);
|
||||
} else {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory(CATEGORIES[0]);
|
||||
setThumbnailPreview('');
|
||||
setInstructor('Mário Morgado');
|
||||
setInstructorRole('Especialista em Performance');
|
||||
setTips([]);
|
||||
setAttachments([]);
|
||||
setLessons([]);
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialData]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setThumbnailPreview(objectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLessonVideoFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setNewLessonVideoUrl(objectUrl);
|
||||
setSelectedFileName(file.name);
|
||||
if (!newLessonDuration) setNewLessonDuration('15m');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddLesson = () => {
|
||||
if (!newLessonTitle.trim()) {
|
||||
alert("Digite um título para a aula.");
|
||||
return;
|
||||
}
|
||||
if (!newLessonVideoUrl.trim()) {
|
||||
alert("Selecione um vídeo ou insira um link.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newLesson: Lesson = {
|
||||
id: `lesson-${Date.now()}`,
|
||||
title: newLessonTitle,
|
||||
duration: newLessonDuration || '15m',
|
||||
videoUrl: newLessonVideoUrl,
|
||||
isFree: newLessonIsFree,
|
||||
description: ''
|
||||
};
|
||||
|
||||
setLessons([...lessons, newLesson]);
|
||||
|
||||
setNewLessonTitle('');
|
||||
setNewLessonDuration('');
|
||||
setNewLessonVideoUrl('');
|
||||
setSelectedFileName('');
|
||||
setNewLessonIsFree(false);
|
||||
if (lessonFileInputRef.current) lessonFileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const removeLesson = (id: string) => {
|
||||
setLessons(lessons.filter(l => l.id !== id));
|
||||
};
|
||||
|
||||
const toggleLessonAccess = (id: string) => {
|
||||
setLessons(lessons.map(l => l.id === id ? { ...l, isFree: !l.isFree } : l));
|
||||
};
|
||||
|
||||
const handleAddTip = () => {
|
||||
if (newTip.trim()) {
|
||||
setTips([...tips, newTip.trim()]);
|
||||
setNewTip('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeTip = (idx: number) => {
|
||||
setTips(tips.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleAddAttachment = () => {
|
||||
if (!newAttName.trim()) {
|
||||
alert("Informe o nome do arquivo para download.");
|
||||
return;
|
||||
}
|
||||
|
||||
const att: Attachment = {
|
||||
id: `att-${Date.now()}`,
|
||||
name: newAttName.trim(),
|
||||
type: newAttType,
|
||||
url: newAttUrl.trim() || '#',
|
||||
size: newAttSize.trim() || '1.5 MB',
|
||||
description: newAttDesc.trim()
|
||||
};
|
||||
|
||||
setAttachments([...attachments, att]);
|
||||
setNewAttName('');
|
||||
setNewAttSize('');
|
||||
setNewAttUrl('');
|
||||
setNewAttDesc('');
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments(attachments.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lessons.length === 0) {
|
||||
alert("Adicione pelo menos uma aula à trilha.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const durationStr = `${lessons.length} Aulas`;
|
||||
|
||||
onSave({
|
||||
id: initialData?.id || `course-${Date.now()}`,
|
||||
title,
|
||||
description,
|
||||
category: category || CATEGORIES[0],
|
||||
thumbnail: thumbnailPreview || 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?auto=format&fit=crop&w=800&q=80',
|
||||
duration: durationStr,
|
||||
instructor,
|
||||
instructorRole,
|
||||
views: initialData?.views || 0,
|
||||
progress: initialData?.progress || 0,
|
||||
lessons: lessons,
|
||||
tips: tips,
|
||||
attachments: attachments
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-xl" onClick={onClose} />
|
||||
<div className="relative w-full max-w-6xl bg-[#141416] rounded-3xl border border-white/10 p-6 md:p-8 shadow-2xl animate-fade-in-up max-h-[92vh] overflow-y-auto flex flex-col lg:flex-row gap-8">
|
||||
|
||||
{/* Left Column: Course Details, Tips & Downloads */}
|
||||
<div className="w-full lg:w-5/12 space-y-6">
|
||||
<div className="flex justify-between items-center lg:hidden mb-2">
|
||||
<h2 className="text-xl font-bold text-white">{initialData ? 'Editar Curso' : 'Criar Curso'}</h2>
|
||||
<button onClick={onClose}><X className="text-white" /></button>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-2 block">Imagem de Capa (16:9)</label>
|
||||
<div
|
||||
className="w-full aspect-video rounded-2xl border-2 border-dashed border-white/10 bg-black/40 flex flex-col items-center justify-center cursor-pointer hover:border-orange-500/50 transition-colors relative overflow-hidden group"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{thumbnailPreview ? (
|
||||
<>
|
||||
<img src={thumbnailPreview} alt="Preview" className="w-full h-full object-cover opacity-70 group-hover:opacity-40 transition-opacity" />
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="bg-orange-500 px-4 py-2 rounded-xl text-xs font-bold shadow-lg">Alterar Capa</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center p-6">
|
||||
<ImageIcon className="w-10 h-10 text-orange-400/60 mx-auto mb-2" />
|
||||
<p className="text-gray-300 text-xs font-medium">Clique para selecionar imagem</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Título do Curso</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ex: Masterclass: Escala de Tráfego Pago"
|
||||
className="w-full bg-black/50 text-white text-base font-bold border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:border-orange-500/50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Categoria</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50"
|
||||
>
|
||||
{CATEGORIES.map(cat => (
|
||||
<option key={cat} value={cat} className="bg-zinc-900">{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Instrutor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instructor}
|
||||
onChange={(e) => setInstructor(e.target.value)}
|
||||
placeholder="Nome do Instrutor"
|
||||
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 uppercase font-bold tracking-wider mb-1 block">Descrição do Curso</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="O que os alunos vão aprender e dominar neste curso?"
|
||||
className="w-full bg-black/50 text-white border border-white/10 rounded-xl px-4 py-2.5 text-xs focus:outline-none focus:border-orange-500/50 resize-none leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Downloadable Materials Section */}
|
||||
<div className="bg-black/40 p-4 rounded-2xl border border-white/10 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-orange-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Download className="w-4 h-4" />
|
||||
Materiais Complementares ({attachments.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Add attachment inputs */}
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttName}
|
||||
onChange={(e) => setNewAttName(e.target.value)}
|
||||
placeholder="Nome do arquivo (ex: Planilha_ROAS.xlsx)"
|
||||
className="col-span-2 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<select
|
||||
value={newAttType}
|
||||
onChange={(e) => setNewAttType(e.target.value as AttachmentType)}
|
||||
className="bg-zinc-900 border border-white/10 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none"
|
||||
>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="spreadsheet">Planilha</option>
|
||||
<option value="zip">Pacote ZIP</option>
|
||||
<option value="doc">Documento</option>
|
||||
<option value="link">Link</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttSize}
|
||||
onChange={(e) => setNewAttSize(e.target.value)}
|
||||
placeholder="Tamanho (ex: 2.4 MB)"
|
||||
className="w-28 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newAttDesc}
|
||||
onChange={(e) => setNewAttDesc(e.target.value)}
|
||||
placeholder="Breve descrição do material..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2.5 py-1.5 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddAttachment}
|
||||
className="bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg text-white text-xs font-bold shrink-0 transition-colors"
|
||||
>
|
||||
Adicionar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List of Attachments */}
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto pr-1 pt-1">
|
||||
{attachments.map((att) => (
|
||||
<div key={att.id} className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||
{getAttachmentIcon(att.type)}
|
||||
<span className="text-gray-200 font-medium truncate">{att.name}</span>
|
||||
<span className="text-[10px] text-gray-500 font-mono">({att.size || '1 MB'})</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
className="text-gray-500 hover:text-red-400 p-1"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Lessons Builder */}
|
||||
<div className="flex-1 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="hidden lg:flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-extrabold text-white">
|
||||
{initialData ? 'Gerenciar Trilha & Aulas' : 'Estruturar Grade de Aulas'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 bg-white/5 rounded-full hover:bg-white/20 transition-colors">
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/50 rounded-2xl border border-white/10 overflow-hidden mb-6">
|
||||
<div className="p-4 border-b border-white/5 bg-zinc-900/50 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListVideo className="w-4 h-4 text-orange-400" />
|
||||
<h3 className="font-bold text-white text-sm">Grade do Curso</h3>
|
||||
</div>
|
||||
<span className="bg-orange-500/10 text-orange-400 border border-orange-500/20 px-3 py-0.5 rounded-full text-xs font-bold">
|
||||
{lessons.length} Aulas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Lesson List */}
|
||||
<div className="overflow-y-auto p-4 space-y-2.5 max-h-[300px]">
|
||||
{lessons.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-gray-500 space-y-2">
|
||||
<ListVideo className="w-8 h-8 opacity-20 text-orange-400" />
|
||||
<p className="text-xs">Nenhuma aula cadastrada ainda. Adicione abaixo.</p>
|
||||
</div>
|
||||
)}
|
||||
{lessons.map((lesson, index) => (
|
||||
<div key={lesson.id} className="flex items-center gap-3 bg-white/[0.04] p-3 rounded-xl border border-white/5 group hover:border-orange-500/30 transition-all">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-lg bg-white/10 text-gray-400 font-mono text-xs font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-medium text-xs md:text-sm truncate">{lesson.title}</h4>
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-500 mt-0.5">
|
||||
<span className="flex items-center gap-1"><Video className="w-3 h-3 text-orange-400/70"/> {lesson.duration}</span>
|
||||
{lesson.isFree ? (
|
||||
<span className="text-emerald-400 font-bold bg-emerald-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">GRÁTIS</span>
|
||||
) : (
|
||||
<span className="text-purple-400 font-bold bg-purple-500/10 px-1.5 py-0.2 rounded text-[9px] uppercase">EXCLUSIVA</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleLessonAccess(lesson.id)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${lesson.isFree ? 'text-emerald-400 hover:bg-emerald-500/10' : 'text-purple-400 hover:bg-purple-500/10'}`}
|
||||
title={lesson.isFree ? "Tornar Exclusiva para Membros" : "Tornar Grátis"}
|
||||
>
|
||||
{lesson.isFree ? <Unlock className="w-4 h-4" /> : <Lock className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLesson(lesson.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Lesson Form Box */}
|
||||
<div className="p-4 bg-zinc-950 border-t border-white/5 space-y-3">
|
||||
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5 text-orange-400" />
|
||||
Cadastrar Nova Aula
|
||||
</h4>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonTitle}
|
||||
onChange={(e) => setNewLessonTitle(e.target.value)}
|
||||
placeholder="Título da Aula (ex: 01. Configuração do Pixel)"
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonDuration}
|
||||
onChange={(e) => setNewLessonDuration(e.target.value)}
|
||||
placeholder="Duração (ex: 25m)"
|
||||
className="w-28 bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Video Source Tabs */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex bg-white/5 p-0.5 rounded-lg border border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVideoSourceType('upload')}
|
||||
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'upload' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
|
||||
>
|
||||
Vídeo MP4
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVideoSourceType('link')}
|
||||
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${videoSourceType === 'link' ? 'bg-orange-500 text-white' : 'text-gray-400'}`}
|
||||
>
|
||||
URL Externa
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{videoSourceType === 'upload' ? (
|
||||
<div
|
||||
onClick={() => lessonFileInputRef.current?.click()}
|
||||
className="border border-dashed border-white/20 hover:border-orange-500/50 rounded-xl py-2 px-3 text-center cursor-pointer transition-all bg-white/[0.02]"
|
||||
>
|
||||
<input
|
||||
ref={lessonFileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleLessonVideoFileSelect}
|
||||
/>
|
||||
<span className="text-xs text-gray-300 truncate block">
|
||||
{selectedFileName ? `Arquivo: ${selectedFileName}` : 'Selecionar arquivo de vídeo'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={newLessonVideoUrl}
|
||||
onChange={(e) => setNewLessonVideoUrl(e.target.value)}
|
||||
placeholder="https://commondatastorage.googleapis.com/...mp4"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-xs text-white focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddLesson}
|
||||
className="bg-white hover:bg-orange-500 hover:text-white text-black font-bold px-4 py-2 rounded-xl text-xs transition-all shrink-0"
|
||||
>
|
||||
Adicionar Aula
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 text-white font-extrabold text-base py-4 rounded-2xl active:scale-95 transition-all shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
{isSubmitting ? 'Salvando...' : (initialData ? 'Salvar Alterações no Curso' : 'Publicar Curso & Materiais')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ManageCourses: React.FC = () => {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCourse, setEditingCourse] = useState<Course | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'courses' | 'comments'>('courses');
|
||||
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
|
||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [isGeneratingReply, setIsGeneratingReply] = useState(false);
|
||||
|
||||
const { showToast } = useToast();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const data = await getCourses();
|
||||
setCourses(data);
|
||||
|
||||
// Load all comments
|
||||
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = [];
|
||||
for (const c of data) {
|
||||
const courseComments = await getComments(c.id);
|
||||
courseComments.forEach(comm => {
|
||||
commentsAccumulator.push({ comment: comm, courseTitle: c.title });
|
||||
});
|
||||
}
|
||||
setAllComments(commentsAccumulator);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingCourse(undefined);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (course: Course) => {
|
||||
setEditingCourse(course);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Tem certeza que deseja excluir este curso?')) {
|
||||
await deleteCourse(id);
|
||||
showToast('Curso removido com sucesso!', 'success');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (course: Course) => {
|
||||
await saveCourse(course);
|
||||
showToast('Curso e materiais salvos com sucesso!', 'success');
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleSendReply = async (commentId: string) => {
|
||||
if (!replyText.trim()) return;
|
||||
|
||||
const target = allComments.find(c => c.comment.id === commentId);
|
||||
if (target) {
|
||||
const updatedComment: Comment = {
|
||||
...target.comment,
|
||||
reply: replyText,
|
||||
replyDate: new Date().toLocaleDateString('pt-BR')
|
||||
};
|
||||
await saveComment(updatedComment);
|
||||
showToast('Resposta enviada com sucesso!', 'success');
|
||||
setReplyingTo(null);
|
||||
setReplyText('');
|
||||
loadData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateAIReply = async (commentText: string, courseTitle: string) => {
|
||||
setIsGeneratingReply(true);
|
||||
try {
|
||||
const aiReply = await generateStudentReply(commentText, courseTitle);
|
||||
setReplyText(aiReply);
|
||||
} catch (e) {
|
||||
showToast('Erro ao gerar resposta com IA.', 'error');
|
||||
} finally {
|
||||
setIsGeneratingReply(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Metrics
|
||||
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
|
||||
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0);
|
||||
const totalMaterials = courses.reduce((acc, c) => {
|
||||
const globalCount = c.attachments?.length || 0;
|
||||
const lessonCount = c.lessons.reduce((lAcc, l) => lAcc + (l.attachments?.length || 0), 0);
|
||||
return acc + globalCount + lessonCount;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#09090b] text-white pt-24 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-8 border-b border-white/10">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-caption font-semibold uppercase tracking-caption text-orange-400 mb-2">
|
||||
<span>Compor HUB</span>
|
||||
<span>•</span>
|
||||
<span>Painel do Instrutor</span>
|
||||
</div>
|
||||
<h1 className="font-display text-subheading sm:text-heading-sm md:text-heading font-bold tracking-heading leading-heading">
|
||||
Gestão de Cursos & Materiais
|
||||
</h1>
|
||||
<p className="text-body-sm font-normal text-gray-400 mt-1 leading-body-sm tracking-body-sm">
|
||||
Cadastre videoaulas, organize trilhas e disponibilize arquivos de apoio para download.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm px-6 py-3 rounded-[980px] shadow-xl shadow-orange-500/20 transition-all active:scale-95"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Novo Curso</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 my-8">
|
||||
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Cursos</span>
|
||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{courses.length}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Aulas</span>
|
||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-orange-400 mt-1">{totalLessons}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Materiais para Download</span>
|
||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-emerald-400 mt-1">{totalMaterials}</p>
|
||||
</div>
|
||||
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Visualizações Acumuladas</span>
|
||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalViews.toLocaleString('pt-BR')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-4 border-b border-white/10 mb-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('courses')}
|
||||
className={`pb-3 text-body-sm font-semibold tracking-body-sm transition-all border-b-2 ${
|
||||
activeTab === 'courses'
|
||||
? 'border-orange-500 text-white'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Cursos Cadastrados ({courses.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('comments')}
|
||||
className={`pb-3 text-body-sm font-semibold tracking-body-sm transition-all border-b-2 flex items-center gap-2 ${
|
||||
activeTab === 'comments'
|
||||
? 'border-orange-500 text-white'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Dúvidas dos Alunos ({allComments.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab: Courses List */}
|
||||
{activeTab === 'courses' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{courses.map(course => {
|
||||
const courseAttachments = (course.attachments?.length || 0) + course.lessons.reduce((acc, l) => acc + (l.attachments?.length || 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={course.id}
|
||||
className="bg-zinc-900/80 border border-white/10 rounded-[8px] overflow-hidden flex flex-col justify-between hover:border-orange-500/40 transition-all duration-300 group shadow-xl"
|
||||
>
|
||||
<div>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video relative overflow-hidden bg-black rounded-t-[8px]">
|
||||
<img
|
||||
src={course.thumbnail}
|
||||
alt={course.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute top-3 left-3 bg-black/70 backdrop-blur-md px-2.5 py-1 rounded-[980px] text-caption font-semibold uppercase tracking-caption text-white border border-white/10">
|
||||
{course.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5">
|
||||
<h3 className="font-display font-bold text-subheading text-white mb-2 line-clamp-1 group-hover:text-orange-400 transition-colors tracking-subheading leading-subheading">
|
||||
{course.title}
|
||||
</h3>
|
||||
<p className="text-caption text-gray-400 line-clamp-2 leading-caption tracking-caption font-normal mb-4">
|
||||
{course.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 text-caption text-gray-400 pt-3 border-t border-white/5 font-semibold tracking-caption">
|
||||
<span className="flex items-center gap-1 text-gray-300 font-semibold">
|
||||
<Video className="w-3.5 h-3.5 text-orange-400" />
|
||||
{course.lessons.length} Aulas
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1 text-gray-300 font-semibold">
|
||||
<Download className="w-3.5 h-3.5 text-emerald-400" />
|
||||
{courseAttachments} Materiais
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 bg-black/40 border-t border-white/5 flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(course)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-[980px] bg-white/5 hover:bg-orange-500 hover:text-white text-body-sm font-semibold tracking-body-sm transition-colors text-gray-200"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
Editar Conteúdo
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(course.id)}
|
||||
className="p-2.5 rounded-[980px] bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Excluir Curso"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Comments / Questions */}
|
||||
{activeTab === 'comments' && (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
{allComments.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<MessageSquare className="w-12 h-12 mx-auto mb-3 opacity-30 text-orange-400" />
|
||||
<p className="text-body-sm font-normal text-gray-400">Nenhuma dúvida enviada por alunos até o momento.</p>
|
||||
</div>
|
||||
) : (
|
||||
allComments.map(({ comment, courseTitle }) => (
|
||||
<div key={comment.id} className="p-6 rounded-[8px] bg-zinc-900/60 border border-white/10 space-y-4 shadow-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-caption text-orange-400 font-semibold uppercase tracking-caption">{courseTitle}</span>
|
||||
<h4 className="font-semibold text-white text-body-sm tracking-body-sm">{comment.user}</h4>
|
||||
</div>
|
||||
<span className="text-caption text-gray-500 font-mono">{comment.date}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-body-sm text-gray-300 bg-black/30 p-3.5 rounded-[8px] border border-white/5 leading-body-sm tracking-body-sm font-normal">
|
||||
{comment.text}
|
||||
</p>
|
||||
|
||||
{comment.reply ? (
|
||||
<div className="pl-4 border-l-2 border-orange-500 bg-white/[0.02] p-3 rounded-r-[8px]">
|
||||
<span className="text-caption font-semibold text-orange-400 uppercase tracking-caption">Sua Resposta ({comment.replyDate}):</span>
|
||||
<p className="text-body-sm text-gray-200 mt-1 leading-body-sm">{comment.reply}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{replyingTo === comment.id ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Escreva a resposta para o aluno..."
|
||||
className="w-full bg-black/60 border border-white/10 rounded-[8px] p-3 text-body-sm text-white focus:outline-none focus:border-orange-500/50 resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerateAIReply(comment.text, courseTitle)}
|
||||
disabled={isGeneratingReply}
|
||||
className="inline-flex items-center gap-1.5 text-body-sm text-orange-400 hover:text-orange-300 font-semibold"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{isGeneratingReply ? 'Gerando com IA...' : 'Sugerir Resposta com IA'}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setReplyingTo(null); setReplyText(''); }}
|
||||
className="px-3.5 py-2 rounded-[980px] text-body-sm text-gray-400 hover:text-white font-semibold"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSendReply(comment.id)}
|
||||
className="px-5 py-2 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm shadow-xl"
|
||||
>
|
||||
Enviar Resposta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setReplyingTo(comment.id)}
|
||||
className="text-body-sm font-semibold text-orange-400 hover:text-orange-300 tracking-body-sm"
|
||||
>
|
||||
Responder Dúvida →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<CourseModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
initialData={editingCourse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
948
pages/MaterialsPage.tsx
Normal file
948
pages/MaterialsPage.tsx
Normal file
@@ -0,0 +1,948 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileArchive,
|
||||
FileCode,
|
||||
Link as LinkIcon,
|
||||
Search,
|
||||
Download,
|
||||
ChevronRight,
|
||||
ArrowLeft,
|
||||
Sparkles,
|
||||
Copy,
|
||||
BookOpen,
|
||||
Undo2,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
List,
|
||||
HardDrive,
|
||||
X,
|
||||
Play
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Course, Attachment, AttachmentType } from '../types';
|
||||
import { getCourses } from '../services/db';
|
||||
import { useToast } from '../context/ToastContext';
|
||||
|
||||
interface MaterialsPageProps {
|
||||
onPlayCourse: (course: Course) => void;
|
||||
}
|
||||
|
||||
interface MaterialFileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
formatType: AttachmentType;
|
||||
size: string;
|
||||
modified: string;
|
||||
course: Course;
|
||||
lessonTitle?: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const MaterialsPage: React.FC<MaterialsPageProps> = ({ onPlayCourse }) => {
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Navigation & View Mode State
|
||||
const [activeFolder, setActiveFolder] = useState<string>('downloads');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||
const [selectedItem, setSelectedItem] = useState<MaterialFileItem | null>(null);
|
||||
const [previewItem, setPreviewItem] = useState<MaterialFileItem | null>(null);
|
||||
|
||||
// Filter Chip State (All, XLSX, PDF, ZIP, DOC)
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
|
||||
// Table Sort State
|
||||
const [sortBy, setSortBy] = useState<'name' | 'type' | 'size' | 'modified'>('name');
|
||||
const [sortAsc, setSortAsc] = useState<boolean>(true);
|
||||
|
||||
// Context Menu State
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
item: MaterialFileItem | null;
|
||||
}>({ visible: false, x: 0, y: 0, item: null });
|
||||
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { showToast, showDownloadToast } = useToast();
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
getCourses().then(data => {
|
||||
setCourses(data);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}
|
||||
};
|
||||
window.addEventListener('click', handleClickOutside);
|
||||
return () => window.removeEventListener('click', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Format type helper
|
||||
const getFileExtension = (att: Attachment) => {
|
||||
if (att.type === 'spreadsheet') return 'XLSX';
|
||||
if (att.type === 'pdf') return 'PDF';
|
||||
if (att.type === 'zip') return 'ZIP';
|
||||
if (att.type === 'doc') return 'DOC';
|
||||
if (att.type === 'link') return 'JSON';
|
||||
return 'TXT';
|
||||
};
|
||||
|
||||
// Compile file list
|
||||
const allFiles: MaterialFileItem[] = useMemo(() => {
|
||||
const list: MaterialFileItem[] = [];
|
||||
courses.forEach((course, cIdx) => {
|
||||
if (course.attachments && course.attachments.length > 0) {
|
||||
course.attachments.forEach((att, aIdx) => {
|
||||
const fileId = `f-c-${course.id}-${att.id || aIdx}`;
|
||||
list.push({
|
||||
id: fileId,
|
||||
name: att.name,
|
||||
type: getFileExtension(att),
|
||||
formatType: att.type,
|
||||
size: att.size || `${(aIdx + 1) * 14 + 120} KB`,
|
||||
modified: `2026. 08. 1${(aIdx % 8) + 1}`,
|
||||
course,
|
||||
description: att.description,
|
||||
url: att.url || '#'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (course.lessons) {
|
||||
course.lessons.forEach((lesson, lIdx) => {
|
||||
if (lesson.attachments && lesson.attachments.length > 0) {
|
||||
lesson.attachments.forEach((att, laIdx) => {
|
||||
const fileId = `f-l-${course.id}-${lesson.id}-${att.id || laIdx}`;
|
||||
list.push({
|
||||
id: fileId,
|
||||
name: att.name,
|
||||
type: getFileExtension(att),
|
||||
formatType: att.type,
|
||||
size: att.size || `${(lIdx + 2) * 22 + 210} KB`,
|
||||
modified: `2026. 08. 1${(lIdx % 9) + 1}`,
|
||||
course,
|
||||
lessonTitle: lesson.title,
|
||||
description: att.description,
|
||||
url: att.url || '#'
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}, [courses]);
|
||||
|
||||
// Set default selected item if none selected
|
||||
useEffect(() => {
|
||||
if (allFiles.length > 0 && !selectedItem) {
|
||||
setSelectedItem(allFiles[0]);
|
||||
}
|
||||
}, [allFiles, selectedItem]);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
return Array.from(new Set(courses.map(c => c.category)));
|
||||
}, [courses]);
|
||||
|
||||
// Filtered and Sorted Files
|
||||
const displayedFiles = useMemo(() => {
|
||||
return allFiles.filter(file => {
|
||||
// Type Chip filter
|
||||
if (typeFilter !== 'all') {
|
||||
if (typeFilter === 'xlsx' && file.type !== 'XLSX') return false;
|
||||
if (typeFilter === 'pdf' && file.type !== 'PDF') return false;
|
||||
if (typeFilter === 'zip' && file.type !== 'ZIP') return false;
|
||||
if (typeFilter === 'doc' && file.type !== 'DOC' && file.type !== 'TXT') return false;
|
||||
}
|
||||
|
||||
// Search Query
|
||||
if (searchTerm.trim()) {
|
||||
const query = searchTerm.toLowerCase();
|
||||
const matchesName = file.name.toLowerCase().includes(query);
|
||||
const matchesType = file.type.toLowerCase().includes(query);
|
||||
const matchesCourse = file.course.title.toLowerCase().includes(query);
|
||||
const matchesDesc = file.description?.toLowerCase().includes(query);
|
||||
if (!matchesName && !matchesType && !matchesCourse && !matchesDesc) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Folder navigation filter
|
||||
if (activeFolder === 'downloads' || activeFolder === 'my-files' || activeFolder === 'recent') {
|
||||
return true;
|
||||
}
|
||||
if (activeFolder === 'zip') {
|
||||
return file.type === 'ZIP';
|
||||
}
|
||||
if (activeFolder === 'pdf') {
|
||||
return file.type === 'PDF';
|
||||
}
|
||||
if (activeFolder === 'xlsx') {
|
||||
return file.type === 'XLSX';
|
||||
}
|
||||
if (categories.includes(activeFolder)) {
|
||||
return file.course.category === activeFolder;
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
|
||||
if (sortBy === 'type') cmp = a.type.localeCompare(b.type);
|
||||
if (sortBy === 'size') cmp = a.size.localeCompare(b.size);
|
||||
if (sortBy === 'modified') cmp = a.modified.localeCompare(b.modified);
|
||||
return sortAsc ? cmp : -cmp;
|
||||
});
|
||||
}, [allFiles, searchTerm, activeFolder, typeFilter, sortBy, sortAsc, categories]);
|
||||
|
||||
const handleSort = (column: 'name' | 'type' | 'size' | 'modified') => {
|
||||
if (sortBy === column) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortAsc(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (item: MaterialFileItem) => {
|
||||
setDownloadingId(item.id);
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
setTimeout(() => {
|
||||
setDownloadingId(null);
|
||||
showDownloadToast(item.name, item.size);
|
||||
const link = document.createElement('a');
|
||||
link.href = item.url;
|
||||
link.download = item.name;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: MaterialFileItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedItem(item);
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: Math.min(e.clientX, window.innerWidth - 240),
|
||||
y: Math.min(e.clientY, window.innerHeight - 280),
|
||||
item
|
||||
});
|
||||
};
|
||||
|
||||
// Color Coded File Icon Component with Dark Theme Styling
|
||||
const renderFileIcon = (type: string, sizeClass: string = "w-5 h-5") => {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'XLSX':
|
||||
case 'SPREADSHEET':
|
||||
return <FileSpreadsheet className={`${sizeClass} text-emerald-400 shrink-0`} />;
|
||||
case 'PDF':
|
||||
return <FileText className={`${sizeClass} text-rose-400 shrink-0`} />;
|
||||
case 'ZIP':
|
||||
case 'RAR':
|
||||
return <FileArchive className={`${sizeClass} text-amber-400 shrink-0`} />;
|
||||
case 'DOC':
|
||||
case 'MD':
|
||||
case 'TXT':
|
||||
return <FileCode className={`${sizeClass} text-blue-400 shrink-0`} />;
|
||||
default:
|
||||
return <LinkIcon className={`${sizeClass} text-orange-400 shrink-0`} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white selection:bg-orange-500/30 selection:text-white pt-24 pb-20 font-sans">
|
||||
|
||||
{/* Ambient background glow matching Home */}
|
||||
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
|
||||
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-[700px] h-[400px] bg-orange-500/5 rounded-full blur-[140px]" />
|
||||
<div className="absolute top-1/3 -right-20 w-[450px] h-[450px] bg-amber-500/5 rounded-full blur-[160px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-[1440px] mx-auto px-6 md:px-12 space-y-8">
|
||||
|
||||
{/* Breadcrumb + Header Hero */}
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-6 border-b border-white/10">
|
||||
<div className="space-y-3">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="hover:text-orange-400 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Início</span>
|
||||
</button>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-orange-400">Central de Recursos</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-white tracking-tight leading-tight">
|
||||
Biblioteca de <span className="text-orange-500">Materiais</span>
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-300 max-w-2xl font-normal leading-relaxed">
|
||||
Explore e faça o download de todas as planilhas de ROI, modelos de criativos, relatórios e arquivos complementares das aulas em um ambiente integrado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Search & Switcher Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||
{/* Search Input */}
|
||||
<div className="relative min-w-[260px] sm:min-w-[320px]">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Pesquisar material, planilha ou aula..."
|
||||
className="w-full bg-zinc-900/90 rounded-[980px] pl-11 pr-10 py-2.5 text-sm text-white placeholder:text-gray-400 font-normal focus:outline-none focus:ring-2 focus:ring-orange-500/30 transition-all"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Switcher: Grid vs List */}
|
||||
<div className="flex items-center bg-zinc-900/90 p-1 rounded-[980px] shrink-0 self-end sm:self-auto">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Visualização em Grade de Cards"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Grade</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-[980px] text-xs font-bold uppercase tracking-wider transition-all ${
|
||||
viewMode === 'list'
|
||||
? 'bg-orange-500 text-white shadow-lg shadow-orange-500/25'
|
||||
: 'text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="Visualização em Lista de Tabela"
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Lista</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Explorer Area */}
|
||||
<div className="flex flex-col lg:flex-row items-start gap-6">
|
||||
|
||||
{/* Left Sidebar: Drive Structure */}
|
||||
<aside className="w-full lg:w-64 bg-zinc-950/80 rounded-2xl p-4 shadow-xl shrink-0 select-none">
|
||||
|
||||
<div className="flex items-center gap-2.5 px-3 py-2 text-xs font-bold uppercase tracking-wider text-orange-400 pb-3 mb-2">
|
||||
<HardDrive className="w-4 h-4" />
|
||||
<span>Navegação do Drive</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 text-sm font-medium">
|
||||
|
||||
{/* Primary Folder */}
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => { setActiveFolder('downloads'); setTypeFilter('all'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all ${
|
||||
activeFolder === 'downloads' && typeFilter === 'all'
|
||||
? 'bg-orange-500/20 text-orange-400 font-bold'
|
||||
: 'text-gray-300 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="w-4 h-4 text-orange-400" />
|
||||
<span>Todos os Arquivos</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono px-2 py-0.5 rounded-[980px] bg-white/10 text-gray-300 font-semibold">
|
||||
{allFiles.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Trilhas & Categorias */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-400 px-3 pb-2 flex items-center justify-between">
|
||||
<span>Trilhas de Cursos</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{categories.map(cat => {
|
||||
const count = allFiles.filter(f => f.course.category === cat).length;
|
||||
const isActive = activeFolder === cat;
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => { setActiveFolder(cat); setTypeFilter('all'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center justify-between px-3.5 py-2 rounded-xl text-xs transition-all ${
|
||||
isActive
|
||||
? 'bg-orange-500/20 text-orange-400 font-bold'
|
||||
: 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 pr-1">
|
||||
<Folder className={`w-3.5 h-3.5 shrink-0 ${isActive ? 'text-orange-400 fill-orange-400/20' : 'text-gray-500'}`} />
|
||||
<span className="truncate">{cat}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formatos Rápidos */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wider text-gray-400 px-3 pb-2">
|
||||
Formatos de Download
|
||||
</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<button
|
||||
onClick={() => { setTypeFilter('xlsx'); setActiveFolder('downloads'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
|
||||
typeFilter === 'xlsx' ? 'bg-emerald-500/20 text-emerald-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Planilhas Excel (.xlsx)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setTypeFilter('pdf'); setActiveFolder('downloads'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
|
||||
typeFilter === 'pdf' ? 'bg-rose-500/20 text-rose-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-rose-400" />
|
||||
<span>Documentos PDF (.pdf)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { setTypeFilter('zip'); setActiveFolder('downloads'); setSelectedItem(null); }}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2 rounded-xl transition-all ${
|
||||
typeFilter === 'zip' ? 'bg-amber-500/20 text-amber-400 font-semibold' : 'text-gray-400 hover:bg-white/5 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<FileArchive className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Pacotes Compactados (.zip)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Storage Gauge */}
|
||||
<div className="mt-8 pt-4 border-t border-white/5 text-xs">
|
||||
<div className="flex items-center gap-2 text-gray-300 font-semibold mb-2">
|
||||
<HardDrive className="w-4 h-4 text-orange-400" />
|
||||
<span>Espaço de Arquivos</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-white/10 rounded-[980px] overflow-hidden mb-2">
|
||||
<div className="h-full w-1/3 bg-gradient-to-r from-orange-500 to-amber-500 rounded-[980px]"></div>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 leading-tight">
|
||||
Acesso Ilimitado de Alta Velocidade para Alunos Compor HUB.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
{/* Main Files Area */}
|
||||
<main className="flex-1 min-w-0 bg-zinc-950/80 rounded-2xl shadow-2xl overflow-hidden flex flex-col min-h-[700px]">
|
||||
|
||||
{/* Explorer Header */}
|
||||
<div className="p-5 sm:p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-white capitalize flex items-center gap-2">
|
||||
<span>
|
||||
{activeFolder === 'downloads'
|
||||
? (typeFilter !== 'all' ? `Filtro: ${typeFilter.toUpperCase()}` : 'Todos os Materiais')
|
||||
: activeFolder}
|
||||
</span>
|
||||
</h2>
|
||||
<span className="text-xs font-mono px-3 py-1 rounded-[980px] bg-white/10 text-orange-400 font-bold">
|
||||
{displayedFiles.length} {displayedFiles.length === 1 ? 'item' : 'itens'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(searchTerm || typeFilter !== 'all' || activeFolder !== 'downloads') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setTypeFilter('all');
|
||||
setActiveFolder('downloads');
|
||||
showToast('Filtros redefinidos.', 'info');
|
||||
}}
|
||||
className="px-4 py-2 rounded-[980px] text-xs font-semibold bg-white/5 hover:bg-white/10 text-gray-300 hover:text-white flex items-center gap-1.5 transition-colors"
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span>Limpar Filtros</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
showToast('Materiais organizados por relevância.', 'success');
|
||||
setSortBy('type');
|
||||
setSortAsc(true);
|
||||
}}
|
||||
className="px-4 py-2 rounded-[980px] text-xs font-bold bg-orange-500 hover:bg-orange-600 active:scale-95 text-white flex items-center gap-1.5 shadow-lg shadow-orange-500/25 transition-all"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>Ordenar por Tipo</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Files Content: Grid or List */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
{/* EMPTY STATE */}
|
||||
{displayedFiles.length === 0 ? (
|
||||
<div className="py-32 text-center text-gray-400 px-6">
|
||||
<div className="w-16 h-16 rounded-full bg-white/5 flex items-center justify-center mx-auto mb-4 text-orange-400">
|
||||
<Folder className="w-8 h-8 opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-1">Nenhum material encontrado</h3>
|
||||
<p className="text-sm text-gray-400 max-w-md mx-auto mb-6">
|
||||
Não encontramos arquivos correspondentes aos filtros selecionados. Tente ajustar o termo de pesquisa ou limpar os filtros.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setTypeFilter('all');
|
||||
setActiveFolder('downloads');
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white font-bold text-xs uppercase tracking-wider transition-all shadow-lg shadow-orange-500/25"
|
||||
>
|
||||
Ver Todos os Arquivos
|
||||
</button>
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* GRID VIEW */
|
||||
<div className="p-6 space-y-8">
|
||||
|
||||
{/* Category Fast-Folders (When in All or Category View) */}
|
||||
{activeFolder === 'downloads' && typeFilter === 'all' && !searchTerm && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3.5 flex items-center gap-2">
|
||||
<Folder className="w-4 h-4 text-orange-400" />
|
||||
<span>Pastas por Módulo</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3.5">
|
||||
{categories.map(cat => {
|
||||
const count = allFiles.filter(f => f.course.category === cat).length;
|
||||
return (
|
||||
<div
|
||||
key={cat}
|
||||
onClick={() => setActiveFolder(cat)}
|
||||
className="bg-zinc-900/60 hover:bg-zinc-900 rounded-xl p-4 cursor-pointer hover:shadow-xl transition-all flex items-center justify-between group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/15 flex items-center justify-center text-orange-400 shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Folder className="w-5 h-5 fill-orange-500/20" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-bold text-white truncate group-hover:text-orange-400 transition-colors">
|
||||
{cat}
|
||||
</h4>
|
||||
<p className="text-[11px] text-gray-400 font-mono mt-0.5">{count} arquivos</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-500 group-hover:text-orange-400 group-hover:translate-x-1 transition-all shrink-0" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Material Cards Grid */}
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-gray-400 mb-3.5 flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-orange-400" />
|
||||
<span>Arquivos para Download</span>
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
|
||||
{displayedFiles.map(file => {
|
||||
const isSelected = selectedItem?.id === file.id;
|
||||
const isDownloading = downloadingId === file.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setSelectedItem(file)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
className={`bg-zinc-900/60 hover:bg-zinc-900 rounded-xl overflow-hidden cursor-pointer transition-all hover:shadow-2xl flex flex-col justify-between group ${
|
||||
isSelected
|
||||
? 'ring-2 ring-orange-500 bg-zinc-900'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{/* Card Header with Icon */}
|
||||
<div className="p-4 bg-black/20 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderFileIcon(file.type, "w-6 h-6")}
|
||||
<div>
|
||||
<span className="text-[11px] font-bold font-mono uppercase px-2 py-0.5 rounded bg-white/10 text-gray-300">
|
||||
{file.type}
|
||||
</span>
|
||||
<span className="text-[11px] font-mono text-gray-500 ml-2">
|
||||
{file.size}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewItem(file);
|
||||
}}
|
||||
className="p-1.5 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Pré-visualizar detalhes"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-4 flex-1 flex flex-col justify-between space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-bold text-white line-clamp-2 group-hover:text-orange-400 transition-colors leading-snug mb-1.5">
|
||||
{file.name}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-400 line-clamp-1 font-medium flex items-center gap-1.5">
|
||||
<BookOpen className="w-3.5 h-3.5 text-orange-400 shrink-0" />
|
||||
<span className="truncate">{file.course.title}</span>
|
||||
</p>
|
||||
{file.lessonTitle && (
|
||||
<p className="text-[11px] text-gray-500 line-clamp-1 mt-1 font-normal">
|
||||
Aula: {file.lessonTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Footer Actions */}
|
||||
<div className="pt-3 flex items-center justify-between gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlayCourse(file.course);
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-orange-400 flex items-center gap-1 transition-colors truncate"
|
||||
title="Assistir aula associada"
|
||||
>
|
||||
<Play className="w-3 h-3 text-orange-400 shrink-0" />
|
||||
<span className="truncate">Ver Aula</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
disabled={isDownloading}
|
||||
className="px-4 py-2 rounded-[980px] bg-orange-500 hover:bg-orange-600 active:scale-95 text-white text-xs font-bold flex items-center gap-1.5 shadow-md shadow-orange-500/20 transition-all shrink-0"
|
||||
>
|
||||
<Download className={`w-3.5 h-3.5 ${isDownloading ? 'animate-bounce' : ''}`} />
|
||||
<span>{isDownloading ? 'Baixando...' : 'Baixar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : (
|
||||
/* LIST VIEW (TABLE) */
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-black/60 text-xs font-bold uppercase tracking-wider text-gray-400 select-none sticky top-0 z-10 backdrop-blur-md">
|
||||
|
||||
<th
|
||||
onClick={() => handleSort('name')}
|
||||
className="py-4 px-6 cursor-pointer hover:text-white transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Nome do Arquivo</span>
|
||||
{sortBy === 'name' && (
|
||||
<span className="text-orange-400 font-bold">{sortAsc ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<th className="py-4 px-6 hidden md:table-cell">
|
||||
<span>Curso / Trilha</span>
|
||||
</th>
|
||||
|
||||
<th className="py-4 px-6 text-center w-36">
|
||||
<span>Ações</span>
|
||||
</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-white/5 text-sm font-medium">
|
||||
{displayedFiles.map((file) => {
|
||||
const isSelected = selectedItem?.id === file.id;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
onClick={() => setSelectedItem(file)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
className={`group transition-all cursor-pointer select-none ${
|
||||
isSelected
|
||||
? 'bg-orange-500/10'
|
||||
: 'hover:bg-white/[0.03]'
|
||||
}`}
|
||||
>
|
||||
{/* Name column */}
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-3.5">
|
||||
{renderFileIcon(file.type, "w-6 h-6")}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold transition-colors truncate text-sm ${
|
||||
isSelected ? 'text-orange-400' : 'text-white group-hover:text-orange-400'
|
||||
}`}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{file.lessonTitle && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5 font-normal">
|
||||
Aula: {file.lessonTitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Course / Owner */}
|
||||
<td className="py-4 px-6 hidden md:table-cell text-gray-300">
|
||||
<span className="inline-flex items-center gap-1.5 bg-zinc-900 px-3 py-1 rounded-[980px] text-xs font-semibold text-gray-300">
|
||||
<BookOpen className="w-3.5 h-3.5 text-orange-400 shrink-0" />
|
||||
<span className="truncate max-w-[200px]">{file.course.title}</span>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-4 px-6 text-center">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(file);
|
||||
}}
|
||||
className="p-2 rounded-[980px] bg-orange-500/10 hover:bg-orange-500 text-orange-400 hover:text-white transition-all shadow-sm"
|
||||
title="Baixar arquivo"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewItem(file);
|
||||
}}
|
||||
className="p-2 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
title="Ver detalhes"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlayCourse(file.course);
|
||||
}}
|
||||
className="p-2 rounded-[980px] bg-white/5 hover:bg-white/10 text-gray-400 hover:text-orange-400 transition-colors"
|
||||
title="Assistir aula"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Table Footer */}
|
||||
<div className="p-4 px-6 bg-black/40 flex items-center justify-between text-xs text-gray-400">
|
||||
<span className="font-semibold">{displayedFiles.length} item(s) listados</span>
|
||||
<span className="hidden sm:inline">Dica: Clique com o botão direito para o menu de ações rápido</span>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* QUICK PREVIEW MODAL */}
|
||||
{previewItem && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-zinc-950 border border-white/15 rounded-[12px] max-w-lg w-full p-6 shadow-2xl space-y-6 animate-in zoom-in-95 duration-200">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
{renderFileIcon(previewItem.type)}
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white truncate max-w-xs">{previewItem.name}</h3>
|
||||
<p className="text-xs text-gray-400 font-mono">{previewItem.size} • {previewItem.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreviewItem(null)}
|
||||
className="p-1.5 rounded-full hover:bg-white/10 text-gray-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="p-4 rounded-[8px] bg-zinc-900 border border-white/10 space-y-2">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-400">Trilha:</span>
|
||||
<span className="text-white font-semibold">{previewItem.course.title}</span>
|
||||
</div>
|
||||
{previewItem.lessonTitle && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-400">Aula:</span>
|
||||
<span className="text-white font-semibold">{previewItem.lessonTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-gray-400">Instrutor:</span>
|
||||
<span className="text-orange-400 font-semibold">{previewItem.course.instructor.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewItem.description && (
|
||||
<div className="text-xs text-gray-300 leading-relaxed bg-white/5 p-3 rounded-[8px] border border-white/10">
|
||||
{previewItem.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onPlayCourse(previewItem.course);
|
||||
setPreviewItem(null);
|
||||
}}
|
||||
className="px-5 py-2.5 rounded-[980px] bg-white/10 hover:bg-white/15 text-white text-xs font-bold flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Play className="w-4 h-4 text-orange-400" />
|
||||
<span>Assistir Aula</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDownload(previewItem);
|
||||
setPreviewItem(null);
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-[980px] bg-orange-500 hover:bg-orange-600 text-white text-xs font-bold flex items-center gap-2 shadow-lg shadow-orange-500/25 transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Baixar Material</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Menu Popup */}
|
||||
{contextMenu.visible && contextMenu.item && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
className="fixed z-50 w-60 bg-zinc-950 border border-white/15 rounded-[12px] shadow-2xl py-2 text-xs text-gray-200 animate-in fade-in zoom-in-95 duration-100 select-none"
|
||||
>
|
||||
|
||||
<button
|
||||
onClick={() => handleDownload(contextMenu.item!)}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-orange-500 hover:text-white flex items-center gap-2.5 transition-colors font-bold text-orange-400"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Fazer Download</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onPlayCourse(contextMenu.item!.course);
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-2.5 transition-colors font-medium text-white"
|
||||
>
|
||||
<Play className="w-4 h-4 text-orange-400" />
|
||||
<span>Assistir Aula do Curso</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.origin + contextMenu.item!.url);
|
||||
showToast('Link do arquivo copiado!', 'info');
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}}
|
||||
className="w-full text-left px-4 py-2.5 hover:bg-white/10 flex items-center gap-2.5 transition-colors font-medium text-gray-300"
|
||||
>
|
||||
<Copy className="w-4 h-4 text-gray-400" />
|
||||
<span>Copiar Link do Arquivo</span>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-white/10 my-1"></div>
|
||||
|
||||
<button
|
||||
onClick={() => setContextMenu(prev => ({ ...prev, visible: false }))}
|
||||
className="w-full text-left px-4 py-2 hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<span>Fechar</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
public/Compor Hub-negativo.svg
Normal file
30
public/Compor Hub-negativo.svg
Normal file
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 90" fill="none" width="100%" height="100%">
|
||||
<!-- "compor" in white for dark background -->
|
||||
<text
|
||||
x="5"
|
||||
y="66"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="64"
|
||||
font-weight="900"
|
||||
fill="#FFFFFF"
|
||||
letter-spacing="-2px"
|
||||
>compor</text>
|
||||
|
||||
<!-- 3 Orange Dots in precise triangle (2 top, 1 bottom) -->
|
||||
<g fill="#EA721D">
|
||||
<circle cx="282" cy="22" r="8" />
|
||||
<circle cx="304" cy="22" r="8" />
|
||||
<circle cx="293" cy="38" r="8" />
|
||||
</g>
|
||||
|
||||
<!-- "HUB" in Orange -->
|
||||
<text
|
||||
x="326"
|
||||
y="66"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="66"
|
||||
font-weight="900"
|
||||
fill="#EA721D"
|
||||
letter-spacing="0px"
|
||||
>HUB</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 935 B |
30
public/Compor Hub.svg
Normal file
30
public/Compor Hub.svg
Normal file
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 90" fill="none" width="100%" height="100%">
|
||||
<!-- "compor" in dark charcoal for light background -->
|
||||
<text
|
||||
x="5"
|
||||
y="66"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="64"
|
||||
font-weight="900"
|
||||
fill="#222222"
|
||||
letter-spacing="-2px"
|
||||
>compor</text>
|
||||
|
||||
<!-- 3 Orange Dots in precise triangle (2 top, 1 bottom) -->
|
||||
<g fill="#EA721D">
|
||||
<circle cx="282" cy="22" r="8" />
|
||||
<circle cx="304" cy="22" r="8" />
|
||||
<circle cx="293" cy="38" r="8" />
|
||||
</g>
|
||||
|
||||
<!-- "HUB" in Orange -->
|
||||
<text
|
||||
x="326"
|
||||
y="66"
|
||||
font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Montserrat', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
|
||||
font-size="66"
|
||||
font-weight="900"
|
||||
fill="#EA721D"
|
||||
letter-spacing="0px"
|
||||
>HUB</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 944 B |
12
public/favicon.svg
Normal file
12
public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="favOrangeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FB923C" />
|
||||
<stop offset="100%" stop-color="#EA580C" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="48" fill="#121214" />
|
||||
<circle cx="38" cy="40" r="14" fill="url(#favOrangeGrad)" />
|
||||
<circle cx="68" cy="40" r="14" fill="url(#favOrangeGrad)" />
|
||||
<circle cx="53" cy="66" r="14" fill="url(#favOrangeGrad)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 537 B |
19
public/logo-dark-bg.svg
Normal file
19
public/logo-dark-bg.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 540 100" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="hubOrangeGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FB923C" />
|
||||
<stop offset="100%" stop-color="#EA580C" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- "compor" in negative (white for dark backgrounds) -->
|
||||
<text x="0" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="#FFFFFF" letter-spacing="-2px">compor</text>
|
||||
|
||||
<!-- 3 Orange Dots Brand Icon -->
|
||||
<circle cx="316" cy="18" r="9" fill="url(#hubOrangeGrad)" />
|
||||
<circle cx="344" cy="18" r="9" fill="url(#hubOrangeGrad)" />
|
||||
<circle cx="330" cy="38" r="9" fill="url(#hubOrangeGrad)" />
|
||||
|
||||
<!-- "HUB" in vibrant orange -->
|
||||
<text x="368" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="url(#hubOrangeGrad)" letter-spacing="-0.5px">HUB</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 994 B |
19
public/logo-light-bg.svg
Normal file
19
public/logo-light-bg.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 540 100" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="hubOrangeGradLight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FB923C" />
|
||||
<stop offset="100%" stop-color="#EA580C" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- "compor" in dark charcoal/cinza escuro for light backgrounds -->
|
||||
<text x="0" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="#1C1E21" letter-spacing="-2px">compor</text>
|
||||
|
||||
<!-- 3 Orange Dots Brand Icon -->
|
||||
<circle cx="316" cy="18" r="9" fill="url(#hubOrangeGradLight)" />
|
||||
<circle cx="344" cy="18" r="9" fill="url(#hubOrangeGradLight)" />
|
||||
<circle cx="330" cy="38" r="9" fill="url(#hubOrangeGradLight)" />
|
||||
|
||||
<!-- "HUB" in vibrant orange -->
|
||||
<text x="368" y="74" font-family="system-ui, -apple-system, 'Inter', 'Segoe UI', sans-serif" font-weight="900" font-size="74" fill="url(#hubOrangeGradLight)" letter-spacing="-0.5px">HUB</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
33
services/ai.ts
Normal file
33
services/ai.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
|
||||
export const generateStudentReply = async (studentComment: string, courseContext: string): Promise<string> => {
|
||||
try {
|
||||
const apiKey = process.env.API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn("EduClean: API Key is missing. AI features are disabled.");
|
||||
return "Peço desculpas, mas não consigo gerar uma resposta no momento. Por favor, contate o professor diretamente.";
|
||||
}
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey });
|
||||
|
||||
const prompt = `
|
||||
Você é um assistente de professor útil, encorajador e claro.
|
||||
Contexto: O aluno está fazendo uma pergunta sobre o curso: "${courseContext}".
|
||||
Pergunta do Aluno: "${studentComment}"
|
||||
|
||||
Tarefa: Escreva uma resposta concisa, didática e amigável (máximo de 2 frases) em Português do Brasil.
|
||||
Responda diretamente ao ponto do aluno.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: 'gemini-3-flash-preview',
|
||||
contents: prompt,
|
||||
});
|
||||
|
||||
return response.text || "";
|
||||
} catch (error) {
|
||||
console.error("Gemini API Error:", error);
|
||||
return "Desculpe, não consegui gerar uma resposta automática agora. Por favor, tente novamente.";
|
||||
}
|
||||
};
|
||||
81
services/db.ts
Normal file
81
services/db.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import localforage from 'localforage';
|
||||
import { Course, Comment, Section } from '../types';
|
||||
import { INITIAL_COURSES, generateMockSections } from '../constants';
|
||||
|
||||
const COURSES_KEY = 'compor_hub_courses_v1';
|
||||
const COMMENTS_KEY = 'compor_hub_comments_v1';
|
||||
const PROGRESS_KEY = 'compor_hub_progress_v1';
|
||||
|
||||
// Initialize DB with seed courses
|
||||
export const initDB = async (): Promise<void> => {
|
||||
const existingCourses = await localforage.getItem<Course[]>(COURSES_KEY);
|
||||
if (!existingCourses || existingCourses.length === 0) {
|
||||
await localforage.setItem(COURSES_KEY, INITIAL_COURSES);
|
||||
}
|
||||
};
|
||||
|
||||
export const getCourses = async (): Promise<Course[]> => {
|
||||
const courses = await localforage.getItem<Course[]>(COURSES_KEY);
|
||||
if (!courses || courses.length === 0) {
|
||||
await initDB();
|
||||
return INITIAL_COURSES;
|
||||
}
|
||||
return courses;
|
||||
};
|
||||
|
||||
export const getCourseById = async (id: string): Promise<Course | null> => {
|
||||
const courses = await getCourses();
|
||||
return courses.find(c => c.id === id) || null;
|
||||
};
|
||||
|
||||
export const saveCourse = async (course: Course): Promise<void> => {
|
||||
const courses = await getCourses();
|
||||
const index = courses.findIndex(c => c.id === course.id);
|
||||
if (index >= 0) {
|
||||
courses[index] = course;
|
||||
} else {
|
||||
courses.unshift(course);
|
||||
}
|
||||
await localforage.setItem(COURSES_KEY, courses);
|
||||
};
|
||||
|
||||
export const deleteCourse = async (id: string): Promise<void> => {
|
||||
const courses = await getCourses();
|
||||
const filtered = courses.filter(c => c.id !== id);
|
||||
await localforage.setItem(COURSES_KEY, filtered);
|
||||
};
|
||||
|
||||
export const getComments = async (courseId: string): Promise<Comment[]> => {
|
||||
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
|
||||
return allComments.filter(c => c.courseId === courseId);
|
||||
};
|
||||
|
||||
export const saveComment = async (comment: Comment): Promise<void> => {
|
||||
const allComments = (await localforage.getItem<Comment[]>(COMMENTS_KEY)) || [];
|
||||
const index = allComments.findIndex(c => c.id === comment.id);
|
||||
|
||||
if (index >= 0) {
|
||||
allComments[index] = comment;
|
||||
} else {
|
||||
allComments.unshift(comment);
|
||||
}
|
||||
await localforage.setItem(COMMENTS_KEY, allComments);
|
||||
};
|
||||
|
||||
export const saveProgress = async (courseId: string, progress: number): Promise<void> => {
|
||||
const courses = await getCourses();
|
||||
const course = courses.find(c => c.id === courseId);
|
||||
if (course) {
|
||||
course.progress = progress;
|
||||
await saveCourse(course);
|
||||
}
|
||||
};
|
||||
|
||||
export const incrementViews = async (courseId: string): Promise<void> => {
|
||||
const courses = await getCourses();
|
||||
const course = courses.find(c => c.id === courseId);
|
||||
if (course) {
|
||||
course.views = (course.views || 0) + 1;
|
||||
await saveCourse(course);
|
||||
}
|
||||
};
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
66
types.ts
Normal file
66
types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export type SectionVariant = 'landscape' | 'portrait';
|
||||
|
||||
export type AttachmentType = 'pdf' | 'spreadsheet' | 'zip' | 'doc' | 'image' | 'link' | 'video';
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AttachmentType;
|
||||
url: string;
|
||||
size?: string; // e.g. "4.2 MB"
|
||||
description?: string;
|
||||
lessonId?: string; // Associated lesson or global course asset
|
||||
downloadCount?: number;
|
||||
}
|
||||
|
||||
export interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
duration: string;
|
||||
videoUrl: string;
|
||||
isFree: boolean; // true = Public, false = Pro (Login required)
|
||||
description?: string;
|
||||
attachments?: Attachment[]; // Lesson-specific downloadable materials
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
thumbnail: string;
|
||||
lessons: Lesson[];
|
||||
progress?: number; // 0 to 100
|
||||
views?: number;
|
||||
duration: string; // Total duration or lesson count
|
||||
description: string;
|
||||
instructor?: string;
|
||||
instructorRole?: string;
|
||||
tips?: string[]; // Key takeaways / instructor tips
|
||||
attachments?: Attachment[]; // Downloadable materials across entire course
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
title: string;
|
||||
variant: SectionVariant;
|
||||
courses: Course[];
|
||||
}
|
||||
|
||||
export type UserRole = 'professor' | 'student';
|
||||
|
||||
export interface User {
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
courseId: string;
|
||||
lessonId?: string;
|
||||
user: string;
|
||||
text: string;
|
||||
date: string;
|
||||
reply?: string; // Professor's reply
|
||||
replyDate?: string;
|
||||
}
|
||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react()],
|
||||
define: {
|
||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user