mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-17 05:47:52 +00:00
feat: implementa páginas do dashboard do aluno
- Adiciona página de listagem de histórias com filtros e ordenação - Cria formulário de criação de novas histórias com temas - Implementa visualizador de história com navegação entre páginas - Integra gravador de áudio para leitura - Adiciona funcionalidade de compartilhamento - Implementa estados de loading e tratamento de erros
This commit is contained in:
parent
f1a7cd8730
commit
5952d83ec8
185
src/pages/student-dashboard/CreateStoryPage.tsx
Normal file
185
src/pages/student-dashboard/CreateStoryPage.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, Sparkles, Send } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
interface StoryForm {
|
||||
title: string;
|
||||
theme: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export function CreateStoryPage() {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = React.useState<StoryForm>({
|
||||
title: '',
|
||||
theme: '',
|
||||
prompt: ''
|
||||
});
|
||||
const [generating, setGenerating] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const themes = [
|
||||
{ id: 'nature', name: 'Natureza e Meio Ambiente' },
|
||||
{ id: 'culture', name: 'Cultura Brasileira' },
|
||||
{ id: 'science', name: 'Ciência e Descobertas' },
|
||||
{ id: 'adventure', name: 'Aventura e Exploração' },
|
||||
{ id: 'friendship', name: 'Amizade e Cooperação' }
|
||||
];
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) throw new Error('Usuário não autenticado');
|
||||
|
||||
// Em produção: Integrar com API de IA para gerar história
|
||||
const generatedStory = {
|
||||
title: formData.title,
|
||||
content: {
|
||||
pages: [
|
||||
{
|
||||
text: "Era uma vez...",
|
||||
image: "https://images.unsplash.com/photo-1472162072942-cd5147eb3902"
|
||||
}
|
||||
]
|
||||
},
|
||||
theme: formData.theme,
|
||||
status: 'draft'
|
||||
};
|
||||
|
||||
const { error: saveError } = await supabase
|
||||
.from('stories')
|
||||
.insert({
|
||||
student_id: session.user.id,
|
||||
title: generatedStory.title,
|
||||
theme: generatedStory.theme,
|
||||
content: generatedStory.content,
|
||||
status: generatedStory.status,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (saveError) throw saveError;
|
||||
navigate('/aluno/historias');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao criar história:', err);
|
||||
setError('Não foi possível criar sua história. Tente novamente.');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar para histórias
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Criar Nova História</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Título da História
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
placeholder="Ex: A Aventura na Floresta Encantada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="theme" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tema
|
||||
</label>
|
||||
<select
|
||||
id="theme"
|
||||
name="theme"
|
||||
value={formData.theme}
|
||||
onChange={handleChange}
|
||||
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
>
|
||||
<option value="">Selecione um tema</option>
|
||||
{themes.map(theme => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="prompt" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sobre o que você quer escrever?
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt"
|
||||
name="prompt"
|
||||
value={formData.prompt}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border-gray-300 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
placeholder="Descreva sua ideia para a história..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Sparkles className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-purple-900">
|
||||
Assistente de Criação
|
||||
</h3>
|
||||
<p className="text-sm text-purple-700">
|
||||
Nossa IA vai ajudar você a criar uma história incrível baseada nas suas ideias!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={generating}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-5 w-5" />
|
||||
{generating ? 'Criando História...' : 'Criar História'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/pages/student-dashboard/StoryPage.tsx
Normal file
165
src/pages/student-dashboard/StoryPage.tsx
Normal file
@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save } from 'lucide-react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { AudioRecorder } from '../../components/story/AudioRecorder';
|
||||
import type { Story } from '../../types/database';
|
||||
|
||||
export function StoryPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [story, setStory] = React.useState<Story | null>(null);
|
||||
const [currentPage, setCurrentPage] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchStory = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
setStory(data);
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar história:', err);
|
||||
setError('Não foi possível carregar a história');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStory();
|
||||
}, [id]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: story?.title,
|
||||
text: 'Confira minha história!',
|
||||
url: window.location.href
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erro ao compartilhar:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-96 bg-gray-200 rounded-xl mb-8" />
|
||||
<div className="h-20 bg-gray-200 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !story) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Voltar para histórias
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Voltar para histórias
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
Compartilhar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<Volume2 className="h-5 w-5" />
|
||||
{isPlaying ? 'Pausar' : 'Ouvir'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{/* Imagem da página atual */}
|
||||
{story.content.pages[currentPage].image && (
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={story.content.pages[currentPage].image}
|
||||
alt={`Página ${currentPage + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story.title}</h1>
|
||||
|
||||
{/* Texto da página atual */}
|
||||
<p className="text-lg text-gray-700 mb-8">
|
||||
{story.content.pages[currentPage].text}
|
||||
</p>
|
||||
|
||||
{/* Gravador de áudio */}
|
||||
<AudioRecorder
|
||||
storyId={story.id}
|
||||
studentId={story.student_id}
|
||||
onAudioUploaded={(audioUrl) => {
|
||||
console.log('Áudio gravado:', audioUrl);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navegação entre páginas */}
|
||||
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
|
||||
disabled={currentPage === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-gray-500">
|
||||
Página {currentPage + 1} de {story.content.pages.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
|
||||
disabled={currentPage === story.content.pages.length - 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900 disabled:opacity-50"
|
||||
>
|
||||
Próxima
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/pages/student-dashboard/StudentDashboardLayout.tsx
Normal file
142
src/pages/student-dashboard/StudentDashboardLayout.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
BookOpen,
|
||||
Settings,
|
||||
LogOut,
|
||||
School,
|
||||
Trophy,
|
||||
History
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
|
||||
export function StudentDashboardLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<aside className="fixed left-0 top-0 h-full w-64 bg-white border-r border-gray-200">
|
||||
<div className="flex items-center gap-2 p-6 border-b border-gray-200">
|
||||
<School className="h-8 w-8 text-purple-600" />
|
||||
<span className="font-semibold text-gray-900">Histórias Mágicas</span>
|
||||
</div>
|
||||
|
||||
<nav className="p-4 space-y-1">
|
||||
<NavLink
|
||||
to="/aluno"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
Início
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/historias"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Minhas Histórias
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/conquistas"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Trophy className="h-5 w-5" />
|
||||
Conquistas
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/historico"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<History className="h-5 w-5" />
|
||||
Histórico
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/aluno/configuracoes"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-4 py-2 rounded-lg text-sm ${
|
||||
isActive
|
||||
? 'bg-purple-50 text-purple-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
Configurações
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-50 w-full mt-4"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
Sair
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Footer com informações do aluno */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-purple-600">
|
||||
{/* Primeira letra do nome do aluno */}
|
||||
A
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{/* Nome do aluno */}
|
||||
Aluno da Silva
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{/* Turma do aluno */}
|
||||
5º Ano A
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="ml-64 p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
261
src/pages/student-dashboard/StudentDashboardPage.tsx
Normal file
261
src/pages/student-dashboard/StudentDashboardPage.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import React from 'react';
|
||||
import { Plus, BookOpen, Clock, TrendingUp, Award } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import type { Story, Student } from '../../types/database';
|
||||
|
||||
interface DashboardMetrics {
|
||||
totalStories: number;
|
||||
averageReadingFluency: number;
|
||||
totalReadingTime: number;
|
||||
currentLevel: number;
|
||||
}
|
||||
|
||||
export function StudentDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const [student, setStudent] = React.useState<Student | null>(null);
|
||||
const [stories, setStories] = React.useState<Story[]>([]);
|
||||
const [metrics, setMetrics] = React.useState<DashboardMetrics>({
|
||||
totalStories: 0,
|
||||
averageReadingFluency: 0,
|
||||
totalReadingTime: 0,
|
||||
currentLevel: 1
|
||||
});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
// Buscar dados do aluno
|
||||
const { data: studentData, error: studentError } = await supabase
|
||||
.from('students')
|
||||
.select(`
|
||||
*,
|
||||
class:classes(name, grade),
|
||||
school:schools(name)
|
||||
`)
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (studentError) throw studentError;
|
||||
setStudent(studentData);
|
||||
|
||||
// Buscar histórias do aluno
|
||||
const { data: storiesData, error: storiesError } = await supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('student_id', session.user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(6);
|
||||
|
||||
if (storiesError) throw storiesError;
|
||||
setStories(storiesData || []);
|
||||
|
||||
// Calcular métricas
|
||||
// Em produção: Implementar cálculos reais baseados nos dados
|
||||
setMetrics({
|
||||
totalStories: storiesData?.length || 0,
|
||||
averageReadingFluency: 85, // Exemplo
|
||||
totalReadingTime: 120, // Exemplo: 120 minutos
|
||||
currentLevel: 3 // Exemplo
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar dashboard:', err);
|
||||
setError('Não foi possível carregar seus dados');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-32 bg-gray-200 rounded-xl mb-8" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-24 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-64 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Cabeçalho */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
{student?.avatar_url ? (
|
||||
<img
|
||||
src={student.avatar_url}
|
||||
alt={student?.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-purple-600">
|
||||
{student?.name?.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{student?.name}</h1>
|
||||
<p className="text-gray-500">
|
||||
{(student?.class as any)?.grade} • {(student?.class as any)?.name} • {(student?.school as any)?.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Nova História
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métricas */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-purple-100 rounded-lg">
|
||||
<BookOpen className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total de Histórias</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.totalStories}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Fluência Média</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.averageReadingFluency}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Tempo de Leitura</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.totalReadingTime}min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-yellow-100 rounded-lg">
|
||||
<Award className="h-6 w-6 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Nível Atual</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.currentLevel}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Histórias Recentes */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900">Histórias Recentes</h2>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias')}
|
||||
className="text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Ver todas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stories.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
|
||||
<BookOpen className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Nenhuma história ainda
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Comece sua jornada criando sua primeira história!
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Criar História
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{stories.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
|
||||
onClick={() => navigate(`/aluno/historias/${story.id}`)}
|
||||
>
|
||||
{story.content.pages[0].image && (
|
||||
<img
|
||||
src={story.content.pages[0].image}
|
||||
alt={story.title}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{story.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{new Date(story.created_at).toLocaleDateString()}</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
story.status === 'published'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{story.status === 'published' ? 'Publicada' : 'Rascunho'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
src/pages/student-dashboard/StudentStoriesPage.tsx
Normal file
227
src/pages/student-dashboard/StudentStoriesPage.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import React from 'react';
|
||||
import { Plus, Search, Filter, BookOpen, ArrowUpDown } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import type { Story } from '../../types/database';
|
||||
|
||||
type StoryStatus = 'all' | 'draft' | 'published';
|
||||
type SortOption = 'recent' | 'oldest' | 'title' | 'performance';
|
||||
|
||||
export function StudentStoriesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stories, setStories] = React.useState<Story[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<StoryStatus>('all');
|
||||
const [sortBy, setSortBy] = React.useState<SortOption>('recent');
|
||||
const [showFilters, setShowFilters] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchStories = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const query = supabase
|
||||
.from('stories')
|
||||
.select('*')
|
||||
.eq('student_id', session.user.id);
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
query.eq('status', statusFilter);
|
||||
}
|
||||
|
||||
let { data, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
// Aplicar ordenação
|
||||
const sortedData = (data || []).sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
case 'title':
|
||||
return a.title.localeCompare(b.title);
|
||||
case 'performance':
|
||||
return (b.performance_score || 0) - (a.performance_score || 0);
|
||||
default: // recent
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
setStories(sortedData);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar histórias:', err);
|
||||
setError('Não foi possível carregar suas histórias');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStories();
|
||||
}, [statusFilter, sortBy]);
|
||||
|
||||
const filteredStories = stories.filter(story =>
|
||||
story.title.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-20 bg-gray-200 rounded-xl mb-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-64 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Minhas Histórias</h1>
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Nova História
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Busca */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar histórias..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtros e Ordenação */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-5 w-5" />
|
||||
Filtros
|
||||
</button>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="recent">Mais recentes</option>
|
||||
<option value="oldest">Mais antigas</option>
|
||||
<option value="title">Por título</option>
|
||||
<option value="performance">Melhor desempenho</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Painel de Filtros */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
statusFilter === 'all'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Todas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('published')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
statusFilter === 'published'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Publicadas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('draft')}
|
||||
className={`px-4 py-2 rounded-lg ${
|
||||
statusFilter === 'draft'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Rascunhos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista de Histórias */}
|
||||
{filteredStories.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<BookOpen className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Nenhuma história encontrada
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{searchTerm
|
||||
? 'Tente usar outros termos na busca'
|
||||
: 'Comece criando sua primeira história!'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<button
|
||||
onClick={() => navigate('/aluno/historias/nova')}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
Criar História
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6">
|
||||
{filteredStories.map((story) => (
|
||||
<div
|
||||
key={story.id}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
|
||||
onClick={() => navigate(`/aluno/historias/${story.id}`)}
|
||||
>
|
||||
{story.content.pages[0].image && (
|
||||
<img
|
||||
src={story.content.pages[0].image}
|
||||
alt={story.title}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-2">{story.title}</h3>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>{new Date(story.created_at).toLocaleDateString()}</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
story.status === 'published'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{story.status === 'published' ? 'Publicada' : 'Rascunho'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -14,6 +14,11 @@ import { InviteTeacherPage } from './pages/dashboard/teachers/InviteTeacherPage'
|
||||
import { StudentsPage } from './pages/dashboard/students/StudentsPage';
|
||||
import { AddStudentPage } from './pages/dashboard/students/AddStudentPage';
|
||||
import { SettingsPage } from './pages/dashboard/settings/SettingsPage';
|
||||
import { StudentDashboardPage } from './pages/student-dashboard/StudentDashboardPage';
|
||||
import { StudentDashboardLayout } from './pages/student-dashboard/StudentDashboardLayout';
|
||||
import { StudentStoriesPage } from './pages/student-dashboard/StudentStoriesPage';
|
||||
import { CreateStoryPage } from './pages/student-dashboard/CreateStoryPage';
|
||||
import { StoryPage } from './pages/student-dashboard/StoryPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@ -116,4 +121,31 @@ export const router = createBrowserRouter([
|
||||
path: '/auth/callback',
|
||||
element: <AuthCallback />
|
||||
},
|
||||
{
|
||||
path: '/aluno',
|
||||
element: <StudentDashboardLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <StudentDashboardPage />,
|
||||
},
|
||||
{
|
||||
path: 'historias',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <StudentStoriesPage />,
|
||||
},
|
||||
{
|
||||
path: 'nova',
|
||||
element: <CreateStoryPage />,
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
element: <StoryPage />,
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
Loading…
Reference in New Issue
Block a user