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:
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user