style: padroniza visual da listagem de redações

- Alinha estilo com StudentStoriesPage
- Adiciona busca e filtros avançados
- Melhora feedback visual e estados interativos
- Implementa loading states animados
This commit is contained in:
Lucas Santana 2025-02-06 21:54:01 -03:00
parent 2929946499
commit 1c6aa56b32
3 changed files with 263 additions and 73 deletions

View File

@ -160,6 +160,7 @@ export function Editor({
'rounded-md border border-input bg-transparent', 'rounded-md border border-input bg-transparent',
'focus-within:outline-none focus-within:ring-2', 'focus-within:outline-none focus-within:ring-2',
'focus-within:ring-ring focus-within:ring-offset-2', 'focus-within:ring-ring focus-within:ring-offset-2',
'transition-all duration-200',
className className
)} )}
> >
@ -172,6 +173,7 @@ export function Editor({
'[&_.is-editor-empty]:before:float-left', '[&_.is-editor-empty]:before:float-left',
'[&_.is-editor-empty]:before:h-0', '[&_.is-editor-empty]:before:h-0',
'[&_.is-editor-empty]:before:pointer-events-none', '[&_.is-editor-empty]:before:pointer-events-none',
'transition-all duration-200',
readOnly && 'prose-sm' readOnly && 'prose-sm'
)} )}
style={{ minHeight }} style={{ minHeight }}

View File

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PlusCircle } from 'lucide-react'; import { Plus, Search, Filter, PenTool } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { useSession } from '@/hooks/useSession';
import { useUppercasePreference } from '@/hooks/useUppercasePreference';
import { AdaptiveText } from '@/components/ui/adaptive-text';
interface Essay { interface Essay {
id: string; id: string;
@ -21,107 +22,265 @@ interface Essay {
}; };
} }
type EssayStatus = 'all' | 'draft' | 'submitted' | 'analyzed';
type SortOption = 'recent' | 'oldest' | 'title';
export function EssaysPage() { export function EssaysPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [essays, setEssays] = useState<Essay[]>([]); const [essays, setEssays] = useState<Essay[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<EssayStatus>('all');
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [showFilters, setShowFilters] = useState(false);
const { session } = useSession();
const { isUpperCase } = useUppercasePreference(session?.user?.id);
useEffect(() => { useEffect(() => {
loadEssays(); loadEssays();
}, []); }, [statusFilter, sortBy]);
async function loadEssays() { async function loadEssays() {
try { try {
const { data, error } = await supabase const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
const query = supabase
.from('student_essays') .from('student_essays')
.select(` .select(`
*, *,
essay_type:essay_types(title), essay_type:essay_types(title),
essay_genre:essay_genres(title) essay_genre:essay_genres(title)
`) `)
.order('created_at', { ascending: false }); .eq('student_id', session.user.id);
if (error) throw error; if (statusFilter !== 'all') {
setEssays(data || []); query.eq('status', statusFilter);
}
const { data, error } = await query;
if (error) {
setError('Não foi possível carregar suas redações');
return;
}
// 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);
default: // recent
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
}
});
setEssays(sortedData);
} catch (error) { } catch (error) {
console.error('Erro ao carregar redações:', error); console.error('Erro ao carregar redações:', error);
setError('Não foi possível carregar suas redações');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
const filteredEssays = essays.filter(essay =>
essay.title.toLowerCase().includes(searchTerm.toLowerCase())
);
function getStatusBadge(status: Essay['status']) { function getStatusBadge(status: Essay['status']) {
const statusMap = { const statusMap = {
draft: { label: 'Rascunho', variant: 'secondary' as const }, draft: { label: 'Rascunho', variant: 'secondary' as const, classes: 'bg-yellow-100 text-yellow-800' },
submitted: { label: 'Enviada', variant: 'default' as const }, submitted: { label: 'Enviada', variant: 'default' as const, classes: 'bg-blue-100 text-blue-800' },
analyzed: { label: 'Analisada', variant: 'success' as const } analyzed: { label: 'Analisada', variant: 'success' as const, classes: 'bg-green-100 text-green-800' }
}; };
const { label, variant } = statusMap[status]; const { label, classes } = statusMap[status];
return <Badge variant={variant}>{label}</Badge>; return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${classes}`}>
<AdaptiveText text={label} isUpperCase={isUpperCase} />
</span>
);
}
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 ( return (
<div className="container mx-auto p-6"> <div>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Minhas Redações</h1> <h1 className="text-2xl font-bold text-gray-900">
<AdaptiveText text="Minhas Redações" isUpperCase={isUpperCase} />
</h1>
<Button <Button
onClick={() => navigate('/aluno/redacoes/nova')} onClick={() => navigate('/aluno/redacoes/nova')}
className="flex items-center gap-2"
trackingId="essay-new-create-button" trackingId="essay-new-create-button"
trackingProperties={{ trackingProperties={{
action: 'create_new_essay', action: 'create_new_essay',
page: 'essays_list' page: 'essays_list'
}} }}
> >
<PlusCircle className="mr-2 h-4 w-4" /> <Plus className="h-5 w-5" />
Nova Redação <AdaptiveText text="Nova Redação" isUpperCase={isUpperCase} />
</Button> </Button>
</div> </div>
{loading ? ( <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6">
<div>Carregando...</div> <div className="p-4 border-b border-gray-200">
) : essays.length === 0 ? ( <div className="flex flex-col md:flex-row gap-4">
<Card> {/* Busca */}
<CardContent className="flex flex-col items-center justify-center p-6"> <div className="flex-1 relative">
<p className="text-muted-foreground mb-4">Você ainda não tem nenhuma redação</p> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<Button <input
onClick={() => navigate('/aluno/redacoes/nova')} type="text"
trackingId="essay-empty-create-button" placeholder="Buscar redações..."
trackingProperties={{ value={searchTerm}
action: 'create_first_essay', onChange={(e) => setSearchTerm(e.target.value)}
page: 'essays_list', className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
context: 'empty_state' />
}} </div>
>
Criar Primeira Redação {/* Filtros e Ordenação */}
</Button> <div className="flex gap-2">
</CardContent> <button
</Card> onClick={() => setShowFilters(!showFilters)}
) : ( className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> >
{essays.map((essay) => ( <Filter className="h-5 w-5" />
<Card key={essay.id} className="cursor-pointer hover:shadow-lg transition-shadow" <AdaptiveText text="Filtros" isUpperCase={isUpperCase} />
onClick={() => navigate(`/aluno/redacoes/${essay.id}`)}> </button>
<CardHeader> <select
<div className="flex justify-between items-start"> value={sortBy}
<div> onChange={(e) => setSortBy(e.target.value as SortOption)}
<CardTitle>{essay.title || 'Sem título'}</CardTitle> className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-purple-500 focus:border-purple-500"
<CardDescription> >
{essay.essay_type?.title} - {essay.essay_genre?.title} <option value="recent">Mais recentes</option>
</CardDescription> <option value="oldest">Mais antigas</option>
</div> <option value="title">Por título</option>
{getStatusBadge(essay.status)} </select>
</div> </div>
</CardHeader> </div>
<CardContent>
<p className="text-sm text-muted-foreground"> {/* Painel de Filtros */}
Criada em {new Date(essay.created_at).toLocaleDateString('pt-BR')} {showFilters && (
</p> <div className="mt-4 pt-4 border-t border-gray-200">
</CardContent> <div className="flex gap-2">
</Card> <button
))} onClick={() => setStatusFilter('all')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'all'
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100'
}`}
>
<AdaptiveText text="Todas" isUpperCase={isUpperCase} />
</button>
<button
onClick={() => setStatusFilter('analyzed')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'analyzed'
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100'
}`}
>
<AdaptiveText text="Analisadas" isUpperCase={isUpperCase} />
</button>
<button
onClick={() => setStatusFilter('submitted')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'submitted'
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100'
}`}
>
<AdaptiveText text="Enviadas" isUpperCase={isUpperCase} />
</button>
<button
onClick={() => setStatusFilter('draft')}
className={`px-4 py-2 rounded-lg ${
statusFilter === 'draft'
? 'bg-purple-100 text-purple-700'
: 'hover:bg-gray-100'
}`}
>
<AdaptiveText text="Rascunhos" isUpperCase={isUpperCase} />
</button>
</div>
</div>
)}
</div> </div>
)}
{/* Lista de Redações */}
{filteredEssays.length === 0 ? (
<div className="p-12 text-center">
<PenTool className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
<AdaptiveText text="Nenhuma redação encontrada" isUpperCase={isUpperCase} />
</h3>
<p className="text-gray-500 mb-6">
<AdaptiveText
text={searchTerm
? 'Tente usar outros termos na busca'
: 'Comece criando sua primeira redação!'}
isUpperCase={isUpperCase}
/>
</p>
{!searchTerm && (
<Button
onClick={() => navigate('/aluno/redacoes/nova')}
className="inline-flex items-center gap-2"
trackingId="essay-empty-create-button"
trackingProperties={{
action: 'create_first_essay',
page: 'essays_list',
context: 'empty_state'
}}
>
<Plus className="h-5 w-5" />
<AdaptiveText text="Criar Redação" isUpperCase={isUpperCase} />
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6">
{filteredEssays.map((essay) => (
<div
key={essay.id}
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
onClick={() => navigate(`/aluno/redacoes/${essay.id}`)}
>
<div className="p-6">
<h3 className="font-medium text-gray-900 mb-2">
<AdaptiveText text={essay.title} isUpperCase={isUpperCase} />
</h3>
<div className="flex items-center gap-2 text-sm text-gray-600 mb-4">
<AdaptiveText text={essay.essay_type?.title} isUpperCase={isUpperCase} />
<span></span>
<AdaptiveText text={essay.essay_genre?.title} isUpperCase={isUpperCase} />
</div>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{new Date(essay.created_at).toLocaleDateString('pt-BR')}</span>
{getStatusBadge(essay.status)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -50,25 +50,55 @@ module.exports = {
DEFAULT: "hsl(var(--card))", DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))", foreground: "hsl(var(--card-foreground))",
}, },
purple: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
blue: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", none: '0',
md: "calc(var(--radius) - 2px)", sm: '0.125rem',
sm: "calc(var(--radius) - 4px)", DEFAULT: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
full: '9999px',
}, },
keyframes: { keyframes: {
"accordion-down": { "fade-in": {
from: { height: 0 }, "0%": { opacity: 0 },
to: { height: "var(--radix-accordion-content-height)" }, "100%": { opacity: 1 }
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
}, },
"fade-out": {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
}
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "fade-in": "fade-in 200ms ease-in",
"accordion-up": "accordion-up 0.2s ease-out", "fade-out": "fade-out 200ms ease-out"
}, },
typography: { typography: {
DEFAULT: { DEFAULT: {
@ -282,7 +312,6 @@ module.exports = {
}, },
}, },
plugins: [ plugins: [
require("tailwindcss-animate"),
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
], ],
} }