diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a245a8b..133e222 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,19 +1,30 @@ -import { Routes, Route } from 'react-router-dom' +import { Routes, Route, Navigate } from 'react-router-dom' import Home from './pages/Home' import Lobby from './pages/Lobby' import Game from './pages/Game' import Results from './pages/Results' import Replay from './pages/Replay' +import { AdminLayout, Login, Dashboard, Questions, Calendar } from './pages/admin' function App() { return (
+ {/* Game routes */} } /> } /> } /> } /> } /> + + {/* Admin routes */} + } /> + }> + } /> + } /> + } /> + } /> +
) diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx new file mode 100644 index 0000000..44ecd33 --- /dev/null +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -0,0 +1,78 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom' +import { useAdminStore } from '../../stores/adminStore' +import { useEffect } from 'react' + +const navItems = [ + { path: '/admin/dashboard', label: 'Dashboard', icon: '📊' }, + { path: '/admin/questions', label: 'Preguntas', icon: '❓' }, + { path: '/admin/calendar', label: 'Calendario', icon: '📅' }, +] + +export default function AdminLayout() { + const { isAuthenticated, username, logout } = useAdminStore() + const navigate = useNavigate() + + // Protect routes + useEffect(() => { + if (!isAuthenticated) { + navigate('/admin/login') + } + }, [isAuthenticated, navigate]) + + if (!isAuthenticated) { + return null + } + + const handleLogout = () => { + logout() + navigate('/admin/login') + } + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ) +} diff --git a/frontend/src/pages/admin/Calendar.tsx b/frontend/src/pages/admin/Calendar.tsx new file mode 100644 index 0000000..8cef6a6 --- /dev/null +++ b/frontend/src/pages/admin/Calendar.tsx @@ -0,0 +1,236 @@ +import { useEffect, useState } from 'react' +import { motion } from 'framer-motion' +import { useAdminStore } from '../../stores/adminStore' +import { getQuestions, updateQuestion } from '../../services/adminApi' + +interface Question { + id: number + category_id: number + question_text: string + correct_answer: string + difficulty: number + points: number + date_active: string | null + status: string +} + +export default function Calendar() { + const { token } = useAdminStore() + const [questions, setQuestions] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedDate, setSelectedDate] = useState(null) + const [currentMonth, setCurrentMonth] = useState(new Date()) + + useEffect(() => { + fetchQuestions() + }, [token]) + + const fetchQuestions = async () => { + if (!token) return + try { + const data = await getQuestions(token, undefined, 'approved') + setQuestions(data) + } catch (error) { + console.error('Error:', error) + } finally { + setLoading(false) + } + } + + const assignDate = async (questionId: number, date: string) => { + if (!token) return + try { + await updateQuestion(token, questionId, { date_active: date }) + fetchQuestions() + } catch (error) { + console.error('Error:', error) + } + } + + const removeDate = async (questionId: number) => { + if (!token) return + try { + await updateQuestion(token, questionId, { date_active: null }) + fetchQuestions() + } catch (error) { + console.error('Error:', error) + } + } + + // Calendar helpers + const getDaysInMonth = (date: Date) => { + const year = date.getFullYear() + const month = date.getMonth() + const firstDay = new Date(year, month, 1) + const lastDay = new Date(year, month + 1, 0) + const days: Date[] = [] + + // Add padding for first week + const startPadding = firstDay.getDay() + for (let i = startPadding - 1; i >= 0; i--) { + days.push(new Date(year, month, -i)) + } + + // Add days of month + for (let d = 1; d <= lastDay.getDate(); d++) { + days.push(new Date(year, month, d)) + } + + return days + } + + const formatDate = (date: Date) => date.toISOString().split('T')[0] + + const getQuestionsForDate = (date: string) => + questions.filter(q => q.date_active === date) + + const unassignedQuestions = questions.filter(q => !q.date_active) + + const days = getDaysInMonth(currentMonth) + const monthName = currentMonth.toLocaleDateString('es', { month: 'long', year: 'numeric' }) + + const prevMonth = () => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1)) + const nextMonth = () => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1)) + + return ( +
+

Calendario de Preguntas

+ +
+ {/* Calendar */} +
+ {/* Month Navigation */} +
+ +

{monthName}

+ +
+ + {/* Day Headers */} +
+ {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(day => ( +
+ {day} +
+ ))} +
+ + {/* Days Grid */} +
+ {days.map((day, i) => { + const dateStr = formatDate(day) + const isCurrentMonth = day.getMonth() === currentMonth.getMonth() + const isToday = dateStr === formatDate(new Date()) + const dayQuestions = getQuestionsForDate(dateStr) + const isSelected = selectedDate === dateStr + + return ( + setSelectedDate(dateStr)} + whileHover={{ scale: 1.05 }} + className={`p-2 rounded min-h-[60px] text-left transition-colors ${ + !isCurrentMonth ? 'opacity-30' : + isSelected ? 'bg-blue-600' : + isToday ? 'bg-blue-500/30' : + 'bg-gray-700 hover:bg-gray-600' + }`} + > + + {day.getDate()} + + {dayQuestions.length > 0 && ( +
+ = 5 ? 'bg-green-500/30 text-green-400' : + 'bg-yellow-500/30 text-yellow-400' + }`}> + {dayQuestions.length} preg. + +
+ )} +
+ ) + })} +
+
+ + {/* Sidebar */} +
+ {/* Selected Date Questions */} + {selectedDate && ( +
+

+ {new Date(selectedDate + 'T12:00:00').toLocaleDateString('es', { + weekday: 'long', day: 'numeric', month: 'long' + })} +

+ + {getQuestionsForDate(selectedDate).length === 0 ? ( +

No hay preguntas para este día

+ ) : ( +
+ {getQuestionsForDate(selectedDate).map(q => ( +
+

{q.question_text}

+
+ Dif. {q.difficulty} + +
+
+ ))} +
+ )} +
+ )} + + {/* Unassigned Questions */} +
+

+ Sin asignar ({unassignedQuestions.length}) +

+ + {loading ? ( +

Cargando...

+ ) : unassignedQuestions.length === 0 ? ( +

Todas las preguntas están asignadas

+ ) : ( +
+ {unassignedQuestions.slice(0, 10).map(q => ( +
+

{q.question_text}

+
+ Dif. {q.difficulty} + {selectedDate && ( + + )} +
+
+ ))} + {unassignedQuestions.length > 10 && ( +

+ +{unassignedQuestions.length - 10} más +

+ )} +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/admin/Dashboard.tsx b/frontend/src/pages/admin/Dashboard.tsx new file mode 100644 index 0000000..d438277 --- /dev/null +++ b/frontend/src/pages/admin/Dashboard.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { motion } from 'framer-motion' +import { useAdminStore } from '../../stores/adminStore' +import { getQuestions, getCategories } from '../../services/adminApi' + +interface Stats { + pendingQuestions: number + totalQuestions: number + totalCategories: number +} + +export default function Dashboard() { + const { token } = useAdminStore() + const [stats, setStats] = useState({ + pendingQuestions: 0, + totalQuestions: 0, + totalCategories: 0 + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchStats = async () => { + if (!token) return + + try { + const [pending, all, categories] = await Promise.all([ + getQuestions(token, undefined, 'pending'), + getQuestions(token), + getCategories(token) + ]) + + setStats({ + pendingQuestions: pending.length, + totalQuestions: all.length, + totalCategories: categories.length + }) + } catch (error) { + console.error('Error fetching stats:', error) + } finally { + setLoading(false) + } + } + + fetchStats() + }, [token]) + + const statCards = [ + { label: 'Pendientes', value: stats.pendingQuestions, color: 'yellow', icon: '⏳' }, + { label: 'Total Preguntas', value: stats.totalQuestions, color: 'blue', icon: '❓' }, + { label: 'Categorías', value: stats.totalCategories, color: 'green', icon: '📁' }, + ] + + return ( +
+

Dashboard

+ + {/* Stats Grid */} +
+ {statCards.map((card, i) => ( + +
+
+

{card.label}

+

+ {loading ? '...' : card.value} +

+
+ {card.icon} +
+
+ ))} +
+ + {/* Quick Actions */} +
+ +

+ ❓ Gestionar Preguntas +

+

+ Crear, editar, aprobar y generar preguntas con IA +

+ + + +

+ 📅 Ver Calendario +

+

+ Programar preguntas para fechas específicas +

+ +
+ + {/* Pending Alert */} + {stats.pendingQuestions > 0 && ( + +

+ ⚠️ Tienes {stats.pendingQuestions} preguntas pendientes de aprobación.{' '} + + Revisar ahora + +

+
+ )} +
+ ) +} diff --git a/frontend/src/pages/admin/Login.tsx b/frontend/src/pages/admin/Login.tsx new file mode 100644 index 0000000..c5bc7d8 --- /dev/null +++ b/frontend/src/pages/admin/Login.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { motion } from 'framer-motion' +import { useAdminStore } from '../../stores/adminStore' +import { adminLogin } from '../../services/adminApi' + +export default function AdminLogin() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const navigate = useNavigate() + const { setAuth, isAuthenticated } = useAdminStore() + + // Redirect if already authenticated + if (isAuthenticated) { + navigate('/admin/dashboard') + return null + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const { access_token } = await adminLogin(username, password) + setAuth(access_token, username) + navigate('/admin/dashboard') + } catch (err) { + setError(err instanceof Error ? err.message : 'Login failed') + } finally { + setLoading(false) + } + } + + return ( +
+ +

+ Panel de Administración +

+ +
+
+ + setUsername(e.target.value)} + className="w-full px-4 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 outline-none" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-4 py-2 bg-gray-700 text-white rounded border border-gray-600 focus:border-blue-500 outline-none" + required + /> +
+ + {error && ( +

{error}

+ )} + + +
+ +

+ ← Volver al juego +

+
+
+ ) +} diff --git a/frontend/src/pages/admin/Questions.tsx b/frontend/src/pages/admin/Questions.tsx new file mode 100644 index 0000000..4c3b524 --- /dev/null +++ b/frontend/src/pages/admin/Questions.tsx @@ -0,0 +1,479 @@ +import { useEffect, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useAdminStore } from '../../stores/adminStore' +import { + getQuestions, getCategories, createQuestion, updateQuestion, + deleteQuestion, generateQuestions, approveQuestion, rejectQuestion +} from '../../services/adminApi' +import type { Category } from '../../types' + +interface Question { + id: number + category_id: number + question_text: string + correct_answer: string + alt_answers: string[] + difficulty: number + points: number + time_seconds: number + date_active: string | null + status: string + fun_fact: string | null +} + +export default function Questions() { + const { token } = useAdminStore() + const [questions, setQuestions] = useState([]) + const [categories, setCategories] = useState([]) + const [loading, setLoading] = useState(true) + + // Filters + const [filterCategory, setFilterCategory] = useState('') + const [filterStatus, setFilterStatus] = useState('') + + // Modals + const [showEditModal, setShowEditModal] = useState(false) + const [showGenerateModal, setShowGenerateModal] = useState(false) + const [editingQuestion, setEditingQuestion] = useState(null) + + // Form state + const [formData, setFormData] = useState({ + category_id: 1, + question_text: '', + correct_answer: '', + alt_answers: '', + difficulty: 3, + fun_fact: '' + }) + + // Generate state + const [generateData, setGenerateData] = useState({ + category_id: 1, + difficulty: 3, + count: 5 + }) + const [generating, setGenerating] = useState(false) + + useEffect(() => { + fetchData() + }, [token, filterCategory, filterStatus]) + + const fetchData = async () => { + if (!token) return + setLoading(true) + try { + const [qs, cats] = await Promise.all([ + getQuestions(token, filterCategory || undefined, filterStatus || undefined), + getCategories(token) + ]) + setQuestions(qs) + setCategories(cats) + } catch (error) { + console.error('Error fetching data:', error) + } finally { + setLoading(false) + } + } + + const handleCreate = () => { + setEditingQuestion(null) + setFormData({ + category_id: 1, + question_text: '', + correct_answer: '', + alt_answers: '', + difficulty: 3, + fun_fact: '' + }) + setShowEditModal(true) + } + + const handleEdit = (q: Question) => { + setEditingQuestion(q) + setFormData({ + category_id: q.category_id, + question_text: q.question_text, + correct_answer: q.correct_answer, + alt_answers: q.alt_answers?.join(', ') || '', + difficulty: q.difficulty, + fun_fact: q.fun_fact || '' + }) + setShowEditModal(true) + } + + const handleSave = async () => { + if (!token) return + const data = { + ...formData, + alt_answers: formData.alt_answers.split(',').map(s => s.trim()).filter(Boolean) + } + + try { + if (editingQuestion) { + await updateQuestion(token, editingQuestion.id, data) + } else { + await createQuestion(token, data) + } + setShowEditModal(false) + fetchData() + } catch (error) { + console.error('Error saving:', error) + } + } + + const handleDelete = async (id: number) => { + if (!token || !confirm('¿Eliminar esta pregunta?')) return + try { + await deleteQuestion(token, id) + fetchData() + } catch (error) { + console.error('Error deleting:', error) + } + } + + const handleApprove = async (id: number) => { + if (!token) return + try { + await approveQuestion(token, id) + fetchData() + } catch (error) { + console.error('Error approving:', error) + } + } + + const handleReject = async (id: number) => { + if (!token) return + try { + await rejectQuestion(token, id) + fetchData() + } catch (error) { + console.error('Error rejecting:', error) + } + } + + const handleGenerate = async () => { + if (!token) return + setGenerating(true) + try { + const result = await generateQuestions(token, generateData) + alert(`Se generaron ${result.generated} preguntas`) + setShowGenerateModal(false) + setFilterStatus('pending') + fetchData() + } catch (error) { + console.error('Error generating:', error) + alert('Error al generar preguntas') + } finally { + setGenerating(false) + } + } + + const getCategoryName = (id: number) => categories.find(c => c.id === id)?.name || 'Unknown' + const getStatusBadge = (status: string) => { + switch (status) { + case 'pending': return 'bg-yellow-500/20 text-yellow-500' + case 'approved': return 'bg-green-500/20 text-green-500' + case 'used': return 'bg-gray-500/20 text-gray-400' + default: return 'bg-gray-500/20 text-gray-400' + } + } + + return ( +
+
+

Preguntas

+
+ + +
+
+ + {/* Filters */} +
+ + + +
+ + {/* Questions List */} +
+ {loading ? ( +

Cargando...

+ ) : questions.length === 0 ? ( +

No hay preguntas

+ ) : ( + questions.map((q) => ( + +
+
+
+ + {q.status} + + + {getCategoryName(q.category_id)} - Dif. {q.difficulty} - {q.points}pts + +
+

{q.question_text}

+

R: {q.correct_answer}

+
+ +
+ {q.status === 'pending' && ( + <> + + + + )} + + +
+
+
+ )) + )} +
+ + {/* Edit Modal */} + + {showEditModal && ( + + +

+ {editingQuestion ? 'Editar Pregunta' : 'Nueva Pregunta'} +

+ +
+
+
+ + +
+
+ + +
+
+ +
+ +