feat: Implementando páginas de essays

This commit is contained in:
Lucas Santana 2025-02-06 20:44:41 -03:00
parent f602f4c666
commit d1e44f84b7
7 changed files with 813 additions and 0 deletions

View File

@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -12,6 +12,7 @@ import {
X,
ChevronLeft,
ChevronRight,
PenTool
} from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
import * as Dialog from '@radix-ui/react-dialog';
@ -69,6 +70,21 @@ export function StudentDashboardLayout() {
{!isCollapsed && <span>Painel</span>}
</NavLink>
<NavLink
to="/aluno/redacoes"
onClick={handleNavigation}
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'
}`
}
>
<PenTool className="h-5 w-5" />
{!isCollapsed && <span>Redações</span>}
</NavLink>
{/* <NavLink
to="/aluno/conquistas"
onClick={handleNavigation}

View File

@ -0,0 +1,214 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { supabase } from '@/lib/supabase';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { ArrowLeft, Save, Send } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
interface Essay {
id: string;
title: string;
content: string;
type_id: string;
genre_id: string;
status: 'draft' | 'submitted' | 'analyzed';
essay_type: {
title: string;
description: string;
};
essay_genre: {
title: string;
description: string;
requirements: {
min_words: number;
max_words: number;
required_elements: string[];
};
};
}
export default function EssayPage() {
const router = useRouter();
const { id } = router.query;
const { supabase } = supabase();
const [essay, setEssay] = useState<Essay | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [wordCount, setWordCount] = useState(0);
useEffect(() => {
if (id) {
loadEssay();
}
}, [id]);
useEffect(() => {
if (essay?.content) {
const words = essay.content.trim().split(/\s+/).length;
setWordCount(words);
}
}, [essay?.content]);
async function loadEssay() {
try {
const { data, error } = await supabase
.from('student_essays')
.select(`
*,
essay_type:essay_types(*),
essay_genre:essay_genres(*)
`)
.eq('id', id)
.single();
if (error) throw error;
setEssay(data);
} catch (error) {
console.error('Erro ao carregar redação:', error);
} finally {
setLoading(false);
}
}
async function saveEssay() {
if (!essay) return;
setSaving(true);
try {
const { error } = await supabase
.from('student_essays')
.update({
title: essay.title,
content: essay.content
})
.eq('id', essay.id);
if (error) throw error;
} catch (error) {
console.error('Erro ao salvar redação:', error);
} finally {
setSaving(false);
}
}
async function submitForAnalysis() {
if (!essay) return;
try {
// Primeiro atualiza o status
const { error: updateError } = await supabase
.from('student_essays')
.update({ status: 'submitted' })
.eq('id', essay.id);
if (updateError) throw updateError;
// Chama a Edge Function para análise
const { error: analysisError } = await supabase.functions.invoke('analyze-essay', {
body: {
essay_id: essay.id,
content: essay.content,
type_id: essay.type_id,
genre_id: essay.genre_id
}
});
if (analysisError) throw analysisError;
// Redireciona para a página de análise
router.push(`/student-dashboard/essays/${essay.id}/analysis`);
} catch (error) {
console.error('Erro ao enviar para análise:', error);
}
}
if (loading) return <div>Carregando...</div>;
if (!essay) return <div>Redação não encontrada</div>;
const isWithinWordLimit =
wordCount >= (essay.essay_genre.requirements.min_words || 0) &&
wordCount <= (essay.essay_genre.requirements.max_words || Infinity);
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => router.push('/student-dashboard/essays')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar
</Button>
<div>
<Input
value={essay.title}
onChange={(e) => setEssay({ ...essay, title: e.target.value })}
className="text-2xl font-bold border-none focus:border-none"
placeholder="Título da redação"
/>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{essay.essay_type.title}</span>
<span></span>
<span>{essay.essay_genre.title}</span>
<span></span>
<Badge variant={essay.status === 'draft' ? 'secondary' : 'primary'}>
{essay.status === 'draft' ? 'Rascunho' : 'Enviada'}
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={saveEssay} disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Salvando...' : 'Salvar'}
</Button>
{essay.status === 'draft' && (
<Button
onClick={submitForAnalysis}
disabled={!isWithinWordLimit}
title={!isWithinWordLimit ? 'Número de palavras fora do limite' : ''}
>
<Send className="mr-2 h-4 w-4" />
Enviar para análise
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-3">
<Textarea
value={essay.content}
onChange={(e) => setEssay({ ...essay, content: e.target.value })}
className="min-h-[500px] font-mono"
placeholder="Escreva sua redação aqui..."
/>
<div className="mt-2 text-sm text-muted-foreground">
{wordCount} palavras
{!isWithinWordLimit && (
<span className="text-destructive">
{' '}(mínimo: {essay.essay_genre.requirements.min_words},
máximo: {essay.essay_genre.requirements.max_words})
</span>
)}
</div>
</div>
<Card>
<CardContent className="p-4">
<h3 className="font-semibold mb-2">Requisitos do Gênero</h3>
<div className="text-sm text-muted-foreground">
<p>Mínimo: {essay.essay_genre.requirements.min_words} palavras</p>
<p>Máximo: {essay.essay_genre.requirements.max_words} palavras</p>
<p className="mt-2">Elementos necessários:</p>
<ul className="list-disc list-inside">
{essay.essay_genre.requirements.required_elements.map((element, index) => (
<li key={index}>{element}</li>
))}
</ul>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,238 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { supabase } from '@/lib/supabase';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft, CheckCircle2, XCircle } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
interface EssayAnalysis {
id: string;
essay_id: string;
overall_score: number;
feedback: {
structure: string;
content: string;
language: string;
};
strengths: string[];
improvements: string[];
suggestions: string;
criteria_scores: {
adequacy: number;
coherence: number;
cohesion: number;
vocabulary: number;
grammar: number;
};
created_at: string;
}
interface Essay {
id: string;
title: string;
content: string;
essay_type: {
title: string;
};
essay_genre: {
title: string;
};
}
export default function EssayAnalysisPage() {
const router = useRouter();
const { id } = router.query;
const { supabase } = supabase();
const [analysis, setAnalysis] = useState<EssayAnalysis | null>(null);
const [essay, setEssay] = useState<Essay | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (id) {
loadEssayAndAnalysis();
}
}, [id]);
async function loadEssayAndAnalysis() {
try {
// Carregar redação
const { data: essayData, error: essayError } = await supabase
.from('student_essays')
.select(`
*,
essay_type:essay_types(title),
essay_genre:essay_genres(title)
`)
.eq('id', id)
.single();
if (essayError) throw essayError;
setEssay(essayData);
// Carregar análise
const { data: analysisData, error: analysisError } = await supabase
.from('essay_analyses')
.select('*')
.eq('essay_id', id)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (analysisError) throw analysisError;
setAnalysis(analysisData);
} catch (error) {
console.error('Erro ao carregar dados:', error);
} finally {
setLoading(false);
}
}
if (loading) return <div>Carregando...</div>;
if (!essay || !analysis) return <div>Análise não encontrada</div>;
return (
<div className="container mx-auto p-6">
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => router.push(`/student-dashboard/essays/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar para redação
</Button>
<div>
<h1 className="text-3xl font-bold">{essay.title}</h1>
<p className="text-muted-foreground">
{essay.essay_type.title} {essay.essay_genre.title}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Pontuação Geral */}
<Card>
<CardHeader>
<CardTitle>Pontuação Geral</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">
<div className="text-6xl font-bold text-primary">
{analysis.overall_score}
</div>
<div className="text-2xl ml-1">/100</div>
</div>
</CardContent>
</Card>
{/* Pontos Fortes */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-success" />
Pontos Fortes
</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc list-inside space-y-2">
{analysis.strengths.map((strength, index) => (
<li key={index} className="text-success">{strength}</li>
))}
</ul>
</CardContent>
</Card>
{/* Pontos a Melhorar */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-destructive" />
Pontos a Melhorar
</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc list-inside space-y-2">
{analysis.improvements.map((improvement, index) => (
<li key={index} className="text-destructive">{improvement}</li>
))}
</ul>
</CardContent>
</Card>
{/* Feedback Detalhado */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Feedback Detalhado</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-semibold mb-2">Estrutura</h4>
<p className="text-muted-foreground">{analysis.feedback.structure}</p>
</div>
<div>
<h4 className="font-semibold mb-2">Conteúdo</h4>
<p className="text-muted-foreground">{analysis.feedback.content}</p>
</div>
<div>
<h4 className="font-semibold mb-2">Linguagem</h4>
<p className="text-muted-foreground">{analysis.feedback.language}</p>
</div>
</CardContent>
</Card>
{/* Critérios de Avaliação */}
<Card>
<CardHeader>
<CardTitle>Critérios de Avaliação</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex justify-between mb-1">
<span>Adequação ao Gênero</span>
<span>{analysis.criteria_scores.adequacy}%</span>
</div>
<Progress value={analysis.criteria_scores.adequacy} />
</div>
<div>
<div className="flex justify-between mb-1">
<span>Coerência</span>
<span>{analysis.criteria_scores.coherence}%</span>
</div>
<Progress value={analysis.criteria_scores.coherence} />
</div>
<div>
<div className="flex justify-between mb-1">
<span>Coesão</span>
<span>{analysis.criteria_scores.cohesion}%</span>
</div>
<Progress value={analysis.criteria_scores.cohesion} />
</div>
<div>
<div className="flex justify-between mb-1">
<span>Vocabulário</span>
<span>{analysis.criteria_scores.vocabulary}%</span>
</div>
<Progress value={analysis.criteria_scores.vocabulary} />
</div>
<div>
<div className="flex justify-between mb-1">
<span>Gramática</span>
<span>{analysis.criteria_scores.grammar}%</span>
</div>
<Progress value={analysis.criteria_scores.grammar} />
</div>
</CardContent>
</Card>
{/* Sugestões */}
<Card className="md:col-span-3">
<CardHeader>
<CardTitle>Sugestões para Melhoria</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground whitespace-pre-line">
{analysis.suggestions}
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { PlusCircle } from 'lucide-react';
import { useRouter } from 'next/router';
import { Badge } from '@/components/ui/badge';
interface Essay {
id: string;
title: string;
type_id: string;
genre_id: string;
status: 'draft' | 'submitted' | 'analyzed';
created_at: string;
essay_type: {
title: string;
};
essay_genre: {
title: string;
};
}
export default function EssaysPage() {
const router = useRouter();
const { supabase } = supabase();
const [essays, setEssays] = useState<Essay[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadEssays();
}, []);
async function loadEssays() {
try {
const { data, error } = await supabase
.from('student_essays')
.select(`
*,
essay_type:essay_types(title),
essay_genre:essay_genres(title)
`)
.order('created_at', { ascending: false });
if (error) throw error;
setEssays(data || []);
} catch (error) {
console.error('Erro ao carregar redações:', error);
} finally {
setLoading(false);
}
}
function getStatusBadge(status: Essay['status']) {
const statusMap = {
draft: { label: 'Rascunho', variant: 'secondary' },
submitted: { label: 'Enviada', variant: 'primary' },
analyzed: { label: 'Analisada', variant: 'success' }
};
const { label, variant } = statusMap[status];
return <Badge variant={variant as any}>{label}</Badge>;
}
return (
<div className="container mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Minhas Redações</h1>
<Button onClick={() => router.push('/student-dashboard/essays/new')}>
<PlusCircle className="mr-2 h-4 w-4" />
Nova Redação
</Button>
</div>
{loading ? (
<div>Carregando...</div>
) : essays.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-6">
<p className="text-muted-foreground mb-4">Você ainda não tem nenhuma redação</p>
<Button onClick={() => router.push('/student-dashboard/essays/new')}>
Criar Primeira Redação
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{essays.map((essay) => (
<Card key={essay.id} className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => router.push(`/student-dashboard/essays/${essay.id}`)}>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>{essay.title || 'Sem título'}</CardTitle>
<CardDescription>
{essay.essay_type?.title} - {essay.essay_genre?.title}
</CardDescription>
</div>
{getStatusBadge(essay.status)}
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Criada em {new Date(essay.created_at).toLocaleDateString('pt-BR')}
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,183 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
interface EssayType {
id: string;
title: string;
description: string;
icon: string;
}
interface EssayGenre {
id: string;
title: string;
description: string;
icon: string;
requirements: {
min_words: number;
max_words: number;
required_elements: string[];
};
}
export default function NewEssayPage() {
const router = useRouter();
const { supabase } = supabase();
const [step, setStep] = useState<'type' | 'genre'>('type');
const [selectedType, setSelectedType] = useState<EssayType | null>(null);
const [types, setTypes] = useState<EssayType[]>([]);
const [genres, setGenres] = useState<EssayGenre[]>([]);
const [loading, setLoading] = useState(true);
// Carregar tipos textuais
async function loadTypes() {
try {
const { data, error } = await supabase
.from('essay_types')
.select('*')
.eq('active', true);
if (error) throw error;
setTypes(data || []);
} catch (error) {
console.error('Erro ao carregar tipos:', error);
} finally {
setLoading(false);
}
}
// Carregar gêneros do tipo selecionado
async function loadGenres(typeId: string) {
try {
const { data, error } = await supabase
.from('essay_genres')
.select('*')
.eq('type_id', typeId)
.eq('active', true);
if (error) throw error;
setGenres(data || []);
} catch (error) {
console.error('Erro ao carregar gêneros:', error);
}
}
// Criar nova redação
async function createEssay(genreId: string) {
try {
const { data, error } = await supabase
.from('student_essays')
.insert({
type_id: selectedType!.id,
genre_id: genreId,
status: 'draft',
title: 'Nova Redação',
content: ''
})
.select()
.single();
if (error) throw error;
router.push(`/student-dashboard/essays/${data.id}`);
} catch (error) {
console.error('Erro ao criar redação:', error);
}
}
// Renderizar seleção de tipo textual
function renderTypeSelection() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{types.map((type) => (
<Card
key={type.id}
className={`cursor-pointer hover:shadow-lg transition-shadow ${
selectedType?.id === type.id ? 'ring-2 ring-primary' : ''
}`}
onClick={() => {
setSelectedType(type);
loadGenres(type.id);
setStep('genre');
}}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>{type.icon}</span>
{type.title}
</CardTitle>
<CardDescription>{type.description}</CardDescription>
</CardHeader>
</Card>
))}
</div>
);
}
// Renderizar seleção de gênero textual
function renderGenreSelection() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{genres.map((genre) => (
<Card
key={genre.id}
className="cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => createEssay(genre.id)}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>{genre.icon}</span>
{genre.title}
</CardTitle>
<CardDescription>{genre.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
<p>Mínimo: {genre.requirements.min_words} palavras</p>
<p>Máximo: {genre.requirements.max_words} palavras</p>
<p className="mt-2">Elementos necessários:</p>
<ul className="list-disc list-inside">
{genre.requirements.required_elements.map((element, index) => (
<li key={index}>{element}</li>
))}
</ul>
</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="container mx-auto p-6">
<div className="flex items-center gap-4 mb-6">
{step === 'genre' && (
<Button variant="ghost" onClick={() => setStep('type')}>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar
</Button>
)}
<div>
<h1 className="text-3xl font-bold">Nova Redação</h1>
<p className="text-muted-foreground">
{step === 'type'
? 'Selecione o tipo textual'
: 'Selecione o gênero textual'}
</p>
</div>
</div>
{loading ? (
<div>Carregando...</div>
) : step === 'type' ? (
renderTypeSelection()
) : (
renderGenreSelection()
)}
</div>
);
}

View File

@ -33,6 +33,10 @@ import { EvidenceBased } from './pages/landing/EvidenceBased';
import { TextSalesLetter } from './pages/landing/TextSalesLetter';
import { PhonicsPage } from "./pages/student-dashboard/PhonicsPage";
import { PhonicsProgressPage } from "./pages/student-dashboard/PhonicsProgressPage";
import { EssaysPage } from './pages/student-dashboard/essays';
import { NewEssayPage } from './pages/student-dashboard/essays/new';
import { EssayPage } from './pages/student-dashboard/essays/[id]';
import { EssayAnalysisPage } from './pages/student-dashboard/essays/[id]/analysis';
function RootLayout({ children }: { children: React.ReactNode }) {
return (
@ -219,6 +223,27 @@ export const router = createBrowserRouter([
{
path: 'fonicos/progresso',
element: <PhonicsProgressPage />,
},
{
path: 'redacoes',
children: [
{
index: true,
element: <EssaysPage />,
},
{
path: 'nova',
element: <NewEssayPage />,
},
{
path: ':id',
element: <EssayPage />,
},
{
path: ':id/analise',
element: <EssayAnalysisPage />,
}
]
}
]
},