mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 21:37:51 +00:00
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:
parent
2929946499
commit
1c6aa56b32
@ -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 }}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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'),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user